From 167cd17cb754d7c8d116ccf280061559c1f81b49 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 21:49:00 +0900 Subject: [PATCH] Corroborate arm-time signals and tolerate agent startup in condition waits Two condition-wait behaviors made the documented flows unreliable: - `--until idle|blocked` matched whatever terminal signal was current, however old. A cooperative `needs-input` sent minutes earlier, while the agent was already idle again, ended a fresh `blocked` wait immediately. The wait now captures the arm-time terminal signal as its baseline: that signal satisfies the condition only when the screen detector corroborates it (idle/done for `idle`, blocked for `blocked`), while a signal that arrives after arming counts on its own. `changed` remains the edge wait. - A pane whose agent was still starting failed instantly with `AGENT_NOT_FOUND`. The wait now polls for up to `agentAppearanceGraceMilliseconds` (10 s), bounded by `--timeout`, and only then fails with condition-mode details; the grace counts toward `waited_ms`. The loop is split into appearance grace, `pollCondition`, and a `HeuristicStabilizer` so each phase stays within the lint budget. Claude-Session: https://claude.ai/code/session_01JgjCiPw6bdaWx3TfT84Svk --- .../CLIService/AgentWaitCommandHandler.swift | 366 +++++++++++------- .../AgentWaitCommandHandlerTests.swift | 265 ++++++++++++- 2 files changed, 498 insertions(+), 133 deletions(-) diff --git a/supacode/CLIService/AgentWaitCommandHandler.swift b/supacode/CLIService/AgentWaitCommandHandler.swift index b0290f81..a14a1964 100644 --- a/supacode/CLIService/AgentWaitCommandHandler.swift +++ b/supacode/CLIService/AgentWaitCommandHandler.swift @@ -42,6 +42,10 @@ final class AgentWaitCommandHandler: CommandHandler { case cancelled } + /// A condition wait armed right after an agent was launched tolerates this much detector + /// latency before failing with `AGENT_NOT_FOUND`; `--timeout` still bounds the whole call. + nonisolated static let agentAppearanceGraceMilliseconds = 10_000 + private let observeDispatch: ObserveDispatch private let observeCondition: ObserveCondition private let resolveConditionTarget: @MainActor (String) -> Result @@ -322,8 +326,40 @@ final class AgentWaitCommandHandler: CommandHandler { } } - // This is the single cancellation-scoped state machine for freshness and stabilization. - // swiftlint:disable:next function_body_length + private struct ConditionBaseline { + let revision: UInt64 + let changedSignal: AgentSignal? + /// Terminal evidence that already existed when the wait was armed; it satisfies `idle` or + /// `blocked` only with detector corroboration, so a stale level cannot end a fresh wait. + let terminalSignal: AgentSignal? + let state: String + } + + private enum AgentAppearance { + case appeared(ConditionSnapshot, elapsedMilliseconds: Int) + case failed(CommandResponse) + } + + /// Tracks how long a heuristic candidate state has been unchanged; `auto` accepts it only + /// after two seconds so a transient screen never resolves a wait. + private struct HeuristicStabilizer { + private var state: String? + private var sinceMilliseconds = 0 + + mutating func observe(candidate: String?, elapsedMilliseconds: Int) -> Bool { + guard let candidate else { + state = nil + return false + } + if state != candidate { + state = candidate + sinceMilliseconds = elapsedMilliseconds + return false + } + return elapsedMilliseconds - sinceMilliseconds >= 2_000 + } + } + private func waitForCondition(_ input: AgentWaitInput) async -> CommandResponse { guard let pane = input.pane, let condition = input.condition, input.dispatchID == nil else { return failure(code: CLIErrorCode.invalidArgument, message: "Condition wait requires a pane and condition.") @@ -341,129 +377,187 @@ final class AgentWaitCommandHandler: CommandHandler { return failure(code: CLIErrorCode.targetNotFound, message: "The pane identifier is invalid.") } - let initial = conditionSnapshot(target) - if let initialFailure = await initialConditionFailure( - condition: condition, - target: target, - snapshot: initial, - includeScreenLines: input.includeScreenLines - ) { - return initialFailure - } - let baselineRevision = initial.revision - let baselineSignal = initial.changedSignal - let baselineState = normalizedState(initial) - var stableState: String? - var stableSinceMilliseconds = 0 - var elapsedMilliseconds = 0 let timeoutMilliseconds = input.timeoutSeconds * 1_000 + var elapsedMilliseconds = 0 + var initial = conditionSnapshot(target) + if !initial.isLive, condition != .exit { + return await conditionGoneFailure( + condition: condition, + waitedMilliseconds: 0, + target: target, + snapshot: initial, + includeScreenLines: input.includeScreenLines + ) + } + if initial.agent == nil, condition != .exit { + switch await awaitAgentAppearance( + condition: condition, + target: target, + initial: initial, + graceMilliseconds: min(timeoutMilliseconds, Self.agentAppearanceGraceMilliseconds), + includeScreenLines: input.includeScreenLines + ) { + case .failed(let response): + return response + case .appeared(let snapshot, let waited): + initial = snapshot + elapsedMilliseconds = waited + } + } + let baseline = ConditionBaseline( + revision: initial.revision, + changedSignal: initial.changedSignal, + terminalSignal: initial.signal, + state: normalizedState(initial) + ) let observationPump = AgentWaitObservationPump() observationPump.start(surfaceID: surfaceID, observe: observeCondition) defer { observationPump.cancel() } return await withTaskCancellationHandler { - while elapsedMilliseconds <= timeoutMilliseconds { - if Task.isCancelled { - return failure(code: CLIErrorCode.timeout, message: "The wait was cancelled.") + await pollCondition( + input, + condition: condition, + target: target, + baseline: baseline, + elapsedMilliseconds: elapsedMilliseconds, + timeoutMilliseconds: timeoutMilliseconds + ) + } onCancel: { + observationPump.cancel() + } + } + + // This is the single cancellation-scoped state machine for freshness and stabilization. + // swiftlint:disable:next function_parameter_count + private func pollCondition( + _ input: AgentWaitInput, + condition: AgentWaitCondition, + target: TabResolvedTarget, + baseline: ConditionBaseline, + elapsedMilliseconds startMilliseconds: Int, + timeoutMilliseconds: Int + ) async -> CommandResponse { + let minimumConfidence = input.minimumConfidence ?? .auto + var elapsedMilliseconds = startMilliseconds + var stabilizer = HeuristicStabilizer() + while elapsedMilliseconds <= timeoutMilliseconds { + if Task.isCancelled { + return failure(code: CLIErrorCode.timeout, message: "The wait was cancelled.") + } + let snapshot = conditionSnapshot(target) + if !snapshot.isLive, condition != .exit { + return await conditionGoneFailure( + condition: condition, + waitedMilliseconds: elapsedMilliseconds, + target: target, + snapshot: snapshot, + includeScreenLines: input.includeScreenLines + ) + } + let state = normalizedState(snapshot) + var observation = exactMatch( + condition: condition, + snapshot: snapshot, + normalizedState: state, + baseline: baseline, + minimumConfidence: minimumConfidence + ) + if observation == nil { + let candidate = + allowsHeuristic(minimumConfidence, condition: condition, signals: snapshot.signals) + && heuristicMatches(condition: condition, snapshot: snapshot, normalizedState: state, baseline: baseline) + if stabilizer.observe(candidate: candidate ? state : nil, elapsedMilliseconds: elapsedMilliseconds) { + observation = heuristicObservation(snapshot, state: state) } - let snapshot = conditionSnapshot(target) - if !snapshot.isLive, condition != .exit { - return await conditionGoneFailure( + } + if let observation { + return await conditionSuccess( + condition: condition, + waitedMilliseconds: elapsedMilliseconds, + target: target, + observation: observation, + signals: snapshot.signals, + includeScreenLines: input.includeScreenLines + ) + } + + guard elapsedMilliseconds < timeoutMilliseconds else { break } + do { + try await clock.sleep(for: .milliseconds(200)) + } catch { + return failure(code: CLIErrorCode.timeout, message: "The wait was cancelled.") + } + elapsedMilliseconds += 200 + } + let last = conditionSnapshot(target) + let details = AgentWaitErrorDetails.condition( + AgentConditionWaitErrorDetails( + condition: condition, + waitedMilliseconds: min(elapsedMilliseconds, timeoutMilliseconds), + target: TabTarget(from: target), + observation: heuristicObservation(last, state: normalizedState(last)), + signals: last.signals, + screen: await stableScreen( + requestedLines: input.includeScreenLines, + target: TabTarget(from: target) + ) + )) + return failure( + code: CLIErrorCode.waitTimeout, + message: "Timed out waiting for the agent condition.", + details: details + ) + } + + /// Polls until the detector publishes an agent for the pane, the surface closes, or the grace + /// budget is spent; the elapsed time counts toward the caller's `waited_ms`. + private func awaitAgentAppearance( + condition: AgentWaitCondition, + target: TabResolvedTarget, + initial: ConditionSnapshot, + graceMilliseconds: Int, + includeScreenLines: Int? + ) async -> AgentAppearance { + var snapshot = initial + var elapsedMilliseconds = 0 + while snapshot.agent == nil { + guard elapsedMilliseconds < graceMilliseconds else { + return .failed( + await agentNotFoundFailure( condition: condition, waitedMilliseconds: elapsedMilliseconds, target: target, snapshot: snapshot, - includeScreenLines: input.includeScreenLines - ) - } - if let observation = exactMatch( - condition: condition, - snapshot: snapshot, - baselineRevision: baselineRevision, - baselineSignal: baselineSignal, - minimumConfidence: input.minimumConfidence ?? .auto - ) { - return await conditionSuccess( + includeScreenLines: includeScreenLines + )) + } + do { + try await clock.sleep(for: .milliseconds(200)) + } catch { + return .failed(failure(code: CLIErrorCode.timeout, message: "The wait was cancelled.")) + } + elapsedMilliseconds += 200 + snapshot = conditionSnapshot(target) + if !snapshot.isLive { + return .failed( + await conditionGoneFailure( condition: condition, waitedMilliseconds: elapsedMilliseconds, target: target, - observation: observation, - signals: snapshot.signals, - includeScreenLines: input.includeScreenLines - ) - } - - let state = normalizedState(snapshot) - let heuristicEligible = allowsHeuristic( - input.minimumConfidence ?? .auto, - condition: condition, - signals: snapshot.signals - ) - let heuristicCandidate = - heuristicEligible - && heuristicMatches( - condition: condition, snapshot: snapshot, - normalizedState: state, - baselineState: baselineState, - baselineRevision: baselineRevision - ) - if heuristicCandidate { - if stableState != state { - stableState = state - stableSinceMilliseconds = elapsedMilliseconds - } else if elapsedMilliseconds - stableSinceMilliseconds >= 2_000, - let observation = heuristicObservation(snapshot, state: state) - { - return await conditionSuccess( - condition: condition, - waitedMilliseconds: elapsedMilliseconds, - target: target, - observation: observation, - signals: snapshot.signals, - includeScreenLines: input.includeScreenLines - ) - } - } else { - stableState = nil - } - - guard elapsedMilliseconds < timeoutMilliseconds else { break } - do { - try await clock.sleep(for: .milliseconds(200)) - } catch { - return failure(code: CLIErrorCode.timeout, message: "The wait was cancelled.") - } - elapsedMilliseconds += 200 + includeScreenLines: includeScreenLines + )) } - let last = conditionSnapshot(target) - let details = AgentWaitErrorDetails.condition( - AgentConditionWaitErrorDetails( - condition: condition, - waitedMilliseconds: min(elapsedMilliseconds, timeoutMilliseconds), - target: TabTarget(from: target), - observation: heuristicObservation(last, state: normalizedState(last)), - signals: last.signals, - screen: await stableScreen( - requestedLines: input.includeScreenLines, - target: TabTarget(from: target) - ) - )) - return failure( - code: CLIErrorCode.waitTimeout, - message: "Timed out waiting for the agent condition.", - details: details - ) - } onCancel: { - observationPump.cancel() } + return .appeared(snapshot, elapsedMilliseconds: elapsedMilliseconds) } private func exactMatch( condition: AgentWaitCondition, snapshot: ConditionSnapshot, - baselineRevision: UInt64, - baselineSignal: AgentSignal?, + normalizedState: String, + baseline: ConditionBaseline, minimumConfidence: AgentWaitMinimumConfidence ) -> AgentWaitObservation? { let signal = condition == .changed ? snapshot.changedSignal : snapshot.signal @@ -482,11 +576,15 @@ final class AgentWaitCommandHandler: CommandHandler { } return nil } + let isPreArmLevel = condition != .changed && signal == baseline.terminalSignal let matches = switch condition { - case .idle: signal.event == .turnEnded - case .blocked: signal.event == .needsInput - case .changed: snapshot.revision > baselineRevision && signal != baselineSignal + case .idle: + signal.event == .turnEnded && (!isPreArmLevel || detectorReports(.idle, normalizedState: normalizedState)) + case .blocked: + signal.event == .needsInput + && (!isPreArmLevel || detectorReports(.blocked, normalizedState: normalizedState)) + case .changed: snapshot.revision > baseline.revision && signal != baseline.changedSignal case .exit: signal.event == .sessionEnd } guard matches else { return nil } @@ -500,6 +598,17 @@ final class AgentWaitCommandHandler: CommandHandler { ) } + /// Whether the screen detector currently reports the requested `idle` or `blocked` condition. + private func detectorReports(_ condition: AgentWaitCondition, normalizedState: String) -> Bool { + switch condition { + case .idle: + normalizedState == AgentsCommandStatus.idle.rawValue || normalizedState == AgentsCommandStatus.done.rawValue + case .blocked: + normalizedState == AgentsCommandStatus.blocked.rawValue + case .changed, .exit: + false + } + } private func accepts( _ confidence: AgentSignal.Confidence, minimum: AgentWaitMinimumConfidence @@ -539,18 +648,14 @@ final class AgentWaitCommandHandler: CommandHandler { condition: AgentWaitCondition, snapshot: ConditionSnapshot, normalizedState: String, - baselineState: String, - baselineRevision: UInt64 + baseline: ConditionBaseline ) -> Bool { switch condition { - case .idle: - normalizedState == AgentsCommandStatus.idle.rawValue || normalizedState == AgentsCommandStatus.done.rawValue - case .blocked: normalizedState == AgentsCommandStatus.blocked.rawValue - case .changed: snapshot.revision > baselineRevision && normalizedState != baselineState + case .idle, .blocked: detectorReports(condition, normalizedState: normalizedState) + case .changed: snapshot.revision > baseline.revision && normalizedState != baseline.state case .exit: !snapshot.isLive || snapshot.agent == nil } } - private func normalizedState(_ snapshot: ConditionSnapshot) -> String { guard snapshot.isLive else { return "gone" } return snapshot.agent.map { status(for: $0, fallback: .idle).rawValue } ?? "absent" @@ -571,30 +676,27 @@ final class AgentWaitCommandHandler: CommandHandler { ) } - private func initialConditionFailure( + private func agentNotFoundFailure( condition: AgentWaitCondition, + waitedMilliseconds: Int, target: TabResolvedTarget, snapshot: ConditionSnapshot, includeScreenLines: Int? - ) async -> CommandResponse? { - if !snapshot.isLive, condition != .exit { - return await conditionGoneFailure( - condition: condition, - waitedMilliseconds: 0, - target: target, - snapshot: snapshot, - includeScreenLines: includeScreenLines - ) - } - guard snapshot.agent != nil || condition == .exit else { - return failure( - code: CLIErrorCode.agentNotFound, - message: "No detected agent is active in the selected pane." - ) - } - return nil + ) async -> CommandResponse { + let payloadTarget = TabTarget(from: target) + return failure( + code: CLIErrorCode.agentNotFound, + message: "No detected agent became active in the selected pane.", + details: .condition( + AgentConditionWaitErrorDetails( + condition: condition, + waitedMilliseconds: waitedMilliseconds, + target: payloadTarget, + signals: snapshot.signals, + screen: await stableScreen(requestedLines: includeScreenLines, target: payloadTarget) + )) + ) } - private func conditionGoneFailure( condition: AgentWaitCondition, waitedMilliseconds: Int, diff --git a/supacodeTests/AgentWaitCommandHandlerTests.swift b/supacodeTests/AgentWaitCommandHandlerTests.swift index 0f726d03..2aeeb2e6 100644 --- a/supacodeTests/AgentWaitCommandHandlerTests.swift +++ b/supacodeTests/AgentWaitCommandHandlerTests.swift @@ -463,6 +463,261 @@ struct AgentWaitCommandHandlerTests { _ = await task.value } + @Test func preArmNeedsInputNeedsBlockedDetectorBeforeSatisfyingBlocked() async { + let clock = TestClock() + let target = resolvedTarget() + let working = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) + let staleNeedsInput = signal(.needsInput, at: Self.start) + let handler = AgentWaitCommandHandler( + observeDispatch: { _ in .failure(.notFound) }, + resolveConditionTarget: { _ in .success(target) }, + conditionSnapshot: { _ in + .init(agent: working, signal: staleNeedsInput, revision: 3, isLive: true, signals: .empty) + }, + clock: clock, + now: { Self.start } + ) + let task = Task { + await handler.handle(envelope: conditionWait(target, .blocked, timeout: 1, minimumConfidence: .exact)) + } + + for _ in 0..<6 { + await Task.yield() + await clock.advance(by: .milliseconds(200)) + } + let response = await task.value + #expect(response.error?.code == CLIErrorCode.waitTimeout) + } + + @Test func preArmNeedsInputSatisfiesBlockedWhenDetectorAgrees() async throws { + let target = resolvedTarget() + let blocked = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .blocked) + let needsInput = signal(.needsInput, at: Self.start) + let handler = AgentWaitCommandHandler( + observeDispatch: { _ in .failure(.notFound) }, + resolveConditionTarget: { _ in .success(target) }, + conditionSnapshot: { _ in + .init(agent: blocked, signal: needsInput, revision: 3, isLive: true, signals: .empty) + } + ) + let response = await handler.handle( + envelope: conditionWait(target, .blocked, timeout: 1, minimumConfidence: .exact) + ) + #expect(response.ok) + let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) + guard case .condition(let condition) = payload else { + Issue.record("Expected condition payload") + return + } + #expect(condition.observation.source == "cooperative_cli") + #expect(condition.observation.status == .blocked) + } + + @Test func needsInputArrivingAfterArmSatisfiesBlockedWithoutDetectorCorroboration() async throws { + let clock = TestClock() + let target = resolvedTarget() + let working = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) + let preArm = signal(.turnEnded, at: Self.start) + let fresh = signal(.needsInput, at: Self.start.addingTimeInterval(5)) + var snapshotReads = 0 + let handler = AgentWaitCommandHandler( + observeDispatch: { _ in .failure(.notFound) }, + resolveConditionTarget: { _ in .success(target) }, + conditionSnapshot: { _ in + snapshotReads += 1 + return .init( + agent: working, + signal: snapshotReads > 3 ? fresh : preArm, + revision: snapshotReads > 3 ? 4 : 3, + isLive: true, + signals: .empty + ) + }, + clock: clock, + now: { Self.start } + ) + let task = Task { + await handler.handle(envelope: conditionWait(target, .blocked, timeout: 5, minimumConfidence: .exact)) + } + + for _ in 0..<4 { + await Task.yield() + await clock.advance(by: .milliseconds(200)) + } + let response = await task.value + #expect(response.ok) + let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) + guard case .condition(let condition) = payload else { + Issue.record("Expected condition payload") + return + } + #expect(condition.observation.source == "cooperative_cli") + #expect(condition.observation.status == .working) + #expect(condition.waitedMilliseconds == 400) + } + + @Test func preArmTurnEndedNeedsIdleDetectorBeforeSatisfyingIdle() async { + let clock = TestClock() + let target = resolvedTarget() + let working = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .working) + let staleTurnEnded = signal(.turnEnded, at: Self.start) + let handler = AgentWaitCommandHandler( + observeDispatch: { _ in .failure(.notFound) }, + resolveConditionTarget: { _ in .success(target) }, + conditionSnapshot: { _ in + .init(agent: working, signal: staleTurnEnded, revision: 3, isLive: true, signals: .empty) + }, + clock: clock, + now: { Self.start } + ) + let task = Task { + await handler.handle(envelope: conditionWait(target, .idle, timeout: 1, minimumConfidence: .exact)) + } + + for _ in 0..<6 { + await Task.yield() + await clock.advance(by: .milliseconds(200)) + } + let response = await task.value + #expect(response.error?.code == CLIErrorCode.waitTimeout) + } + + @Test func agentAppearingWithinGraceStartsTheConditionWait() async throws { + let clock = TestClock() + let target = resolvedTarget() + let idle = agentEntry(surfaceID: UUID(uuidString: target.paneID)!, status: .idle) + let turnEnded = signal(.turnEnded, at: Self.start.addingTimeInterval(1)) + var snapshotReads = 0 + let handler = AgentWaitCommandHandler( + observeDispatch: { _ in .failure(.notFound) }, + resolveConditionTarget: { _ in .success(target) }, + conditionSnapshot: { _ in + snapshotReads += 1 + let appeared = snapshotReads > 3 + return .init( + agent: appeared ? idle : nil, + signal: appeared ? turnEnded : nil, + revision: appeared ? 1 : 0, + isLive: true, + signals: .empty + ) + }, + clock: clock, + now: { Self.start } + ) + let task = Task { + await handler.handle(envelope: conditionWait(target, .idle, timeout: 60, minimumConfidence: .exact)) + } + + for _ in 0..<4 { + await Task.yield() + await clock.advance(by: .milliseconds(200)) + } + let response = await task.value + #expect(response.ok) + let payload = try #require(response.data).decode(as: AgentWaitCommandPayload.self) + guard case .condition(let condition) = payload else { + Issue.record("Expected condition payload") + return + } + #expect(condition.observation.source == "cooperative_cli") + #expect(condition.waitedMilliseconds == 600) + } + + @Test func agentNeverAppearingFailsWithAgentNotFoundAfterGrace() async throws { + let clock = TestClock() + let target = resolvedTarget() + let handler = AgentWaitCommandHandler( + observeDispatch: { _ in .failure(.notFound) }, + resolveConditionTarget: { _ in .success(target) }, + conditionSnapshot: { _ in + .init(agent: nil, signal: nil, revision: 0, isLive: true, signals: .empty) + }, + clock: clock, + now: { Self.start } + ) + let task = Task { + await handler.handle(envelope: conditionWait(target, .blocked, timeout: 600)) + } + + let graceTicks = AgentWaitCommandHandler.agentAppearanceGraceMilliseconds / 200 + for _ in 0.. CommandEnvelope { + CommandEnvelope( + output: .json, + command: .agentsWait( + AgentWaitInput( + mode: .condition, + pane: target.paneID, + condition: condition, + timeoutSeconds: timeout, + minimumConfidence: minimumConfidence + ) + ) + ) + } + + private func signal(_ event: AgentSignal.Kind, at timestamp: Date) -> AgentSignal { + AgentSignal( + kind: event, + source: .cooperativeCLI, + confidence: .exact, + timestamp: timestamp, + sessionID: nil, + detail: nil, + claimedOrigin: nil + ) + } + private static let start = Date(timeIntervalSince1970: 1_000) private func handler(dispatchSnapshot: AgentDispatchSnapshot) -> AgentWaitCommandHandler { @@ -535,9 +790,17 @@ struct AgentWaitCommandHandlerTests { paneIndex: 0, iconLookupToken: "codex", agent: .codex, - rawState: status == .idle ? .idle : .working, + rawState: Self.rawState(for: status), displayState: status, lastChangedAt: Self.start ) } + + private static func rawState(for status: AgentDisplayState) -> AgentRawState { + switch status { + case .working: .working + case .blocked: .blocked + case .idle, .done: .idle + } + } } -- 2.51.2