From 34205d79e1ea77e5a464c165af3d95fc36419e06 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 23 Aug 2026 19:26:58 +0900 Subject: [PATCH] fix(agents): bound launch generation acquisition --- .../013-prowl-cli/contracts/agents-wait.md | 9 ++++-- .../003-s2-dispatch-wait-design.md | 5 ++- .../004-s2-work-note.md | 3 ++ .../005-s2-action.md | 4 ++- docs/components/agent-detection.md | 9 ++++-- .../BusinessLogic/AgentObservationStore.swift | 32 +++++++++++++++++-- supacodeTests/AgentEvidenceEpochTests.swift | 23 +++++++++++++ 7 files changed, 74 insertions(+), 11 deletions(-) diff --git a/docs-ai/013-prowl-cli/contracts/agents-wait.md b/docs-ai/013-prowl-cli/contracts/agents-wait.md index e6e4b1ad..165b4e1c 100644 --- a/docs-ai/013-prowl-cli/contracts/agents-wait.md +++ b/docs-ai/013-prowl-cli/contracts/agents-wait.md @@ -71,9 +71,12 @@ revision remain unchanged for two seconds. Higher minimum-confidence settings re evidence rather than relabelling it. Evidence is bound to PID plus process start time and, when known at exact/high confidence, -the current session id. Medium-confidence session guesses remain diagnostic and never bind or -rotate an evidence epoch. PID reuse, delayed children, replaced sessions, mismatched sessions, -and unverifiable sessionless signals remain diagnostic only. Generic success and timeout +the current session id. A dispatch launch accepts its first detected process generation only +when that process started within ten seconds of binding; a later-started process is a +replacement epoch even if the original runtime escaped detection. Medium-confidence session +guesses remain diagnostic and never bind or rotate an evidence epoch. PID reuse, delayed +children, replaced sessions, mismatched sessions, and unverifiable sessionless signals remain +diagnostic only. Generic success and timeout details report the actual source, confidence, timestamp, revision, and current signal channels. When requested, screen evidence reads the detection buffer every 200 ms until unchanged for diff --git a/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md b/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md index 4c5bde12..fba7025d 100644 --- a/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md +++ b/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md @@ -324,7 +324,10 @@ Each surface has an evidence epoch minted for the CLI dispatch launch (or the fi agent detection on an unpaired surface). Detection records an `AgentProcessGeneration` as the agent PID plus its process start time; a stable replacement mints a new epoch even when the detected agent kind is unchanged. The first generation observed after dispatch launch -attaches to the launch epoch rather than minting another one. +attaches to the launch epoch only when its process start time falls within ten seconds of +launch binding. Observation itself may arrive later. A process started outside that acquisition +window mints a replacement epoch, so an initially missed short-lived runtime cannot lend its +old dispatch to an unrelated agent launched later in the same pane. S2 extends caller resolution to retain the process ancestry walked before the pane shell. Surface attribution is enough to accept and retain a cooperative signal, but not enough to diff --git a/docs-ai/064-agent-completion-signals/004-s2-work-note.md b/docs-ai/064-agent-completion-signals/004-s2-work-note.md index 87ae5286..33277e2a 100644 --- a/docs-ai/064-agent-completion-signals/004-s2-work-note.md +++ b/docs-ai/064-agent-completion-signals/004-s2-work-note.md @@ -102,6 +102,9 @@ recorded in the existing 064 plan/action documents. - 2026-08-23 — Review follow-up made requested stable-screen evidence symmetric across success and structured dispatch/condition errors. Handler and executable-schema regressions cover a failed receipt and a condition timeout without changing their primary exit codes. +- 2026-08-23 — Review follow-up bounded first-generation attachment by process start time. A + launch generation started within ten seconds still attaches even if observed late; a process + started after that window rotates the epoch and cannot drive the missed dispatch. ## Fresh Debug E2E diff --git a/docs-ai/064-agent-completion-signals/005-s2-action.md b/docs-ai/064-agent-completion-signals/005-s2-action.md index 0b48964c..b0ab10f3 100644 --- a/docs-ai/064-agent-completion-signals/005-s2-action.md +++ b/docs-ai/064-agent-completion-signals/005-s2-action.md @@ -20,7 +20,9 @@ Implemented and fully validated on `feat/agent-dispatch-wait-s2`; draft PR [#718 two-second heuristic fallback, optional stable detection-screen evidence on success and structured errors, overflow resubscription, and request cancellation cleanup. - Cooperative evidence is bound to PID plus process start time and an exact/high current - session where available; medium session guesses remain diagnostic. Stale, mismatched, and + session where available; medium session guesses remain diagnostic. The first dispatch process + generation must have started within ten seconds of launch binding, preventing a missed + short-lived runtime from lending its epoch to a later agent. Stale, mismatched, and unverifiable signals remain diagnostic. Existing detected-agent JSON rows expose current-epoch signal channels without adding shell rows. - The socket server monitors peer EOF/extra input after the request frame and cancels the diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 0c985a31..cb4217a7 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -122,9 +122,12 @@ workflow, and only a matching `agents dispatch-complete` can complete an exact d Signal eligibility is generation-aware. Prowl binds evidence to the detected process's PID **and process start time**, plus its current session only when that attribution is exact or -high confidence. A medium-confidence session guess remains diagnostic and never rotates an -evidence epoch. Evidence from a reused PID, a replaced session, a delayed child, or an -unverifiable sessionless sender stays diagnostic and cannot advance a wait. +high confidence. A dispatch launch accepts its first process generation only when the process +started within ten seconds of launch binding; a later-started process is a replacement even if +the original runtime exited before detection. A medium-confidence session guess remains +diagnostic and never rotates an evidence epoch. Evidence from a reused PID, a replaced session, +a delayed child, or an unverifiable sessionless sender stays diagnostic and cannot advance a +wait. `prowl agents --json` exposes current-epoch channels under each existing detected-agent row's `signals`; evidence-only shell panes are not added to the roster. diff --git a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift index 2aae2bb9..e20c5d88 100644 --- a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift +++ b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift @@ -28,6 +28,7 @@ final class AgentObservationStore { var sessionlessSignalsAllowed = true var evidenceEpoch = UUID() var awaitingFirstProcessGeneration = false + var firstProcessGenerationStartedBefore: Date? var channels: [String: AgentSignalChannelRecord] = [:] var revision: UInt64 = 0 var subscribers: [UUID: AgentObservationStream.Continuation] = [:] @@ -43,9 +44,19 @@ final class AgentObservationStore { private var records: [UUID: SurfaceRecord] = [:] private let bufferCapacity: Int + private let now: @MainActor () -> Date + /// The launched process starts immediately; this spans more than three idle detection polls + /// while refusing a manually started replacement long after a missed short-lived runtime. + private let dispatchGenerationWindow: TimeInterval - init(bufferCapacity: Int) { + init( + bufferCapacity: Int, + now: @escaping @MainActor () -> Date = Date.init, + dispatchGenerationWindow: TimeInterval = 10 + ) { self.bufferCapacity = max(1, bufferCapacity) + self.now = now + self.dispatchGenerationWindow = max(0, dispatchGenerationWindow) } func observe(surfaceID: UUID, isLive: Bool) -> AgentObservationStream { @@ -190,6 +201,9 @@ final class AgentObservationStore { record.sessionID = nil record.sessionlessSignalsAllowed = true record.awaitingFirstProcessGeneration = true + record.firstProcessGenerationStartedBefore = now().addingTimeInterval( + dispatchGenerationWindow + ) record.channels.removeAll() record.latestCurrentSignal = nil record.activeTerminalSignal = nil @@ -208,11 +222,22 @@ final class AgentObservationStore { sessionID: String? ) { var record = records[surfaceID] ?? SurfaceRecord() + let firstGenerationIsTimely = + processGeneration.map { + $0.startedAt <= (record.firstProcessGenerationStartedBefore ?? .distantPast) + } ?? false let attachesFirstLaunchGeneration = + record.awaitingFirstProcessGeneration + && record.processGeneration == nil + && firstGenerationIsTimely + let rejectsLateFirstGeneration = record.awaitingFirstProcessGeneration && record.processGeneration == nil && processGeneration != nil - let processChanged = !attachesFirstLaunchGeneration && record.processGeneration != processGeneration + && !firstGenerationIsTimely + let processChanged = + rejectsLateFirstGeneration + || (!attachesFirstLaunchGeneration && record.processGeneration != processGeneration) let sessionChanged = !processChanged && record.sessionID != nil @@ -226,8 +251,9 @@ final class AgentObservationStore { if record.latestSignal != nil { record.latestSignalBinding = .stale } record.sessionlessSignalsAllowed = processChanged } - if attachesFirstLaunchGeneration { + if attachesFirstLaunchGeneration || rejectsLateFirstGeneration { record.awaitingFirstProcessGeneration = false + record.firstProcessGenerationStartedBefore = nil } record.processGeneration = processGeneration record.sessionID = sessionID diff --git a/supacodeTests/AgentEvidenceEpochTests.swift b/supacodeTests/AgentEvidenceEpochTests.swift index 6927d296..41da67ad 100644 --- a/supacodeTests/AgentEvidenceEpochTests.swift +++ b/supacodeTests/AgentEvidenceEpochTests.swift @@ -19,6 +19,29 @@ struct AgentEvidenceEpochTests { #expect(store.currentEvidenceEpoch(surfaceID: surfaceID) != dispatchEpoch) } + @Test func dispatchEpochRejectsFirstGenerationThatStartedAfterAcquisitionWindow() { + let launchTime = Date(timeIntervalSince1970: 1_000) + let store = AgentObservationStore( + bufferCapacity: 8, + now: { launchTime }, + dispatchGenerationWindow: 10 + ) + let surfaceID = UUID() + let dispatchEpoch = store.beginDispatchEpoch(surfaceID: surfaceID) + let unrelatedLaterProcess = AgentProcessGeneration( + pid: 42, + startedAt: launchTime.addingTimeInterval(11) + ) + + store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: unrelatedLaterProcess, + sessionID: nil + ) + + #expect(store.currentEvidenceEpoch(surfaceID: surfaceID) != dispatchEpoch) + } + @Test func pidStartTimeAndSessionReplacementInvalidateCurrentChannels() { let store = AgentObservationStore(bufferCapacity: 8) let surfaceID = UUID() -- 2.51.2