Skip to content

Expired-token stderr is not classified as auth on the spawn path, surfacing as "session/new timed out" #6140

Description

@SebastianYuSun

Summary

There are two auth classifiers in the ACP layer, and they have drifted apart by call path.

  • The JSON-RPC error-frame path reaches the full vocabulary: _is_session_expired (401/403 status, expiry wording, rejected bearer token) plus _RE_AUTH (the named service exceptions).
  • The spawn / session/new path has its own detector, AcpRuntime.saw_not_logged_in(), which matches a single literal banner: not logged in.

Real expired-token output uses none of that banner's wording, so the spawn path discards an auth signal this codebase can already read, and the operator is shown Request session/new timed out after 90s.

Where

Anchor Role
src/kiro_crew/acp/runtime.py saw_not_logged_in — matched only _NOT_LOGGED_IN_RE
src/kiro_crew/acp/client.py _NOT_LOGGED_IN_RE = re.compile(r"not\s+logged\s+in") — the whole spawn-path vocabulary
src/kiro_crew/acp/client.py _is_session_expired — the strong classifier
src/kiro_crew/acp/client.py its only two call sites, both error-frame-scoped
src/kiro_crew/providers/acp.py three AcpAuthRequired gates, all on the weak detector
src/kiro_crew/acp/session_provider.py two more, same weak gate
src/kiro_crew/acp/runtime.py emits Request session/new timed out after 90s

Reproduction

The stderr below is real kiro-cli output captured during an actual expired IAM Identity Center bearer token (kiro-cli 2.19.1). The banner the weak detector needs appears nowhere in it.

import asyncio
from kiro_crew.acp.runtime import AcpRuntime
from kiro_crew.acp.client import _is_session_expired

STDERR = [
    'GetProfile failed: AccessDeniedException: "Invalid token" (HTTP 400)',
    "Failed to fetch models from API: service error, using fallback list",
    "Access denied: The bearer token included in the request is invalid.",
]

class _Stderr:
    def __init__(self, lines): self._lines = [f"{l}\n".encode() for l in lines]
    async def readline(self): return self._lines.pop(0) if self._lines else b""

class _Proc:
    def __init__(self, lines): self.stderr = _Stderr(lines)

async def main():
    rt = AcpRuntime.__new__(AcpRuntime)
    rt._stderr_lines = []
    rt._process = _Proc(STDERR)
    await rt._drain_stderr()                      # the real capture path
    print("spawn path  saw_not_logged_in()  =", rt.saw_not_logged_in())
    print("frame path  _is_session_expired() =", _is_session_expired("\n".join(STDERR)))

asyncio.run(main())

Observed:

spawn path  saw_not_logged_in()  = False
frame path  _is_session_expired() = True

The two classifiers disagree on the identical text. The signal is lost, not absent — the same stderr, arriving as an error frame, already produces a correct sign-in message.

Why this is not just a message

AcpAuthRequired is documented as non-retryable, and client.py already treats _RE_AUTH as terminal specifically so the retry ladder is skipped. Failing to reach it means an expired credential burns the full retry ladder and the whole 90-second budget per attempt, on a condition no retry can fix. The user-visible timeout also collides with two unrelated causes that produce the same string, so the wrong remediation gets applied.

Secondary hazard: the observation can be evicted before anyone asks

_stderr_lines is trimmed to the last 20 lines, and nothing asks about auth until a request has already timed out. On a chatty startup the auth line is gone by then, so a detector that re-scans the buffer answers "no auth problem" — indistinguishable from a real negative. Any fix needs to latch the observation at the point lines arrive, not re-scan a ring buffer.

Reproduced by putting the auth line first and following it with 40 lines of noise: the buffer holds 20 entries, none containing the auth line, and a buffer-scanning detector reports False.

Related prior work

A fix is up shortly; I have one prepared.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: agentsACP runtime, sub-agents, session lifecyclebugSomething is not working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions