From e67e217ff9f2abca13890b580a27c5fbb8c7ea16 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 06:16:28 +0000 Subject: [PATCH 01/50] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stop=20heartbeats?= =?UTF-8?q?=20and=20background=20wakes=20from=20pausing=20goals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three mechanisms made scheduled heartbeats appear to pause active goals (diagnosed from live session data): 1. Queue race: a message typed while the goal-creating turn was still streaming dispatched right after the queued set_goal applied and auto-paused the brand-new goal. MessageQueue now stamps entries with enqueuedAtMs, and goal safety skips the pause when the goal was created after the message was typed. 2. Fragile kickoff window: chat-tail reconciliation paused active goals whose in-memory kickoff candidate was lost (restart, eviction) before the first continuation fired — the next getGoal (heartbeat/wake tool assembly runs one every turn) silently flipped them to paused. Goals with lastContinuationFiredAtMs == null are now exempt from the active→paused manual_user reconciliation, making the kickoff window durable and self-healing. 3. Accounting noise: recordStreamAccounting charged paused/complete goals for maintenance streams (heartbeats, background wake turns), bumping updatedAtMs so every heartbeat looked like it had just touched the paused goal. Only goal-driven origins (goal_continuation / goal_budget_limit) now charge non-active goals, mirroring attributeChildReport. --- .../services/agentSession.disposeRace.test.ts | 14 ++-- .../agentSession.goalAutoPause.test.ts | 37 +++++++++ src/node/services/agentSession.ts | 37 +++++++-- src/node/services/messageQueue.ts | 13 +++- .../services/workspaceGoalService.test.ts | 78 +++++++++++++++++-- src/node/services/workspaceGoalService.ts | 25 +++++- 6 files changed, 184 insertions(+), 20 deletions(-) diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 27a8427fa27..ab65098f998 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -553,7 +553,7 @@ describe("AgentSession disposal race conditions", () => { ( _message: string, _options?: { model: string; agentId: string }, - _internal?: { synthetic?: boolean } + _internal?: { synthetic?: boolean; enqueuedAtMs?: number } ) => Promise.resolve(Ok(undefined)) ); @@ -567,10 +567,12 @@ describe("AgentSession disposal race conditions", () => { session.sendQueuedMessages(); expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith( - "Background compaction request", - expect.objectContaining({ model: "anthropic:claude-sonnet-4-5", agentId: "compact" }), - { synthetic: true } - ); + const [text, options, internal] = sendMessage.mock.calls[0] ?? []; + expect(text).toBe("Background compaction request"); + expect(options).toMatchObject({ model: "anthropic:claude-sonnet-4-5", agentId: "compact" }); + // Queue dispatch stamps enqueuedAtMs alongside preserved internal flags + // (goal safety uses it to detect messages that predate a fresh goal). + expect(internal?.synthetic).toBe(true); + expect(typeof internal?.enqueuedAtMs).toBe("number"); }); }); diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index d3b7e37676f..2ee82d3f3b2 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -204,6 +204,43 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("queued manual messages that predate the goal do not pause it", async () => { + // Queue race: the user types while the goal-creating turn is still + // streaming; the model's set_goal applies at stream end, and only then + // does the queued message dispatch. It must not pause a goal the user had + // not seen (observed as goals "paused by heartbeats" half a second after + // creation). + const workspaceId = "queued-predates-goal"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const enqueuedAtMs = Date.now(); + const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); + expect(created.createdAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + + const result = await session.sendMessage("Queued before the goal existed", SEND_OPTIONS, { + enqueuedAtMs, + }); + + expect(result.success).toBe(true); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" }); + session.dispose(); + }); + + test("queued manual messages enqueued after goal creation still pause it", async () => { + const workspaceId = "queued-postdates-goal"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { workspaceId, objective: "Existing goal" }); + + const result = await session.sendMessage("Typed with the goal in view", SEND_OPTIONS, { + enqueuedAtMs: created.createdAtMs + 1, + }); + + expect(result.success).toBe(true); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "paused" }); + session.dispose(); + }); + test("manual user messages are no-ops when no goal exists", async () => { const workspaceId = "manual-no-goal"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index f536d32a14d..450ca317e8f 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1612,6 +1612,7 @@ export class AgentSession { private async applyManualUserMessageGoalSafety(input: { policy: GoalInterventionPolicy; + enqueuedAtMs?: number; }): Promise { const goalService = this.workspaceGoalService; if (!goalService) { @@ -1629,8 +1630,21 @@ export class AgentSession { // anything manually typed by the user pauses until Resume appends a fresh // continuation. Legacy clients may still send the old "steer" policy; treat // it as pause so the invariant holds at this backend boundary. - goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); const goal = await goalService.acknowledgeUser(this.workspaceId); + + // Queue race: a message the user typed while the goal-creating turn was + // still streaming predates the goal itself — the model's queued set_goal + // applies at that turn's stream end, and only then does the queued message + // dispatch. The user cannot have been intervening against a goal they had + // not seen, so let the fresh goal keep its kickoff continuation instead of + // silently pausing it half a second after creation (user report: goals + // "paused by heartbeats" were actually killed here, then heartbeat turns + // kept the workspace moving while the goal sat paused). + if (input.enqueuedAtMs != null && goal != null && goal.createdAtMs >= input.enqueuedAtMs) { + return; + } + + goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); if (goal?.status !== "active") { return; } @@ -2718,6 +2732,13 @@ export class AgentSession { onCanceled?: (reason: string) => Promise | void; cancelState?: { canceledBeforeAcceptance: boolean }; cancelSignal?: AbortSignal; + /** + * For queue-dispatched sends: when the user last added to the queued + * entry. Goal safety uses it to skip auto-pausing a goal created AFTER + * the message was typed (queued while the goal-creating turn streamed) — + * the user cannot have been intervening against a goal they had not seen. + */ + enqueuedAtMs?: number; /** * Synthetic assistant rows persisted immediately before this turn's user * row (family-message payloads). Persisting them inside turn admission — @@ -2871,7 +2892,10 @@ export class AgentSession { // payloads (Codex P2 PRRT_kwDOPxxmWM5_tUsx) would otherwise silently // disable goal continuation after a blank submit / invalid payload. if (persisted) { - await this.applyManualUserMessageGoalSafety({ policy: "pause" }); + await this.applyManualUserMessageGoalSafety({ + policy: "pause", + enqueuedAtMs: internal?.enqueuedAtMs, + }); } } return Err(pricingGate.error); @@ -3567,7 +3591,10 @@ export class AgentSession { } if (manualGoalInterventionPolicy != null) { - await this.applyManualUserMessageGoalSafety({ policy: manualGoalInterventionPolicy }); + await this.applyManualUserMessageGoalSafety({ + policy: manualGoalInterventionPolicy, + enqueuedAtMs: internal?.enqueuedAtMs, + }); } // Workspace may be tearing down while we await filesystem IO. @@ -6410,7 +6437,7 @@ export class AgentSession { // Entries dispatch one at a time (FIFO): special sends (compaction, agent // skills, workspace-turn follow-ups) own their turn, and anything queued // behind them dispatches on a later drain instead of batching into them. - const { message, options, internal } = this.messageQueue.dequeueNext(); + const { message, options, internal, enqueuedAtMs } = this.messageQueue.dequeueNext(); this.dispatchingQueuedEntry = true; this.dispatchingQueuedEntryMuxMetadata = options?.muxMetadata; this.emitQueuedMessageChanged(); @@ -6429,7 +6456,7 @@ export class AgentSession { this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata(options?.muxMetadata); this.setTurnPhase(TurnPhase.PREPARING); - void this.sendMessage(message, options, internal) + void this.sendMessage(message, options, { ...internal, enqueuedAtMs }) .then(async (result) => { // Keep the dispatch marker through the dequeue-to-stream-start window. A background // send can resolve before startup emits stream-start, and later reports must not claim diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index fa2a2f5d6a3..a7b30a6bb0c 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -143,6 +143,13 @@ interface QueueEntry { addCount: number; syntheticCount: number; agentInitiatedCount: number; + /** + * Timestamp of the latest add batched into this entry. Dispatch exposes it so + * goal safety can tell messages typed before a goal existed (queued while the + * goal-creating turn was still streaming) from genuine interventions against + * a goal the user has already seen. + */ + lastAddedAtMs: number; onCanceled?: (reason: string) => Promise | void; onAccepted?: () => Promise | void; onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise | void; @@ -459,6 +466,7 @@ export class MessageQueue { addCount: 0, syntheticCount: 0, agentInitiatedCount: 0, + lastAddedAtMs: Date.now(), }; this.entries.push(entry); } @@ -523,6 +531,7 @@ export class MessageQueue { } entry.addCount += 1; + entry.lastAddedAtMs = Date.now(); if (internal?.synthetic === true) { entry.syntheticCount += 1; } @@ -737,6 +746,8 @@ export class MessageQueue { message: string; options?: SendMessageOptions & { fileParts?: FilePart[] }; internal?: QueuedMessageInternalOptions; + /** Timestamp of the latest add batched into this entry (see QueueEntry.lastAddedAtMs). */ + enqueuedAtMs?: number; } { const entry = this.entries.shift(); if (entry === undefined) { @@ -791,7 +802,7 @@ export class MessageQueue { } : undefined; - return { message: joinedMessages, options, internal }; + return { message: joinedMessages, options, internal, enqueuedAtMs: entry.lastAddedAtMs }; } /** diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 622354268a6..19aa880c96c 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -388,8 +388,36 @@ describe("WorkspaceGoalService", () => { expect(executed[0]?.message).toContain(""); }); - test("getGoal reconciles active goals to paused when the latest user turn is not a continuation", async () => { + // Drive one real continuation so the goal leaves its kickoff window + // (lastContinuationFiredAtMs set + goal_continuation row in history). + async function driveOneContinuation(): Promise { + const dispatcher = new IdleDispatcher(); + service.registerGoalContinuationConsumer( + dispatcher, + continuationBridge(async (input) => { + await appendUserHistoryMessage(historyService, input.workspaceId, input.message, { + timestamp: Date.now(), + synthetic: true, + uiVisible: true, + kind: input.kind ?? GOAL_CONTINUATION_KIND, + }); + return true; + }) + ); + await service.requestContinuationAfterStreamEnd({ + workspaceId, + sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + streamEndedAtMs: 10_000, + }); + await waitForCondition( + async () => (await service.getGoal(workspaceId))?.lastContinuationFiredAtMs != null, + { timeoutMs: 1_000 } + ); + } + + test("getGoal reconciles driven active goals to paused when the latest user turn is not a continuation", async () => { await setGoalOk(service, { workspaceId, objective: "Follow chat tail" }); + await driveOneContinuation(); await appendUserHistoryMessage(historyService, workspaceId, "Manual interruption"); const reconciled = await service.getGoal(workspaceId); @@ -397,14 +425,25 @@ describe("WorkspaceGoalService", () => { expect(reconciled).toMatchObject({ status: "paused" }); }); + test("getGoal keeps a never-driven active goal active across candidate loss (durable kickoff window)", async () => { + // A goal that has never fired a continuation only has pre-goal manual user + // rows in its chat tail. Reconciliation must not pause it — the in-memory + // kickoff candidate can be lost (restart, eviction), and the next getGoal + // (heartbeat/wake tool assembly) would otherwise silently pause the goal + // before it ever ran. + await setGoalOk(service, { workspaceId, objective: "Follow chat tail" }); + await appendUserHistoryMessage(historyService, workspaceId, "Manual interruption"); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "active" }); + }); + test("chat-tail reconciliation ignores synthetic maintenance user rows", async () => { await setGoalOk(service, { workspaceId, objective: "Ignore maintenance rows" }); - await appendUserHistoryMessage(historyService, workspaceId, "Continue goal", { - timestamp: Date.now(), - synthetic: true, - uiVisible: true, - kind: GOAL_CONTINUATION_KIND, - }); + // Drive a real continuation first so the goal is past its kickoff window + // and the synthetic-row skip below is what keeps it active. + await driveOneContinuation(); await appendUserHistoryMessage(historyService, workspaceId, "Synthetic heartbeat", { timestamp: Date.now(), synthetic: true, @@ -2275,6 +2314,31 @@ describe("WorkspaceGoalService", () => { expect(updated).toMatchObject({ costCents: 0, turnsUsed: 0, status: "paused" }); }); + test("paused goals ignore maintenance stream accounting (heartbeats / wake turns)", async () => { + // Regression: paused goals were charged turns/cost (and updatedAtMs bumped) + // by every background wake turn ("other") and scheduled heartbeat, making + // maintenance turns look like they had just touched the paused goal. + const created = await setGoalOk(service, { + workspaceId, + objective: "Paused during maintenance", + turnCap: 3, + }); + await setGoalOk(service, { + workspaceId, + objective: created.objective, + status: "paused", + }); + + const updated = await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.42, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "other", + }); + + expect(updated).toMatchObject({ costCents: 0, turnsUsed: 0, status: "paused" }); + }); + test("completed goals ignore later stream accounting", async () => { const created = await setGoalOk(service, { workspaceId, diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 530aa1eb469..9753f0b5829 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -536,6 +536,20 @@ export class WorkspaceGoalService { chatTailMode.mode === "paused" && chatTailMode.pausedBy === "manual_user" ) { + // Durable kickoff window: a goal that has never fired a continuation has + // no goal_continuation row in history yet, so the chat tail necessarily + // still ends at a pre-goal manual user row. The in-memory candidate guard + // below covers the live process, but candidates are lost on restart and + // can be evicted by unrelated paths — after which the very next getGoal + // (heartbeat / wake-turn tool assembly, Goal panel reads, stream-end + // hooks) would silently pause the goal before it ever ran (user report: + // scheduled heartbeats "pausing" fresh goals). Explicit pause paths are + // unaffected: they append a goal-pause-boundary row, which reconciles via + // the pause_boundary branch, and manual user turns still auto-pause at + // dispatch time via applyManualUserMessageGoalSafety. + if (goal.lastContinuationFiredAtMs == null) { + return goal; + } const candidate = this.pendingContinuationCandidates.get(workspaceId); if (candidate?.source === "kickoff" && candidate.goalId === goal.goalId) { return goal; @@ -2695,7 +2709,16 @@ export class WorkspaceGoalService { return null; } - if ((current.status === "paused" || current.status === "complete") && originKind === "user") { + // Paused/complete goals only accrue cost from streams that are actually + // goal work racing the status change (an in-flight continuation or budget + // wrap-up). Maintenance streams — scheduled heartbeats ("user" origin, + // no agentInitiated flag) and background wake turns ("other") — must not + // charge turns/cost or bump updatedAtMs on a goal that is not running; + // doing so made every heartbeat/wake look like it had just touched the + // paused goal. Mirrors attributeChildReport's paused/complete skip. + const isGoalDrivenStream = + originKind === "goal_continuation" || originKind === "goal_budget_limit"; + if ((current.status === "paused" || current.status === "complete") && !isGoalDrivenStream) { this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); await this.pushSnapshot(input.workspaceId, current); return current; From 02ab4595eeecaaee9d61921c75b108ab58544aa6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 06:45:34 +0000 Subject: [PATCH 02/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20address=20Codex?= =?UTF-8?q?=20findings=20(projected=20createdAtMs,=20crash-safe=20reconcil?= =?UTF-8?q?iation,=20budget=5Flimited=20accounting)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: preserve the projected goal's createdAtMs through the mid-stream drain so interventions queued against the visible optimistic goal are not misread as pre-goal input. - P2: persist enqueuedAtMs on queue-dispatched user rows and scope the never-driven kickoff exemption to rows authored before the goal existed; post-goal rows pause on reconciliation even when the dispatch-time auto-pause was lost to a crash. - P2 (accounting): extend the maintenance-stream skip to budget_limited goals so background wakes cannot inflate the recorded overshoot. --- src/common/types/message.ts | 7 ++ src/node/services/agentSession.ts | 3 + .../services/workspaceGoalService.test.ts | 84 +++++++++++++++++-- src/node/services/workspaceGoalService.ts | 79 +++++++++++++---- 4 files changed, 152 insertions(+), 21 deletions(-) diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 6afd7d70c14..30d249e2454 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -772,6 +772,13 @@ export interface MuxMetadata { systemMessageTokens?: number; // Token count for system message sent with this request (calculated by AIService) partial?: boolean; // Whether this message was interrupted and is incomplete synthetic?: boolean; // Whether this message was synthetically generated (e.g., [CONTINUE] sentinel) + /** + * For queue-dispatched user turns: when the user last added to the queued + * entry. The row `timestamp` is stamped at dispatch (after the blocking turn + * ends), so goal safety needs this to durably tell messages typed before a + * mid-turn goal existed from genuine interventions against a visible goal. + */ + enqueuedAtMs?: number; /** * UI hint: show in the chat UI even when synthetic. * diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 450ca317e8f..77584e6c56e 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -3268,6 +3268,9 @@ export class AgentSession { muxMetadata: stampedMuxMetadata, // Pass through frontend metadata as black-box ...(acpPromptId != null ? { acpPromptId } : {}), ...(goalKind != null ? { kind: goalKind } : {}), + // Persist the queue-entry authoring time so goal-safety reconciliation + // can re-derive the pre-goal/post-goal distinction after a restart. + ...(internal?.enqueuedAtMs != null ? { enqueuedAtMs: internal.enqueuedAtMs } : {}), // Auto-resume and other system-generated messages are synthetic + UI-visible ...(internal?.synthetic && { synthetic: true, uiVisible: true }), }, diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 19aa880c96c..60a1dee4110 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -427,18 +427,47 @@ describe("WorkspaceGoalService", () => { test("getGoal keeps a never-driven active goal active across candidate loss (durable kickoff window)", async () => { // A goal that has never fired a continuation only has pre-goal manual user - // rows in its chat tail. Reconciliation must not pause it — the in-memory - // kickoff candidate can be lost (restart, eviction), and the next getGoal - // (heartbeat/wake tool assembly) would otherwise silently pause the goal - // before it ever ran. + // rows in its chat tail (e.g. the request that made the model set it). + // Reconciliation must not pause it — the in-memory kickoff candidate can be + // lost (restart, eviction), and the next getGoal (heartbeat/wake tool + // assembly) would otherwise silently pause the goal before it ever ran. + await appendUserHistoryMessage(historyService, workspaceId, "Set yourself a goal"); await setGoalOk(service, { workspaceId, objective: "Follow chat tail" }); - await appendUserHistoryMessage(historyService, workspaceId, "Manual interruption"); const reconciled = await service.getGoal(workspaceId); expect(reconciled).toMatchObject({ status: "active" }); }); + test("getGoal keeps a never-driven goal active for queued rows authored before the goal", async () => { + // Queue race: the row is persisted at dispatch (after the goal-creating + // turn's stream end) so its timestamp postdates the goal, but the durable + // enqueuedAtMs proves the user typed before the goal existed. + const created = await setGoalOk(service, { workspaceId, objective: "Queue race" }); + await appendUserHistoryMessage(historyService, workspaceId, "Typed mid-stream", { + timestamp: created.createdAtMs + 500, + enqueuedAtMs: created.createdAtMs - 500, + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "active" }); + }); + + test("getGoal pauses a never-driven goal when a manual row was authored after the goal", async () => { + // Crash-recovery self-healing: if the dispatch-time auto-pause was lost + // (process exit between the user row persist and the pause write), the + // durable row authored after the goal must still pause it on restart. + const created = await setGoalOk(service, { workspaceId, objective: "Post-goal intervention" }); + await appendUserHistoryMessage(historyService, workspaceId, "Stop this goal", { + timestamp: created.createdAtMs + 1_000, + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "paused" }); + }); + test("chat-tail reconciliation ignores synthetic maintenance user rows", async () => { await setGoalOk(service, { workspaceId, objective: "Ignore maintenance rows" }); // Drive a real continuation first so the goal is past its kickoff window @@ -2221,6 +2250,24 @@ describe("WorkspaceGoalService", () => { }); }); + test("queued mid-stream goal creation preserves the projected creation time at drain time", async () => { + // The projected goal is visible in the Goal panel the moment set_goal runs + // mid-stream. The durable record must date from that moment — a stream-end + // createdAtMs would misclassify a user intervention queued against the + // visible goal as pre-goal input in the goal-safety guards. + await extensionMetadata.setStreaming(workspaceId, true); + const queued = await service.setGoal({ workspaceId, objective: "Projected mid-stream" }); + expect(queued.success).toBe(true); + const projected = queued.success ? queued.data : null; + + await new Promise((resolve) => setTimeout(resolve, 5)); + await extensionMetadata.setStreaming(workspaceId, false); + const drained = await service.applyPendingAfterStreamEnd(workspaceId); + + expect(drained?.goalId).toBe(projected?.goalId ?? "missing"); + expect(drained?.createdAtMs).toBe(projected?.createdAtMs ?? -1); + }); + test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Original" }); await extensionMetadata.setStreaming(workspaceId, true); @@ -2339,6 +2386,33 @@ describe("WorkspaceGoalService", () => { expect(updated).toMatchObject({ costCents: 0, turnsUsed: 0, status: "paused" }); }); + test("budget-limited goals ignore maintenance stream accounting", async () => { + // Background wakes/heartbeats running while the budget wrap-up is pending + // must not inflate the recorded overshoot; only goal-driven streams + // (continuation / wrap-up) may still charge a budget_limited goal. + const created = await setGoalOk(service, { + workspaceId, + objective: "Budget exhausted", + budgetCents: 100, + }); + const limited = await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + expect(limited).toMatchObject({ status: "budget_limited", costCents: 125, turnsUsed: 1 }); + + const updated = await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.42, + streamStartedAtMs: created.createdAtMs + 2, + streamOriginKind: "other", + }); + + expect(updated).toMatchObject({ status: "budget_limited", costCents: 125, turnsUsed: 1 }); + }); + test("completed goals ignore later stream accounting", async () => { const created = await setGoalOk(service, { workspaceId, diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 9753f0b5829..10badd70053 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -208,6 +208,15 @@ interface ChatTailGoalModeResult { * freshly armed kickoff (see `applyChatTailGoalMode`). */ pausedBy?: "pause_boundary" | "manual_user"; + /** + * When `pausedBy === "manual_user"`: the moment the user authored the pausing + * row — its persisted enqueue time (queued sends) or the row timestamp. + * Reconciliation compares this against `goal.createdAtMs` to tell pre-goal + * rows (must not pause a never-driven goal) from genuine post-goal + * interventions (must pause even if the dispatch-time auto-pause was lost to + * a crash). + */ + manualRowAuthoredAtMs?: number; } interface GoalContinuationEligibilityResult { @@ -231,6 +240,13 @@ interface PendingGoalMutation { forceNewGoal?: boolean | null; /** Stable id for the optimistic record returned before the deferred write drains. */ projectedGoalId?: string | null; + /** + * Creation time of the optimistic record. The drain re-creates the durable + * goal, but the user could already see (and react to) the projected goal + * mid-stream — a later stream-end `createdAtMs` would misclassify a queued + * intervention as pre-goal input (Codex P1). + */ + projectedCreatedAtMs?: number | null; /** * Carries the caller's `editInPlace` intent across mid-stream deferral so * a queued rename preserves goalId + accounting when it drains. @@ -506,7 +522,15 @@ export class WorkspaceGoalService { if (message.metadata?.synthetic === true) { continue; } - return { mode: "paused", pausedBy: "manual_user" }; + // Queue-dispatched rows persist their authoring time separately: the row + // timestamp is stamped at dispatch, which can postdate a goal created at + // the blocking turn's stream end even though the user typed pre-goal. + const authoredAtMs = message.metadata?.enqueuedAtMs ?? message.metadata?.timestamp; + return { + mode: "paused", + pausedBy: "manual_user", + ...(authoredAtMs != null ? { manualRowAuthoredAtMs: authoredAtMs } : {}), + }; } return { mode: null }; @@ -537,17 +561,26 @@ export class WorkspaceGoalService { chatTailMode.pausedBy === "manual_user" ) { // Durable kickoff window: a goal that has never fired a continuation has - // no goal_continuation row in history yet, so the chat tail necessarily - // still ends at a pre-goal manual user row. The in-memory candidate guard + // no goal_continuation row in history yet, so the chat tail still ends at + // a manual user row that predates the goal. The in-memory candidate guard // below covers the live process, but candidates are lost on restart and // can be evicted by unrelated paths — after which the very next getGoal // (heartbeat / wake-turn tool assembly, Goal panel reads, stream-end // hooks) would silently pause the goal before it ever ran (user report: - // scheduled heartbeats "pausing" fresh goals). Explicit pause paths are - // unaffected: they append a goal-pause-boundary row, which reconciles via - // the pause_boundary branch, and manual user turns still auto-pause at - // dispatch time via applyManualUserMessageGoalSafety. - if (goal.lastContinuationFiredAtMs == null) { + // scheduled heartbeats "pausing" fresh goals). + // + // Scoped to rows AUTHORED before the goal existed (persisted enqueue time + // for queued sends, row timestamp otherwise): a row the user authored + // after the goal became visible is a genuine intervention and must still + // pause even when the dispatch-time auto-pause was lost to a crash + // (Codex P2 — persisted state stays self-healing). Explicit pause paths + // are unaffected: they append a goal-pause-boundary row, which reconciles + // via the pause_boundary branch. + if ( + goal.lastContinuationFiredAtMs == null && + chatTailMode.manualRowAuthoredAtMs != null && + chatTailMode.manualRowAuthoredAtMs <= goal.createdAtMs + ) { return goal; } const candidate = this.pendingContinuationCandidates.get(workspaceId); @@ -681,8 +714,15 @@ export class WorkspaceGoalService { status?: GoalStatus | null; completionSummary?: string | null; goalId?: string | null; + /** + * Preserved creation time for goals projected mid-stream: the durable + * record must date from when the user could first see the goal, or queued + * interventions typed against the visible goal would be misread as + * pre-goal input by the goal-safety guards. + */ + createdAtMs?: number | null; }): GoalRecordV1 { - const now = Date.now(); + const now = input.createdAtMs ?? Date.now(); const status = input.status ?? "active"; const goal = GoalRecordV1Schema.parse({ version: 1, @@ -2075,6 +2115,7 @@ export class WorkspaceGoalService { ...(input.initiator != null ? { initiator: input.initiator } : {}), ...(input.forceNewGoal != null ? { forceNewGoal: input.forceNewGoal } : {}), projectedGoalId: projected.goalId, + projectedCreatedAtMs: projected.createdAtMs, // Forward `editInPlace` so an inline rename submitted while the // agent is streaming still takes the rename branch when the // pending mutation drains. @@ -2111,7 +2152,7 @@ export class WorkspaceGoalService { private async setGoalImmediately( input: SetGoalInput & { objective?: string }, - options?: { replacementGoalId?: string | null } + options?: { replacementGoalId?: string | null; replacementCreatedAtMs?: number | null } ): Promise> { const result = await this.fileLocks.withLock(input.workspaceId, async () => { const current = await this.readGoalFile(input.workspaceId); @@ -2260,6 +2301,7 @@ export class WorkspaceGoalService { status: input.status, completionSummary: input.completionSummary, goalId: options?.replacementGoalId ?? null, + createdAtMs: options?.replacementCreatedAtMs ?? null, }); if ( (next.status === "active" || next.status === "budget_limited") && @@ -2709,16 +2751,18 @@ export class WorkspaceGoalService { return null; } - // Paused/complete goals only accrue cost from streams that are actually - // goal work racing the status change (an in-flight continuation or budget + // Non-running goals only accrue cost from streams that are actually goal + // work racing the status change (an in-flight continuation or budget // wrap-up). Maintenance streams — scheduled heartbeats ("user" origin, // no agentInitiated flag) and background wake turns ("other") — must not // charge turns/cost or bump updatedAtMs on a goal that is not running; // doing so made every heartbeat/wake look like it had just touched the - // paused goal. Mirrors attributeChildReport's paused/complete skip. + // goal. `budget_limited` is included so background activity while the + // wrap-up is pending cannot inflate the recorded overshoot. Mirrors + // attributeChildReport's paused/complete skip. const isGoalDrivenStream = originKind === "goal_continuation" || originKind === "goal_budget_limit"; - if ((current.status === "paused" || current.status === "complete") && !isGoalDrivenStream) { + if (current.status !== "active" && !isGoalDrivenStream) { this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); await this.pushSnapshot(input.workspaceId, current); return current; @@ -2918,10 +2962,13 @@ export class WorkspaceGoalService { // be logged and swallowed so the stream-end pipeline stays alive. // The caller already treats null as "no apply happened". try { - const { projectedGoalId, ...pendingInput } = pending; + const { projectedGoalId, projectedCreatedAtMs, ...pendingInput } = pending; const result = await this.setGoalImmediately( { workspaceId, ...pendingInput }, - { replacementGoalId: projectedGoalId ?? null } + { + replacementGoalId: projectedGoalId ?? null, + replacementCreatedAtMs: projectedCreatedAtMs ?? null, + } ); drained = result.success ? result.data : null; } catch (error) { From 4bd9a185f48fe780b6755862bffae0b3e57d112d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 07:05:13 +0000 Subject: [PATCH 03/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=202=20?= =?UTF-8?q?=E2=80=94=20persist=20authoring=20metadata=20on=20rejected=20se?= =?UTF-8?q?nds,=20gate=20maintenance=20cost=20previews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rejected manual sends (pricing gate) now persist timestamp + enqueuedAtMs so chat-tail reconciliation can classify the row as pre-goal after a restart instead of pausing a never-driven goal. - previewStreamAccounting receives the stream origin kind and mirrors final accounting's budget_limited maintenance skip, so the Goal UI no longer shows climbing heartbeat/wake cost that snaps back at stream end. --- .../services/agentSession.budgetGate.test.ts | 40 +++++++++++++++++++ src/node/services/agentSession.ts | 25 ++++++++++-- .../services/workspaceGoalService.test.ts | 27 +++++++++++++ src/node/services/workspaceGoalService.ts | 11 +++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.budgetGate.test.ts b/src/node/services/agentSession.budgetGate.test.ts index 6c8ebfa4e58..5481647bb8b 100644 --- a/src/node/services/agentSession.budgetGate.test.ts +++ b/src/node/services/agentSession.budgetGate.test.ts @@ -261,6 +261,46 @@ describe("AgentSession.sendMessage budget gate", () => { session.dispose(); }); + test("rejected queued sends persist authoring metadata and pre-goal rows do not pause the goal", async () => { + // Queue race on the rejection path: a message the user typed before the + // goal existed can be rejected by the pricing gate when it dispatches + // after the goal-creating turn. The persisted row must carry authoring + // metadata (timestamp + enqueuedAtMs) so goal-safety reconciliation still + // classifies it as pre-goal after a restart, and the dispatch-time hook + // must leave the fresh goal active. + const workspaceId = "as-budget-gate-queued-pre-goal"; + const { historyService, session, goalService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + + const enqueuedAtMs = Date.now() - 1; + await setGoalOk(goalService, { + workspaceId, + objective: "Stay under budget", + budgetCents: 500, + }); + + const result = await session.sendMessage("Typed before the goal existed", UNPRICED_OPTIONS, { + enqueuedAtMs, + }); + expect(result.success).toBe(false); + + const history = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(history.success).toBe(true); + if (history.success) { + const userMessage = history.data.find((m) => m.role === "user"); + expect(typeof userMessage?.metadata?.timestamp).toBe("number"); + expect(userMessage?.metadata?.enqueuedAtMs).toBe(enqueuedAtMs); + } + + // Dispatch-time guard: no pause. getGoal also re-runs chat-tail + // reconciliation against the persisted row, exercising the durable + // enqueuedAtMs path. + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" }); + + session.dispose(); + }); + test("empty manual rejected send does NOT pause an active goal", async () => { // Codex P2 (PRRT_kwDOPxxmWM5_tUsx): an accidental blank submit must not // silently disable goal continuation. The pre-stream gate runs before the diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 77584e6c56e..a98451035f3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2881,7 +2881,8 @@ export class AgentSession { const persisted = await this.preserveRejectedManualSend( message, options, - pricingGate.error + pricingGate.error, + internal?.enqueuedAtMs ); // The user has explicitly intervened, so the goal-safety contract // for manual sends must still apply on the rejection path: clear any @@ -4040,7 +4041,8 @@ export class AgentSession { private async preserveRejectedManualSend( message: string, options: (SendMessageOptions & { fileParts?: FilePart[] }) | undefined, - rejection: SendMessageError + rejection: SendMessageError, + enqueuedAtMs?: number ): Promise { if (this.disposed) { return false; @@ -4065,7 +4067,15 @@ export class AgentSession { createUserMessageId(), "user", trimmed, - {}, + { + // Stamp authoring metadata like accepted turns do: goal-safety + // reconciliation reads it after a restart to classify this row as + // pre-goal (queued before the goal existed) vs a real intervention. + // Without it a rejected queued send would pause a never-driven goal + // on the next getGoal. + timestamp: Date.now(), + ...(enqueuedAtMs != null ? { enqueuedAtMs } : {}), + }, additionalParts.length > 0 ? additionalParts : undefined ); const appendResult = await this.historyService.appendToHistory(this.workspaceId, userMessage); @@ -5160,6 +5170,8 @@ export class AgentSession { providerMetadata?: Record; metadataModel?: string; isCompaction?: boolean; + goalKind?: GoalSyntheticMessageKind; + agentInitiated?: boolean; }): Promise { if (!this.workspaceGoalService) { return; @@ -5170,12 +5182,17 @@ export class AgentSession { input.providerMetadata, input.metadataModel ); + // Classify like final accounting so previews and stream-end agree on + // whether this stream may charge a non-active goal — otherwise the Goal UI + // shows growing maintenance cost mid-stream that snaps back at stream end. + const streamOriginKind = getGoalStreamOriginKind(input); const costUsd = getTotalCost(displayUsage) ?? 0; try { await this.workspaceGoalService.previewStreamAccounting({ workspaceId: this.workspaceId, costUsd, isCompaction: input.isCompaction === true, + streamOriginKind, streamStartedAtMs: this.activeStreamStartedAtMs ?? null, }); } catch (error) { @@ -5434,6 +5451,8 @@ export class AgentSession { this.activeStreamContext?.providersConfig ?? null ), isCompaction: this.activeCompactionRequest !== undefined, + goalKind: this.activeStreamContext?.goalKind, + agentInitiated: this.activeStreamContext?.agentInitiated, }); // Never recurse compaction while we're already running a compaction request. diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 60a1dee4110..ac6fedd73e5 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2413,6 +2413,33 @@ describe("WorkspaceGoalService", () => { expect(updated).toMatchObject({ status: "budget_limited", costCents: 125, turnsUsed: 1 }); }); + test("budget-limited goals ignore maintenance stream cost previews", async () => { + // Live previews must agree with final accounting: a heartbeat/wake stream + // on a budget_limited goal is discarded at stream end, so previewing its + // cost would show a climbing number that snaps back when the turn ends. + const created = await setGoalOk(service, { + workspaceId, + objective: "Budget exhausted preview", + budgetCents: 100, + }); + const limited = await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + expect(limited).toMatchObject({ status: "budget_limited", costCents: 125 }); + + const preview = await service.previewStreamAccounting({ + workspaceId, + costUsd: 0.42, + streamStartedAtMs: created.createdAtMs + 2, + streamOriginKind: "other", + }); + + expect(preview).toMatchObject({ status: "budget_limited", costCents: 125 }); + }); + test("completed goals ignore later stream accounting", async () => { const created = await setGoalOk(service, { workspaceId, diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 10badd70053..d0c0dfc595c 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2706,6 +2706,17 @@ export class WorkspaceGoalService { if (current.status === "paused" || current.status === "complete") { return toGoalSnapshot(current); } + // Mirror recordStreamAccounting's maintenance skip: final accounting + // discards non-goal-driven cost on a budget_limited goal, so previewing + // it would show climbing cost mid-stream that snaps back at stream end. + const previewOriginKind = input.streamOriginKind ?? "user"; + if ( + current.status === "budget_limited" && + previewOriginKind !== "goal_continuation" && + previewOriginKind !== "goal_budget_limit" + ) { + return toGoalSnapshot(current); + } const preview = GoalRecordV1Schema.parse({ ...current, From e7127113828155b730dea441f71a65f31febc96d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 07:22:54 +0000 Subject: [PATCH 04/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=203=20?= =?UTF-8?q?=E2=80=94=20stamp=20queued=20goal=20creation=20at=20publication?= =?UTF-8?q?=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: createGoal() stamped createdAtMs before the kickoff-model validation and streaming re-check awaits, so a message queued during those awaits postdated the stamp yet predated goal visibility, and the pre-goal guard misread it as an intervention. Re-stamp fresh projected goals immediately before publishPendingGoalSnapshot. --- .../services/workspaceGoalService.test.ts | 33 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 18 ++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index ac6fedd73e5..cd3a9dc525e 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2268,6 +2268,39 @@ describe("WorkspaceGoalService", () => { expect(drained?.createdAtMs).toBe(projected?.createdAtMs ?? -1); }); + test("queued mid-stream goal creation stamps creation at publication time", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6b-CH5): awaits between goal construction and + // publication (kickoff-model pricing validation, streaming re-check) leave + // a window where a user can queue a message after createdAtMs was stamped + // but before the goal is visible anywhere. Creation must date from + // publication so the pre-goal guard (enqueuedAtMs <= createdAtMs) covers + // messages typed during that window. + const dispatcher = new IdleDispatcher(); + let midValidationMs = 0; + service.registerGoalContinuationConsumer(dispatcher, { + ...continuationBridge(), + getKickoffSendOptions: async () => { + // Hold the validation await so wall-clock time observably advances + // between construction and publication. + await new Promise((resolve) => setTimeout(resolve, 10)); + midValidationMs = Date.now(); + return { model: "openai:gpt-4o", agentId: "exec" }; + }, + }); + await extensionMetadata.setStreaming(workspaceId, true); + + const queued = await service.setGoal({ + workspaceId, + objective: "Publication stamp", + budgetCents: 500, + }); + + expect(queued.success).toBe(true); + expect(midValidationMs).toBeGreaterThan(0); + const projected = queued.success ? queued.data : null; + expect(projected?.createdAtMs ?? -1).toBeGreaterThanOrEqual(midValidationMs); + }); + test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Original" }); await extensionMetadata.setStreaming(workspaceId, true); diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index d0c0dfc595c..17a62efe9c2 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2060,6 +2060,7 @@ export class WorkspaceGoalService { // here so the optimistic Goal tab snapshot preserves id/accounting // and applies budget-driven status before stream end. let projected: GoalRecordV1; + let projectedIsFreshGoal = false; if (input.editInPlace === true && current) { const renamed = GoalRecordV1Schema.parse({ ...current, @@ -2083,6 +2084,7 @@ export class WorkspaceGoalService { status: input.status, completionSummary: input.completionSummary, }); + projectedIsFreshGoal = true; } if ( (projected.status === "active" || projected.status === "budget_limited") && @@ -2098,6 +2100,22 @@ export class WorkspaceGoalService { // pending mutation. return null; } + if (projectedIsFreshGoal) { + // Codex P2 (PRRT_kwDOPxxmWM6b-CH5): createGoal() stamped createdAtMs + // before the kickoff-model validation and streaming re-check awaits + // above. A message queued during those awaits postdates that stamp + // yet predates the goal becoming visible, so the pre-goal guard + // (enqueuedAtMs <= createdAtMs) would misread it as an intervention + // against a goal the user could not have seen. Stamp creation at + // publication instead. Existing-goal branches keep their original + // durable createdAtMs — those goals were published long ago. + const publishedAtMs = Date.now(); + projected = GoalRecordV1Schema.parse({ + ...projected, + createdAtMs: publishedAtMs, + updatedAtMs: publishedAtMs, + }); + } this.pendingGoalMutations.set(input.workspaceId, { objective, ...(Object.hasOwn(input, "budgetCents") From 930f18846dcce8db372de376976769fdb8050e22 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 07:41:18 +0000 Subject: [PATCH 05/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=204=20?= =?UTF-8?q?=E2=80=94=20stamp=20goal=20creation=20after=20publication,=20cl?= =?UTF-8?q?ear=20kickoff=20candidate=20on=20ack=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 x2: (1) the round-3 stamp still preceded the async activity-snapshot read inside publishPendingGoalSnapshot, so a message queued during that read postdated the stamp while the renderer had not yet received onActivityChange — publish first, then stamp fresh goals. (2) reordering acknowledgeUser before the candidate clear meant an acknowledgment throw skipped the clear entirely, letting a stale kickoff candidate dispatch a continuation despite the user's persisted intervention — clear conservatively on failure, then rethrow. --- .../agentSession.goalAutoPause.test.ts | 34 ++++++++++++++++++- src/node/services/agentSession.ts | 15 +++++++- .../services/workspaceGoalService.test.ts | 29 ++++++++++++---- src/node/services/workspaceGoalService.ts | 30 +++++++++------- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 2ee82d3f3b2..719338f34f5 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import type { AIService } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; @@ -241,6 +241,38 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("acknowledgment failure still clears the kickoff candidate", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6b-Uln): the manual row is durably appended + // before goal safety runs. If acknowledgeUser() throws (goal / + // extension-metadata write failure), the pre-goal queue-race guard can + // never prove the message predates the goal, so the kickoff candidate must + // still be cleared conservatively — a stale candidate could otherwise + // dispatch a continuation against the user's persisted intervention once + // the failed send returns the workspace to idle. + const workspaceId = "ack-failure-clears-candidate"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); + + spyOn(goalService, "acknowledgeUser").mockImplementation(() => + Promise.reject(new Error("goal write failed")) + ); + const clearSpy = spyOn(goalService, "clearPendingContinuationForManualUserMessage"); + + let thrown: unknown = null; + try { + await session.sendMessage("Manual intervention", SEND_OPTIONS, { + enqueuedAtMs: created.createdAtMs + 1, + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect(clearSpy).toHaveBeenCalledWith(workspaceId); + session.dispose(); + }); + test("manual user messages are no-ops when no goal exists", async () => { const workspaceId = "manual-no-goal"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a98451035f3..b52611ee573 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1630,7 +1630,20 @@ export class AgentSession { // anything manually typed by the user pauses until Resume appends a fresh // continuation. Legacy clients may still send the old "steer" policy; treat // it as pause so the invariant holds at this backend boundary. - const goal = await goalService.acknowledgeUser(this.workspaceId); + let goal: Awaited>; + try { + goal = await goalService.acknowledgeUser(this.workspaceId); + } catch (error) { + // Codex P2 (PRRT_kwDOPxxmWM6b-Uln): the manual row is already durably + // appended by this point. When acknowledgment fails we cannot read the + // goal to prove the pre-goal queue race below, so conservatively clear + // the kickoff candidate first (the pre-reorder behavior): once the + // failed send returns the workspace to idle, a stale candidate could + // otherwise dispatch a continuation despite the user's persisted + // intervention. + goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); + throw error; + } // Queue race: a message the user typed while the goal-creating turn was // still streaming predates the goal itself — the model's queued set_goal diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index cd3a9dc525e..fd99b8322ad 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2269,12 +2269,14 @@ describe("WorkspaceGoalService", () => { }); test("queued mid-stream goal creation stamps creation at publication time", async () => { - // Codex P2 (PRRT_kwDOPxxmWM6b-CH5): awaits between goal construction and - // publication (kickoff-model pricing validation, streaming re-check) leave - // a window where a user can queue a message after createdAtMs was stamped - // but before the goal is visible anywhere. Creation must date from - // publication so the pre-goal guard (enqueuedAtMs <= createdAtMs) covers - // messages typed during that window. + // Codex P2 (PRRT_kwDOPxxmWM6b-CH5, PRRT_kwDOPxxmWM6b-Uli): awaits between + // goal construction and completed publication (kickoff-model pricing + // validation, streaming re-check, and the async activity-snapshot read + // inside publication itself) leave a window where a user can queue a + // message after createdAtMs was stamped but before the goal is visible + // anywhere. Creation must date from completed publication so the pre-goal + // guard (enqueuedAtMs <= createdAtMs) covers messages typed during any of + // those awaits. const dispatcher = new IdleDispatcher(); let midValidationMs = 0; service.registerGoalContinuationConsumer(dispatcher, { @@ -2288,6 +2290,17 @@ describe("WorkspaceGoalService", () => { }, }); await extensionMetadata.setStreaming(workspaceId, true); + // Hold every activity-snapshot read (streaming re-check + the read inside + // publication) so the last read observably postdates any pre-publication + // creation stamp. + let lastActivityReadMs = 0; + const originalGetSnapshot = extensionMetadata.getSnapshot.bind(extensionMetadata); + spyOn(extensionMetadata, "getSnapshot").mockImplementation(async (id: string) => { + const snapshot = await originalGetSnapshot(id); + await new Promise((resolve) => setTimeout(resolve, 5)); + lastActivityReadMs = Date.now(); + return snapshot; + }); const queued = await service.setGoal({ workspaceId, @@ -2297,8 +2310,12 @@ describe("WorkspaceGoalService", () => { expect(queued.success).toBe(true); expect(midValidationMs).toBeGreaterThan(0); + expect(lastActivityReadMs).toBeGreaterThan(0); const projected = queued.success ? queued.data : null; expect(projected?.createdAtMs ?? -1).toBeGreaterThanOrEqual(midValidationMs); + // The publication path's own async read is the last pre-visibility await: + // the creation stamp must postdate it. + expect(projected?.createdAtMs ?? -1).toBeGreaterThanOrEqual(lastActivityReadMs); }); test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 17a62efe9c2..37ef39069ab 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2100,21 +2100,30 @@ export class WorkspaceGoalService { // pending mutation. return null; } + // A user can run /goal while the first turn is still streaming. The + // durable goal write must wait for stream accounting, but the Goal panel + // reads activity snapshots, so publish the projected goal immediately + // without persisting this crash-unsafe optimistic state. + await this.publishPendingGoalSnapshot(input.workspaceId, projected); if (projectedIsFreshGoal) { - // Codex P2 (PRRT_kwDOPxxmWM6b-CH5): createGoal() stamped createdAtMs - // before the kickoff-model validation and streaming re-check awaits - // above. A message queued during those awaits postdates that stamp - // yet predates the goal becoming visible, so the pre-goal guard - // (enqueuedAtMs <= createdAtMs) would misread it as an intervention - // against a goal the user could not have seen. Stamp creation at - // publication instead. Existing-goal branches keep their original - // durable createdAtMs — those goals were published long ago. + // Codex P2 (PRRT_kwDOPxxmWM6b-CH5, PRRT_kwDOPxxmWM6b-Uli): stamp + // fresh-goal creation AFTER publication completes. createGoal()'s + // construction stamp predates the kickoff-model validation await, + // the streaming re-check, and the async activity-snapshot read + // inside publication — a message queued during any of those awaits + // postdated that stamp while the goal was not yet visible anywhere, + // so the pre-goal guard (enqueuedAtMs <= createdAtMs) misread it as + // an intervention against a goal the user could not have seen. + // Existing-goal branches keep their original durable createdAtMs — + // those goals were published long ago. Sync the in-memory pending + // snapshot so later re-publishes match what the drain will persist. const publishedAtMs = Date.now(); projected = GoalRecordV1Schema.parse({ ...projected, createdAtMs: publishedAtMs, updatedAtMs: publishedAtMs, }); + this.pendingGoalSnapshots.set(input.workspaceId, toPendingGoalSnapshot(projected)); } this.pendingGoalMutations.set(input.workspaceId, { objective, @@ -2139,11 +2148,6 @@ export class WorkspaceGoalService { // pending mutation drains. ...(input.editInPlace != null ? { editInPlace: input.editInPlace } : {}), }); - // A user can run /goal while the first turn is still streaming. The - // durable goal write must wait for stream accounting, but the Goal panel - // reads activity snapshots, so publish the projected goal immediately - // without persisting this crash-unsafe optimistic state. - await this.publishPendingGoalSnapshot(input.workspaceId, projected); return Ok(projected); }); if (deferredResult != null) { From 1340998494b316515f9365e612c12dac80b128a8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 08:08:16 +0000 Subject: [PATCH 06/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=205=20?= =?UTF-8?q?=E2=80=94=20install=20pending=20mutation=20before=20publication?= =?UTF-8?q?,=20capture=20authoring=20time=20at=20request=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: round 4 moved pendingGoalMutations.set after the publication await, so a user abort landing during publication deleted nothing and the setter then installed the mutation anyway — resurrecting a goal the abort discarded and silently applying it at the next stream end. Install before publish; guard the post-publication re-stamp on mutation identity so an interleaved abort (or competing setter) is never overwritten. Codex P2: lastAddedAtMs was sampled at enqueue, after WorkspaceService preflight awaits (pricing gate, settings persistence) — a goal becoming visible during those awaits postdated the user's authoring. Capture authoredAtMs at request entry and thread it through queueMessage / MessageQueue, the pricing-rejection delegation, and direct sends. --- src/node/services/agentSession.ts | 2 + src/node/services/messageQueue.test.ts | 23 ++++++ src/node/services/messageQueue.ts | 10 ++- .../services/workspaceGoalService.test.ts | 38 ++++++++++ src/node/services/workspaceGoalService.ts | 76 ++++++++++++------- src/node/services/workspaceService.ts | 16 ++++ 6 files changed, 138 insertions(+), 27 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b52611ee573..c2a9f3a4e21 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6063,6 +6063,8 @@ export class AgentSession { internal?: { synthetic?: boolean; agentInitiated?: boolean; + /** Request-entry authoring time captured before send preflight awaits (see MessageQueue). */ + authoredAtMs?: number; /** True only for a report that continues an existing workspace turn. */ workspaceTurnContinuation?: boolean; /** Coalescing: drop the message when an entry with the same key is already queued. */ diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index a18d2c51d3a..df08fb05be8 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -10,6 +10,29 @@ describe("MessageQueue", () => { queue = new MessageQueue(); }); + describe("authoredAtMs", () => { + it("returns the request-entry authoring time from dequeueNext when provided", () => { + // Codex P2 (PRRT_kwDOPxxmWM6b-orA): the sender captures authoring time + // before its preflight awaits (pricing gate, settings persistence); + // sampling Date.now() at enqueue instead would postdate a goal that + // became visible during those awaits and misclassify the message as an + // intervention against a goal the user had not seen. + const authoredAtMs = Date.now() - 5_000; + queue.add("typed before preflight", undefined, { authoredAtMs }); + + const { enqueuedAtMs } = queue.dequeueNext(); + expect(enqueuedAtMs).toBe(authoredAtMs); + }); + + it("falls back to enqueue-time sampling when authoring time is absent", () => { + const before = Date.now(); + queue.add("plain add"); + + const { enqueuedAtMs } = queue.dequeueNext(); + expect(enqueuedAtMs).toBeGreaterThanOrEqual(before); + }); + }); + describe("getDisplayText", () => { it("should return joined messages for normal messages", () => { queue.add("First message"); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index a7b30a6bb0c..5ed9f3c19c0 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -84,6 +84,14 @@ type QueueDispatchMode = NonNullable; interface QueuedMessageInternalOptions { synthetic?: boolean; agentInitiated?: boolean; + /** + * When the sender authored this message (request entry), before any send + * preflight awaits (pricing gate, settings persistence). Goal safety + * compares the authoring time against goal creation, so sampling at + * enqueue time would misclassify a message authored before a goal became + * visible as an intervention against it (Codex P2 PRRT_kwDOPxxmWM6b-orA). + */ + authoredAtMs?: number; /** True only for a report that continues an existing workspace turn. */ workspaceTurnContinuation?: boolean; /** Keep this queued add isolated so its dedupe key can be removed without affecting siblings. */ @@ -531,7 +539,7 @@ export class MessageQueue { } entry.addCount += 1; - entry.lastAddedAtMs = Date.now(); + entry.lastAddedAtMs = internal?.authoredAtMs ?? Date.now(); if (internal?.synthetic === true) { entry.syntheticCount += 1; } diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index fd99b8322ad..0ac8db74212 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2318,6 +2318,44 @@ describe("WorkspaceGoalService", () => { expect(projected?.createdAtMs ?? -1).toBeGreaterThanOrEqual(lastActivityReadMs); }); + test("user abort during pending-goal publication discards the queued mutation", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6b-orH): recordUserStoppedStream deletes queued + // goal mutations synchronously before taking the goal file lock. If the + // mutation were installed only after the publication await, an abort + // landing during publication would find nothing to delete, block on the + // lock, and the setter would then install the mutation anyway — silently + // applying the discarded goal at the end of the NEXT stream (AgentSession + // deliberately skips the stream-end drain for user aborts). + await extensionMetadata.setStreaming(workspaceId, true); + const stopPromises: Array> = []; + let fireStops = false; + const originalGetSnapshot = extensionMetadata.getSnapshot.bind(extensionMetadata); + spyOn(extensionMetadata, "getSnapshot").mockImplementation(async (id: string) => { + const snapshot = await originalGetSnapshot(id); + if (fireStops) { + // Fire the abort's synchronous mutation delete at every await inside + // the queued-setGoal path, including the activity read inside + // publishPendingGoalSnapshot; the abort then queues on the goal file + // lock behind the setter. + stopPromises.push(service.recordUserStoppedStream(workspaceId)); + } + return snapshot; + }); + + fireStops = true; + const queued = await service.setGoal({ workspaceId, objective: "Aborted goal" }); + fireStops = false; + expect(queued.success).toBe(true); + expect(stopPromises.length).toBeGreaterThan(0); + await Promise.all(stopPromises); + + await extensionMetadata.setStreaming(workspaceId, false); + // Simulate the NEXT stream's end: the drain must find nothing to apply. + const drained = await service.applyPendingAfterStreamEnd(workspaceId); + expect(drained).toBeNull(); + expect(await service.getGoal(workspaceId)).toBeNull(); + }); + test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Original" }); await extensionMetadata.setStreaming(workspaceId, true); diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 37ef39069ab..c81123ff614 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2100,12 +2100,48 @@ export class WorkspaceGoalService { // pending mutation. return null; } + // Codex P1 (PRRT_kwDOPxxmWM6b-orH): the mutation must be installed + // synchronously after the streaming + // re-check, BEFORE the publication await. `recordUserStoppedStream` + // deletes pending mutations synchronously before taking the goal file + // lock, so a user abort landing during publication must find the + // mutation already installed — installing it afterwards would + // resurrect a goal the abort just discarded, silently applying it at + // the end of the NEXT stream (AgentSession deliberately skips the + // stream-end drain for user aborts). + const pendingMutation: PendingGoalMutation = { + objective, + ...(Object.hasOwn(input, "budgetCents") + ? { budgetCents: input.budgetCents ?? null } + : {}), + ...(Object.hasOwn(input, "turnCap") ? { turnCap: input.turnCap ?? null } : {}), + ...(input.status != null ? { status: input.status } : {}), + ...(input.completionSummary != null + ? { completionSummary: input.completionSummary } + : {}), + ...(Object.hasOwn(input, "expectedGoalId") + ? { expectedGoalId: input.expectedGoalId ?? null } + : {}), + ...(input.replacementGuard != null ? { replacementGuard: input.replacementGuard } : {}), + ...(input.initiator != null ? { initiator: input.initiator } : {}), + ...(input.forceNewGoal != null ? { forceNewGoal: input.forceNewGoal } : {}), + projectedGoalId: projected.goalId, + projectedCreatedAtMs: projected.createdAtMs, + // Forward `editInPlace` so an inline rename submitted while the + // agent is streaming still takes the rename branch when the + // pending mutation drains. + ...(input.editInPlace != null ? { editInPlace: input.editInPlace } : {}), + }; + this.pendingGoalMutations.set(input.workspaceId, pendingMutation); // A user can run /goal while the first turn is still streaming. The // durable goal write must wait for stream accounting, but the Goal panel // reads activity snapshots, so publish the projected goal immediately // without persisting this crash-unsafe optimistic state. await this.publishPendingGoalSnapshot(input.workspaceId, projected); - if (projectedIsFreshGoal) { + if ( + projectedIsFreshGoal && + this.pendingGoalMutations.get(input.workspaceId) === pendingMutation + ) { // Codex P2 (PRRT_kwDOPxxmWM6b-CH5, PRRT_kwDOPxxmWM6b-Uli): stamp // fresh-goal creation AFTER publication completes. createGoal()'s // construction stamp predates the kickoff-model validation await, @@ -2115,39 +2151,27 @@ export class WorkspaceGoalService { // so the pre-goal guard (enqueuedAtMs <= createdAtMs) misread it as // an intervention against a goal the user could not have seen. // Existing-goal branches keep their original durable createdAtMs — - // those goals were published long ago. Sync the in-memory pending - // snapshot so later re-publishes match what the drain will persist. + // those goals were published long ago. + // + // The identity guard (Codex P1 PRRT_kwDOPxxmWM6b-orH) skips the + // re-stamp when a user abort (or competing setter) removed or + // replaced OUR mutation during the publication await — re-installing + // the mutation or snapshot here would resurrect state the abort + // deliberately discarded. const publishedAtMs = Date.now(); projected = GoalRecordV1Schema.parse({ ...projected, createdAtMs: publishedAtMs, updatedAtMs: publishedAtMs, }); + this.pendingGoalMutations.set(input.workspaceId, { + ...pendingMutation, + projectedCreatedAtMs: publishedAtMs, + }); + // Sync the in-memory pending snapshot so later re-publishes match + // what the drain will persist. this.pendingGoalSnapshots.set(input.workspaceId, toPendingGoalSnapshot(projected)); } - this.pendingGoalMutations.set(input.workspaceId, { - objective, - ...(Object.hasOwn(input, "budgetCents") - ? { budgetCents: input.budgetCents ?? null } - : {}), - ...(Object.hasOwn(input, "turnCap") ? { turnCap: input.turnCap ?? null } : {}), - ...(input.status != null ? { status: input.status } : {}), - ...(input.completionSummary != null - ? { completionSummary: input.completionSummary } - : {}), - ...(Object.hasOwn(input, "expectedGoalId") - ? { expectedGoalId: input.expectedGoalId ?? null } - : {}), - ...(input.replacementGuard != null ? { replacementGuard: input.replacementGuard } : {}), - ...(input.initiator != null ? { initiator: input.initiator } : {}), - ...(input.forceNewGoal != null ? { forceNewGoal: input.forceNewGoal } : {}), - projectedGoalId: projected.goalId, - projectedCreatedAtMs: projected.createdAtMs, - // Forward `editInPlace` so an inline rename submitted while the - // agent is streaming still takes the rename branch when the - // pending mutation drains. - ...(input.editInPlace != null ? { editInPlace: input.editInPlace } : {}), - }); return Ok(projected); }); if (deferredResult != null) { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 34c04d06507..5afe08df4c5 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -9455,6 +9455,13 @@ export class WorkspaceService extends EventEmitter { agentId: options?.agentId, options, }); + // Codex P2 (PRRT_kwDOPxxmWM6b-orA): capture authoring time at request + // entry, before the preflight awaits below (pricing gate, AI-settings + // persistence). Goal safety compares this against goal creation, so + // sampling later — at enqueue or dispatch — would misclassify a message + // the user authored before a goal became visible as an intervention + // against it, pausing the fresh goal. + const authoredAtMs = Date.now(); let resumedInterruptedTask = false; let claimedAutoTitle = false; @@ -9616,6 +9623,10 @@ export class WorkspaceService extends EventEmitter { startStreamInBackground: internal?.startStreamInBackground, goalContinuation: internal?.goalContinuation, admissionEpochStale, + // The rejected manual send still persists the user row; carry the + // authoring time so goal safety and restart reconciliation can + // prove it predates any goal published during the pricing await. + enqueuedAtMs: authoredAtMs, }); } return Err(pricingGate.error); @@ -9702,6 +9713,7 @@ export class WorkspaceService extends EventEmitter { { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, + authoredAtMs, workspaceTurnContinuation: internal?.workspaceTurnContinuation, dedupeKey: internal?.queueDedupeKey, removableDedupeKey: internal?.removableQueueDedupeKey, @@ -9789,6 +9801,10 @@ export class WorkspaceService extends EventEmitter { startStreamInBackground: internal?.startStreamInBackground, cancelState: internal?.cancelState, cancelSignal: internal?.cancelSignal, + // Same authoring-time race as the queued path: the goal-creating + // stream can end during the preflight awaits above, making a fresh + // goal visible after the user hit enter but before this dispatch. + enqueuedAtMs: authoredAtMs, onCanceled: continuationSendState.onCanceled, onAccepted: internal?.onAccepted, onAcceptedPreStreamFailure, From e81cc185c221d6fab65adef57ff8f6a5aa36cc4b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 08:30:46 +0000 Subject: [PATCH 07/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=206=20?= =?UTF-8?q?=E2=80=94=20locked=20handoff=20between=20stream-end=20drain=20a?= =?UTF-8?q?nd=20mid-publication=20setter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: a stream ending during the setter's publication await let applyPendingAfterStreamEnd take the mutation synchronously (outside the goal file lock) and persist the pre-publication construction stamp — a message authored during the publication await was then misclassified as a post-goal intervention. The drain now flushes the lock and re-reads the mutation, draining the finalized publication stamp (or honoring a discard that landed in the same window). --- .../services/workspaceGoalService.test.ts | 44 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 15 ++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 0ac8db74212..ae7e557dc24 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2356,6 +2356,50 @@ describe("WorkspaceGoalService", () => { expect(await service.getGoal(workspaceId)).toBeNull(); }); + test("stream-end drain racing publication persists the publication stamp", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): the stream can end while the queued + // setter is still inside its publication await. The drain used to take the + // mutation synchronously — outside the goal file lock — and persist the + // pre-publication construction stamp, so a message authored during the + // publication await was misclassified as a post-goal intervention. The + // locked handoff must flush the setter first and drain the finalized + // publication stamp. + await extensionMetadata.setStreaming(workspaceId, true); + const drainPromises: Array> = []; + let fireDrains = false; + let lastActivityReadMs = 0; + const originalGetSnapshot = extensionMetadata.getSnapshot.bind(extensionMetadata); + spyOn(extensionMetadata, "getSnapshot").mockImplementation(async (id: string) => { + const snapshot = await originalGetSnapshot(id); + if (fireDrains && drainPromises.length < 8) { + await new Promise((resolve) => setTimeout(resolve, 5)); + lastActivityReadMs = Date.now(); + // Simulate the stream ending during this await: the stream-end drain + // races the setter that still holds the goal file lock. The drain + // fired during the publication read is the regression case; earlier + // fires see an empty mutation map and no-op. + drainPromises.push(service.applyPendingAfterStreamEnd(workspaceId)); + } + return snapshot; + }); + + fireDrains = true; + const queued = await service.setGoal({ + workspaceId, + objective: "Publication stamp handoff", + }); + fireDrains = false; + expect(queued.success).toBe(true); + expect(drainPromises.length).toBeGreaterThan(0); + await Promise.all(drainPromises); + + const persisted = await service.getGoal(workspaceId); + expect(persisted).not.toBeNull(); + // The drain must persist the post-publication stamp, not the construction + // stamp taken before the publication activity read. + expect(persisted?.createdAtMs ?? -1).toBeGreaterThanOrEqual(lastActivityReadMs); + }); + test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Original" }); await extensionMetadata.setStreaming(workspaceId, true); diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index c81123ff614..ac7fd25fa25 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -3009,7 +3009,20 @@ export class WorkspaceGoalService { async applyPendingAfterStreamEnd(workspaceId: string): Promise { this.liveGoalPreviewSnapshots.delete(workspaceId); - const pending = this.pendingGoalMutations.get(workspaceId); + let pending = this.pendingGoalMutations.get(workspaceId); + if (pending) { + // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): a queued setGoal may be holding the + // goal file lock mid-publication; its post-publication re-stamp replaces + // this mutation object with one carrying the finalized publication + // createdAtMs. Taking the mutation here without the locked handoff would + // drain the pre-publication construction stamp, and a message authored + // during the publication await would then be misclassified as a + // post-goal intervention. Wait for the lock to flush the setter, then + // re-read: the mutation may also have been legitimately discarded in + // that window (user abort / clearGoal). + await this.fileLocks.withLock(workspaceId, () => Promise.resolve()); + pending = this.pendingGoalMutations.get(workspaceId); + } let drained: GoalRecordV1 | null = null; if (pending) { From a9b94d898dc3dec0bba02c5f243ad622a3acd1d6 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 08:35:53 +0000 Subject: [PATCH 08/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20security=20round?= =?UTF-8?q?=20=E2=80=94=20preserve=20the=20newest=20authoring=20time=20in?= =?UTF-8?q?=20batched=20sends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex security P2: overlapping sends can complete preflight out of authoring order, letting an older pre-goal message overwrite a later post-goal stop/correction's authoring time — the batch then satisfied the pre-goal guard and kept the goal running despite the intervention. Fold each add's authoring time in via max(); seed entry creation with 0 so an authoredAtMs captured before slow preflight is never swallowed by the creation wall clock. --- src/node/services/messageQueue.test.ts | 17 +++++++++++++++++ src/node/services/messageQueue.ts | 13 +++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index df08fb05be8..6f9f4be0b97 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -24,6 +24,23 @@ describe("MessageQueue", () => { expect(enqueuedAtMs).toBe(authoredAtMs); }); + it("keeps the newest authoring time when batched adds arrive out of authoring order", () => { + // Codex security P2 (PRRT_kwDOPxxmWM6b_OS9): overlapping sends can + // complete preflight out of authoring order. The batched entry must + // report the NEWEST authoring time so a later post-goal stop/correction + // is never masked by an older pre-goal message — otherwise the batch + // would satisfy the pre-goal guard and keep the goal running despite + // the user's intervention. + const newerAuthoredAtMs = Date.now() - 1_000; + const olderAuthoredAtMs = Date.now() - 10_000; + queue.add("post-goal stop", undefined, { authoredAtMs: newerAuthoredAtMs }); + // The older send finishes its preflight late and batches into the entry. + queue.add("pre-goal message", undefined, { authoredAtMs: olderAuthoredAtMs }); + + const { enqueuedAtMs } = queue.dequeueNext(); + expect(enqueuedAtMs).toBe(newerAuthoredAtMs); + }); + it("falls back to enqueue-time sampling when authoring time is absent", () => { const before = Date.now(); queue.add("plain add"); diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 5ed9f3c19c0..d2a4dbc8975 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -474,7 +474,11 @@ export class MessageQueue { addCount: 0, syntheticCount: 0, agentInitiatedCount: 0, - lastAddedAtMs: Date.now(), + // 0, not Date.now(): every add (including the entry-creating one) + // folds its authoring time in below via max(); seeding with the + // creation wall clock would swallow an authoredAtMs captured before + // slow send preflight, defeating the pre-goal queue-race guard. + lastAddedAtMs: 0, }; this.entries.push(entry); } @@ -539,7 +543,12 @@ export class MessageQueue { } entry.addCount += 1; - entry.lastAddedAtMs = internal?.authoredAtMs ?? Date.now(); + // Codex security P2 (PRRT_kwDOPxxmWM6b_OS9): batched sends can finish + // preflight out of authoring order. Keep the NEWEST authoring time for + // the entry — a plain overwrite would let an older pre-goal message mask + // a later post-goal stop/correction, satisfying the pre-goal guard and + // granting the agent another autonomous turn despite the intervention. + entry.lastAddedAtMs = Math.max(entry.lastAddedAtMs, internal?.authoredAtMs ?? Date.now()); if (internal?.synthetic === true) { entry.syntheticCount += 1; } From 04e46a95a8251a23ec7331b2a47f75532fa1b037 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 08:52:38 +0000 Subject: [PATCH 09/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=207=20?= =?UTF-8?q?=E2=80=94=20validate=20persisted=20authoring=20timestamps=20bef?= =?UTF-8?q?ore=20goal-safety=20comparisons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: chat.jsonl rows are unchecked JSON, so a malformed enqueuedAtMs (negative/NaN/string) could beat a valid row timestamp in the nullish selection and misclassify a genuine post-goal intervention as pre-goal, leaving a never-driven goal running after restart. Only finite non-negative numbers participate; invalid values fall back to the validated row timestamp. --- .../services/workspaceGoalService.test.ts | 16 ++++++++++++++++ src/node/services/workspaceGoalService.ts | 19 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index ae7e557dc24..f2e7e7a6d34 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -468,6 +468,22 @@ describe("WorkspaceGoalService", () => { expect(reconciled).toMatchObject({ status: "paused" }); }); + test("getGoal ignores malformed persisted enqueuedAtMs and pauses on the row timestamp", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6b_1_J): chat.jsonl is unchecked JSON — a + // malformed enqueuedAtMs (negative here) must not beat a valid row + // timestamp, or a genuine post-goal intervention would be misread as + // pre-goal after a restart and the goal would keep running. + const created = await setGoalOk(service, { workspaceId, objective: "Malformed metadata" }); + await appendUserHistoryMessage(historyService, workspaceId, "Stop this goal", { + timestamp: created.createdAtMs + 1_000, + enqueuedAtMs: -1, + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "paused" }); + }); + test("chat-tail reconciliation ignores synthetic maintenance user rows", async () => { await setGoalOk(service, { workspaceId, objective: "Ignore maintenance rows" }); // Drive a real continuation first so the goal is past its kickoff window diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index ac7fd25fa25..28c01a574cd 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -199,6 +199,16 @@ interface PendingGoalContinuationCandidate { sendOptions: SendMessageOptions; } +/** + * Defensive validation for persisted timestamps: chat.jsonl rows are unchecked + * JSON, so metadata numbers can arrive malformed (negative, NaN, or a string + * masquerading through a cast). Only finite non-negative numbers participate + * in goal-safety comparisons; everything else is treated as absent. + */ +function toValidEpochMs(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + interface ChatTailGoalModeResult { mode: "active" | "paused" | null; /** @@ -525,7 +535,14 @@ export class WorkspaceGoalService { // Queue-dispatched rows persist their authoring time separately: the row // timestamp is stamped at dispatch, which can postdate a goal created at // the blocking turn's stream end even though the user typed pre-goal. - const authoredAtMs = message.metadata?.enqueuedAtMs ?? message.metadata?.timestamp; + // Codex P2 (PRRT_kwDOPxxmWM6b_1_J): chat.jsonl rows are unchecked JSON, + // so validate each candidate before comparing — a malformed enqueuedAtMs + // (negative, NaN, or a string) must fall back to the row timestamp + // instead of silently misclassifying a post-goal intervention as + // pre-goal and leaving the goal running. + const authoredAtMs = + toValidEpochMs(message.metadata?.enqueuedAtMs) ?? + toValidEpochMs(message.metadata?.timestamp); return { mode: "paused", pausedBy: "manual_user", From 43b64d2726e27ca4b7919b3c0aa9460622ddf2a2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 09:09:02 +0000 Subject: [PATCH 10/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=208=20?= =?UTF-8?q?=E2=80=94=20claim=20the=20pending=20mutation=20inside=20one=20l?= =?UTF-8?q?ock=20tenure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: the bare barrier released the lock before the drain reread and deleted the mutation, so a setter queued behind the barrier could start first, yield at its in-lock streaming recheck, and have the drain steal the older mutation out from under it — persisting stale state and stranding the setter's newer mutation with no remaining stream-end hook. The claim (read + delete) now happens inside the locked handoff, matching the tenure setters use for install/replace. --- src/node/services/workspaceGoalService.ts | 25 ++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 28c01a574cd..48c0e71be6c 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -3034,17 +3034,28 @@ export class WorkspaceGoalService { // createdAtMs. Taking the mutation here without the locked handoff would // drain the pre-publication construction stamp, and a message authored // during the publication await would then be misclassified as a - // post-goal intervention. Wait for the lock to flush the setter, then - // re-read: the mutation may also have been legitimately discarded in - // that window (user abort / clearGoal). - await this.fileLocks.withLock(workspaceId, () => Promise.resolve()); - pending = this.pendingGoalMutations.get(workspaceId); + // post-goal intervention. + // + // Codex P2 (PRRT_kwDOPxxmWM6cANQH): the claim (read + delete) must + // happen INSIDE one lock tenure. Setters install/replace mutations only + // while holding this lock, so an unlocked reread-then-delete after a + // bare barrier could interleave with a setter that queued behind the + // barrier: the drain would persist the older mutation and strand the + // setter's newer one with no remaining stream-end hook. The claim also + // honors discards that landed in the window (user abort / clearGoal), + // which delete the mutation before the claim runs. + pending = await this.fileLocks.withLock(workspaceId, () => { + const claimed = this.pendingGoalMutations.get(workspaceId); + if (claimed != null) { + this.pendingGoalMutations.delete(workspaceId); + this.pendingGoalSnapshots.delete(workspaceId); + } + return Promise.resolve(claimed); + }); } let drained: GoalRecordV1 | null = null; if (pending) { - this.pendingGoalMutations.delete(workspaceId); - this.pendingGoalSnapshots.delete(workspaceId); // Mirror the `setGoal` wrapper here: invalid queued transitions must // be logged and swallowed so the stream-end pipeline stays alive. // The caller already treats null as "no apply happened". From c5815f24c8ea777bb6dd661a0a3b24640b4d579e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 09:25:13 +0000 Subject: [PATCH 11/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=209=20?= =?UTF-8?q?=E2=80=94=20loop=20the=20stream-end=20drain=20until=20no=20pend?= =?UTF-8?q?ing=20mutation=20remains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: persistence runs in its own lock tenure after the claim, and the async streaming=false metadata update can still report the ended stream as live — a setter interleaving between claim and persistence installs a newer mutation expecting a stream-end drain that would never come. The drain now loops (bounded) so any replacement is deterministically drained; the newest mutation persists last and wins. --- .../services/workspaceGoalService.test.ts | 47 ++++++++++++++ src/node/services/workspaceGoalService.ts | 61 ++++++++++++------- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index f2e7e7a6d34..b3df94e7268 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2416,6 +2416,53 @@ describe("WorkspaceGoalService", () => { expect(persisted?.createdAtMs ?? -1).toBeGreaterThanOrEqual(lastActivityReadMs); }); + test("drain loops to persist a replacement mutation installed between claim and persistence", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cAl6e): the drain's claim and its persistence + // run in separate lock tenures, and a setter interleaving between them can + // still see the ended stream as live (async streaming=false update) and + // install a NEWER mutation expecting a stream-end drain that would never + // come. The drain must loop so the replacement is persisted too — a + // single-pass drain persists the older mutation and strands the newer one. + await extensionMetadata.setStreaming(workspaceId, true); + const first = await service.setGoal({ workspaceId, objective: "First goal" }); + expect(first.success).toBe(true); + + const serviceAccess = service as unknown as { + fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; + pendingGoalMutations: Map; + }; + // Hold the goal file lock with a gate so lock-queue order is + // deterministic: [gate] -> drain claim -> replacement setter -> drain + // persistence. The replacement setter therefore provably lands between + // the drain's claim and its persistence tenure. + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const gateTenure = serviceAccess.fileLocks.withLock(workspaceId, () => gate); + + // Queues the claim tenure behind the gate synchronously (the unlocked + // fast-path check and withLock call run before the first await). + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + const replacementPromise = service.setGoal({ + workspaceId, + objective: "Replacement goal", + forceNewGoal: true, + }); + // Let the replacement setter finish its pre-lock streaming check and + // queue on the lock before the gate opens. + await new Promise((resolve) => setTimeout(resolve, 25)); + releaseGate(); + await gateTenure; + + const [drained, replacement] = await Promise.all([drainPromise, replacementPromise]); + + expect(replacement.success).toBe(true); + expect(drained).toMatchObject({ objective: "Replacement goal" }); + // Nothing may be left stranded for a stream-end hook that will not come. + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); + }); + test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Original" }); await extensionMetadata.setStreaming(workspaceId, true); diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 48c0e71be6c..aba3aad890f 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -3026,25 +3026,40 @@ export class WorkspaceGoalService { async applyPendingAfterStreamEnd(workspaceId: string): Promise { this.liveGoalPreviewSnapshots.delete(workspaceId); - let pending = this.pendingGoalMutations.get(workspaceId); - if (pending) { - // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): a queued setGoal may be holding the - // goal file lock mid-publication; its post-publication re-stamp replaces - // this mutation object with one carrying the finalized publication - // createdAtMs. Taking the mutation here without the locked handoff would - // drain the pre-publication construction stamp, and a message authored - // during the publication await would then be misclassified as a - // post-goal intervention. - // - // Codex P2 (PRRT_kwDOPxxmWM6cANQH): the claim (read + delete) must - // happen INSIDE one lock tenure. Setters install/replace mutations only - // while holding this lock, so an unlocked reread-then-delete after a - // bare barrier could interleave with a setter that queued behind the - // barrier: the drain would persist the older mutation and strand the - // setter's newer one with no remaining stream-end hook. The claim also - // honors discards that landed in the window (user abort / clearGoal), - // which delete the mutation before the claim runs. - pending = await this.fileLocks.withLock(workspaceId, () => { + let drained: GoalRecordV1 | null = null; + + // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): a queued setGoal may be holding the + // goal file lock mid-publication; its post-publication re-stamp replaces + // the mutation object with one carrying the finalized publication + // createdAtMs. Taking the mutation without the locked handoff would drain + // the pre-publication construction stamp, and a message authored during + // the publication await would then be misclassified as a post-goal + // intervention. + // + // Codex P2 (PRRT_kwDOPxxmWM6cANQH): the claim (read + delete) must happen + // INSIDE one lock tenure — setters install/replace mutations only while + // holding this lock, so an unlocked reread-then-delete could steal an + // older mutation mid-setter. The claim also honors discards that landed + // in the window (user abort / clearGoal), which delete the mutation + // before the claim runs. + // + // Codex P2 (PRRT_kwDOPxxmWM6cAl6e): persistence (setGoalImmediately) is a + // separate lock tenure, and the async streaming=false metadata update can + // still report the ended stream as live — a setter interleaving between + // claim and persistence can therefore install a NEWER mutation expecting + // a stream-end drain that would otherwise never come (this is the last + // one). Loop until no pending mutation remains so any replacement is + // deterministically drained; the newest mutation persists last and wins. + // The pass cap is defensive — installs require a live-looking stream, so + // replacements cannot arrive indefinitely after stream end. + for (let pass = 0; pass < 10; pass += 1) { + // Unlocked fast path: setters install before publication, so a setter + // mid-publication always has a visible mutation here; an empty map means + // there is nothing to hand off (the common no-mutation stream end). + if (this.pendingGoalMutations.get(workspaceId) == null) { + break; + } + const pending = await this.fileLocks.withLock(workspaceId, () => { const claimed = this.pendingGoalMutations.get(workspaceId); if (claimed != null) { this.pendingGoalMutations.delete(workspaceId); @@ -3052,10 +3067,10 @@ export class WorkspaceGoalService { } return Promise.resolve(claimed); }); - } - let drained: GoalRecordV1 | null = null; + if (pending == null) { + break; + } - if (pending) { // Mirror the `setGoal` wrapper here: invalid queued transitions must // be logged and swallowed so the stream-end pipeline stays alive. // The caller already treats null as "no apply happened". @@ -3068,7 +3083,7 @@ export class WorkspaceGoalService { replacementCreatedAtMs: projectedCreatedAtMs ?? null, } ); - drained = result.success ? result.data : null; + drained = result.success ? result.data : drained; } catch (error) { log.warn("applyPendingAfterStreamEnd: dropped invalid queued goal mutation", { workspaceId, From 28cf28f6382956153b7fd87095fdd1d77ff05252 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 09:52:28 +0000 Subject: [PATCH 12/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2010=20?= =?UTF-8?q?=E2=80=94=20atomic=20claim+persist=20tenure,=20until-empty=20dr?= =?UTF-8?q?ain,=20wrap-up=20stamp=20preservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 x3: (1) extracted setGoalImmediately's locked core (persistGoalMutationLocked) + post-lock finalization so each drain pass claims AND persists inside one lock tenure — no setter can interleave between claim and persistence, so accepted expectedGoalId/replacement guards always validate against durable state that includes every drained pass. (2) replaced the fixed pass cap with an until-empty loop so a cap exit can never strand the newest mutation. (3) skipped maintenance streams on budget_limited goals no longer overwrite the goal-driven stamp that keeps the pending budget wrap-up eligible. --- .../services/workspaceGoalService.test.ts | 45 ++++++- src/node/services/workspaceGoalService.ts | 127 +++++++++++++----- 2 files changed, 134 insertions(+), 38 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index b3df94e7268..dd7421eae4c 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2372,6 +2372,42 @@ describe("WorkspaceGoalService", () => { expect(await service.getGoal(workspaceId)).toBeNull(); }); + test("skipped maintenance streams preserve the budget wrap-up stamp", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cBACb): once a goal-driven stream flips the + // goal to budget_limited, its stamp keeps the pending budget wrap-up + // eligible. A scheduled heartbeat ending before the wrap-up dispatches + // must not replace that stamp with a user-origin one, or the wrap-up is + // classified budget_wrapup_suppressed and the goal strands without its + // final turn. + const created = await setGoalOk(service, { + workspaceId, + objective: "Budget-limited goal", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 2, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ status: "budget_limited" }); + + // Scheduled heartbeat stream ends while the wrap-up is still pending. + await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.01, + streamStartedAtMs: created.createdAtMs + 2, + streamOriginKind: "user", + }); + + const stamps = ( + service as unknown as { + lastGoalStreamStamps: Map; + } + ).lastGoalStreamStamps; + expect(stamps.get(workspaceId)?.originKind).toBe("goal_continuation"); + }); + test("stream-end drain racing publication persists the publication stamp", async () => { // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): the stream can end while the queued // setter is still inside its publication await. The drain used to take the @@ -2424,8 +2460,7 @@ describe("WorkspaceGoalService", () => { // come. The drain must loop so the replacement is persisted too — a // single-pass drain persists the older mutation and strands the newer one. await extensionMetadata.setStreaming(workspaceId, true); - const first = await service.setGoal({ workspaceId, objective: "First goal" }); - expect(first.success).toBe(true); + const first = await setGoalOk(service, { workspaceId, objective: "First goal" }); const serviceAccess = service as unknown as { fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; @@ -2448,6 +2483,12 @@ describe("WorkspaceGoalService", () => { workspaceId, objective: "Replacement goal", forceNewGoal: true, + // Codex P2 (PRRT_kwDOPxxmWM6cBACj): the replacement targets the goal + // the user sees — the first mutation's projected id. Because each drain + // pass claims AND persists in one lock tenure, that id is already + // durable when this setter validates, and the accepted guard stays + // coherent when the drain replays this mutation on the next pass. + expectedGoalId: first.goalId, }); // Let the replacement setter finish its pre-lock streaming check and // queue on the lock before the gate opens. diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index aba3aad890f..9e57ce05671 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2217,7 +2217,25 @@ export class WorkspaceGoalService { input: SetGoalInput & { objective?: string }, options?: { replacementGoalId?: string | null; replacementCreatedAtMs?: number | null } ): Promise> { - const result = await this.fileLocks.withLock(input.workspaceId, async () => { + const result = await this.fileLocks.withLock(input.workspaceId, () => + this.persistGoalMutationLocked(input, options) + ); + return this.finalizeGoalPersistence(input, result); + } + + /** + * Locked core of `setGoalImmediately`: validates guards against the durable + * record and persists. Callers MUST hold the goal file lock. The stream-end + * drain calls this inside its claim tenure (Codex P2 PRRT_kwDOPxxmWM6cBACj) + * so no setter can interleave between claiming a pending mutation and + * persisting it — every later setter therefore validates its guards against + * durable state that already includes the drained mutation. + */ + private async persistGoalMutationLocked( + input: SetGoalInput & { objective?: string }, + options?: { replacementGoalId?: string | null; replacementCreatedAtMs?: number | null } + ): Promise> { + { const current = await this.readGoalFile(input.workspaceId); const conflict = this.conflictForExpectedGoalId(current, input.expectedGoalId) ?? @@ -2398,8 +2416,18 @@ export class WorkspaceGoalService { hasTurnCap: next.turnCap != null, }); return Ok(next); - }); + } + } + /** + * Post-lock finalization shared by `setGoalImmediately` and the stream-end + * drain: lifecycle/timeline side effects, pause-boundary handling, kickoff + * arming, and chat-tail syncs. Runs outside the goal file lock. + */ + private async finalizeGoalPersistence( + input: SetGoalInput & { objective?: string }, + result: Result + ): Promise> { if (!result.success) { return result; } @@ -2837,7 +2865,20 @@ export class WorkspaceGoalService { const isGoalDrivenStream = originKind === "goal_continuation" || originKind === "goal_budget_limit"; if (current.status !== "active" && !isGoalDrivenStream) { - this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); + // Codex P2 (PRRT_kwDOPxxmWM6cBACb): a skipped maintenance stream on a + // budget_limited goal must not overwrite the goal-driven stamp that + // keeps the pending budget wrap-up eligible — replacing it with a + // user/other stamp would make checkGoalContinuationEligibility + // classify the wrap-up as budget_wrapup_suppressed and delete its + // candidate, stranding the goal without its final wrap-up turn. + const existingStamp = this.lastGoalStreamStamps.get(input.workspaceId); + const preservesWrapupEligibility = + current.status === "budget_limited" && + existingStamp?.goalId === current.goalId && + this.isBudgetWrapupEligibleOrigin(existingStamp.originKind); + if (!preservesWrapupEligibility) { + this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); + } await this.pushSnapshot(input.workspaceId, current); return current; } @@ -3043,56 +3084,70 @@ export class WorkspaceGoalService { // in the window (user abort / clearGoal), which delete the mutation // before the claim runs. // - // Codex P2 (PRRT_kwDOPxxmWM6cAl6e): persistence (setGoalImmediately) is a - // separate lock tenure, and the async streaming=false metadata update can - // still report the ended stream as live — a setter interleaving between - // claim and persistence can therefore install a NEWER mutation expecting - // a stream-end drain that would otherwise never come (this is the last - // one). Loop until no pending mutation remains so any replacement is - // deterministically drained; the newest mutation persists last and wins. - // The pass cap is defensive — installs require a live-looking stream, so - // replacements cannot arrive indefinitely after stream end. - for (let pass = 0; pass < 10; pass += 1) { + // Codex P2 (PRRT_kwDOPxxmWM6cAl6e, PRRT_kwDOPxxmWM6cANQH, + // PRRT_kwDOPxxmWM6cBACj): each pass claims AND persists inside ONE lock + // tenure via `persistGoalMutationLocked`. Setters install/replace + // mutations only while holding this lock, so nothing can interleave + // between the claim and its persistence — a later setter always validates + // its expectedGoalId/replacement guard against durable state that already + // includes every drained pass, keeping accepted guards coherent on + // replay. + // + // Codex P2 (PRRT_kwDOPxxmWM6cBACH): loop until a locked claim observes no + // mutation instead of a fixed pass cap — a cap exit could strand the + // newest mutation with no remaining stream-end hook. Termination: nothing + // can install while we hold the lock, each iteration consumes the single + // mutation slot, and new installs require a setter that still observes + // the (stale) live-stream flag, which closes shortly after stream end. + while (true) { // Unlocked fast path: setters install before publication, so a setter // mid-publication always has a visible mutation here; an empty map means // there is nothing to hand off (the common no-mutation stream end). if (this.pendingGoalMutations.get(workspaceId) == null) { break; } - const pending = await this.fileLocks.withLock(workspaceId, () => { - const claimed = this.pendingGoalMutations.get(workspaceId); - if (claimed != null) { - this.pendingGoalMutations.delete(workspaceId); - this.pendingGoalSnapshots.delete(workspaceId); - } - return Promise.resolve(claimed); - }); - if (pending == null) { - break; - } - // Mirror the `setGoal` wrapper here: invalid queued transitions must // be logged and swallowed so the stream-end pipeline stays alive. // The caller already treats null as "no apply happened". + let claimedMutation = false; try { - const { projectedGoalId, projectedCreatedAtMs, ...pendingInput } = pending; - const result = await this.setGoalImmediately( - { workspaceId, ...pendingInput }, - { - replacementGoalId: projectedGoalId ?? null, - replacementCreatedAtMs: projectedCreatedAtMs ?? null, + const tenure = await this.fileLocks.withLock(workspaceId, async () => { + const claimed = this.pendingGoalMutations.get(workspaceId); + if (claimed == null) { + // Discarded (user abort / clearGoal) between the fast path and + // the claim. + return null; } - ); - drained = result.success ? result.data : drained; + claimedMutation = true; + this.pendingGoalMutations.delete(workspaceId); + this.pendingGoalSnapshots.delete(workspaceId); + const { projectedGoalId, projectedCreatedAtMs, ...pendingInput } = claimed; + const input = { workspaceId, ...pendingInput }; + return { + input, + result: await this.persistGoalMutationLocked(input, { + replacementGoalId: projectedGoalId ?? null, + replacementCreatedAtMs: projectedCreatedAtMs ?? null, + }), + }; + }); + if (tenure == null) { + break; + } + const finalized = await this.finalizeGoalPersistence(tenure.input, tenure.result); + drained = finalized.success ? finalized.data : drained; } catch (error) { log.warn("applyPendingAfterStreamEnd: dropped invalid queued goal mutation", { workspaceId, error: error instanceof Error ? error.message : String(error), }); } finally { - // Always re-read the durable record: queued snapshots are optimistic, and - // drains can succeed as persistence no-ops, reject, or throw. - await this.restorePersistedGoalSnapshot(workspaceId); + // Always re-read the durable record after a claim: queued snapshots + // are optimistic, and drains can succeed as persistence no-ops, + // reject, or throw. + if (claimedMutation) { + await this.restorePersistedGoalSnapshot(workspaceId); + } } } From 169c910f8cd780f0c66c2572e588cddbf1b70c67 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 10:17:33 +0000 Subject: [PATCH 13/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2011=20?= =?UTF-8?q?=E2=80=94=20direct-creation=20publication=20stamp,=20unconditio?= =?UTF-8?q?nal=20budget=20stamp=20preservation,=20drain-generation=20sette?= =?UTF-8?q?r=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 x3: (1) direct idle creations now re-stamp createdAtMs after the snapshot push (no await between push resolution and the re-stamp), so messages authored during kickoff-model validation or the write/push awaits compare as pre-goal — mirroring the queued path. (2) skipped maintenance streams never overwrite the stamp of the stream that hit the budget: goal-driven stamps keep the wrap-up eligible AND user-origin stamps keep it deliberately suppressed (a wake's 'other' stamp would have re-enabled a wrap-up the user's own stream blocked). (3) a per-workspace drain generation, bumped at drain entry and exit, lets setters that span a stream-end drain detect it at their in-lock recheck and persist directly instead of installing a mutation nothing drains. --- .../services/workspaceGoalService.test.ts | 94 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 97 ++++++++++++++++--- 2 files changed, 175 insertions(+), 16 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index dd7421eae4c..41e37b17382 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2408,6 +2408,100 @@ describe("WorkspaceGoalService", () => { expect(stamps.get(workspaceId)?.originKind).toBe("goal_continuation"); }); + test("background wakes preserve user-origin budget wrap-up suppression", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cBr9I): when a manual user stream exhausts the + // budget, its user-origin stamp deliberately suppresses the autonomous + // wrap-up. A later background wake ("other" origin) must not replace that + // stamp, or the wrap-up the user's own stream blocked would dispatch. + const created = await setGoalOk(service, { + workspaceId, + objective: "User-exhausted budget", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 2, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "user", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ status: "budget_limited" }); + + // Background bash-monitor wake ends while the goal sits budget_limited. + await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.01, + streamStartedAtMs: created.createdAtMs + 2, + streamOriginKind: "other", + }); + + const stamps = ( + service as unknown as { + lastGoalStreamStamps: Map; + } + ).lastGoalStreamStamps; + expect(stamps.get(workspaceId)?.originKind).toBe("user"); + }); + + test("direct idle goal creation stamps creation at publication time", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cBr9B): the direct (non-streaming) creation + // path stamped createdAtMs at construction, before kickoff-model + // validation and the write/push awaits. A message the user authored while + // the create request was in flight postdated that stamp and was misread + // as an intervention against a goal not yet visible. Creation must date + // from publication here too. + const dispatcher = new IdleDispatcher(); + let midValidationMs = 0; + service.registerGoalContinuationConsumer(dispatcher, { + ...continuationBridge(), + getKickoffSendOptions: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + if (midValidationMs === 0) { + // Only the FIRST call is the pre-persist kickoff-model validation; + // kickoff arming calls this again after persistence completes. + midValidationMs = Date.now(); + } + return { model: "openai:gpt-4o", agentId: "exec" }; + }, + }); + + const created = await setGoalOk(service, { + workspaceId, + objective: "Direct publication stamp", + budgetCents: 500, + }); + + expect(midValidationMs).toBeGreaterThan(0); + expect(created.createdAtMs).toBeGreaterThanOrEqual(midValidationMs); + expect(await service.getGoal(workspaceId)).toMatchObject({ createdAtMs: created.createdAtMs }); + }); + + test("setters that span a stream-end drain persist directly instead of queueing", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cBr9Q): a setGoal admitted while the (stale) + // streaming flag still reads live can reach its in-lock recheck after the + // stream-end drain already gave up watching the mutation map. It must + // detect the drain via the generation counter and persist directly — + // installing a queued mutation at that point would leave its projected + // success non-durable with no remaining stream-end hook. + await extensionMetadata.setStreaming(workspaceId, true); + + // Fire the setter first (captures the pre-drain generation), then run the + // drain before the setter's pre-lock await resolves. + const setterPromise = service.setGoal({ workspaceId, objective: "Late goal" }); + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + + const [setter, drained] = await Promise.all([setterPromise, drainPromise]); + expect(setter.success).toBe(true); + expect(drained).toBeNull(); + + // The setter must have persisted durably; nothing may sit in the mutation + // map waiting for a stream-end drain that will not come. + const serviceAccess = service as unknown as { + pendingGoalMutations: Map; + }; + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); + expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Late goal" }); + }); + test("stream-end drain racing publication persists the publication stamp", async () => { // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): the stream can end while the queued // setter is still inside its publication await. The drain used to take the diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 9e57ce05671..ce6b35dc4f5 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -428,6 +428,14 @@ export class WorkspaceGoalService { private lastUserStopAtMsByWorkspace = new Map(); private recordedStreamStartedAtMsByWorkspace = new Map(); private lastGoalStreamStamps = new Map(); + /** + * Monotonic per-workspace stream-end drain counter. Bumped when + * `applyPendingAfterStreamEnd` starts and again when it exits, so a queued + * setGoal can detect that a drain ran while it was in flight and must not + * install a pending mutation nothing will drain (Codex P2 + * PRRT_kwDOPxxmWM6cBr9Q). + */ + private readonly streamEndDrainGenerations = new Map(); private nextGoalStreamStampSequence = 1; private goalContinuationBridge: GoalContinuationRuntimeBridge | null = null; private goalContinuationDispatcher: IdleDispatcher | null = null; @@ -2039,6 +2047,13 @@ export class WorkspaceGoalService { private async setGoalInternal(input: SetGoalInput): Promise> { const objective = input.objective?.trim(); this.assertParentWorkspace(input.workspaceId); + // Codex P2 (PRRT_kwDOPxxmWM6cBr9Q): captured synchronously at entry so the + // in-lock recheck below can detect a stream-end drain that started or + // finished while this setter was in flight. The extension-metadata + // streaming flag updates asynchronously after stream end, so it alone can + // hold a stale "live" long enough for a setter to queue a mutation the + // drain has already stopped watching for. + const drainGenerationAtEntry = this.streamEndDrainGenerations.get(input.workspaceId) ?? 0; if (!objective && this.pendingGoalSnapshots.has(input.workspaceId)) { // Until stream-end persists the queued objective, status/budget-only edits @@ -2060,9 +2075,15 @@ export class WorkspaceGoalService { // ----------------------------------------------------------------------- if (objective && (await this.isWorkspaceStreaming(input.workspaceId))) { const deferredResult = await this.fileLocks.withLock(input.workspaceId, async () => { - if (!(await this.isWorkspaceStreaming(input.workspaceId))) { + if ( + !(await this.isWorkspaceStreaming(input.workspaceId)) || + (this.streamEndDrainGenerations.get(input.workspaceId) ?? 0) !== drainGenerationAtEntry + ) { // The stream can end while this caller waits for the goal file lock. - // Persist immediately instead of queueing after stream-end already drained. + // Persist immediately instead of queueing after stream-end already + // drained. The drain-generation check covers the stale-streaming + // window: the async streaming flag can still read "live" after the + // drain finished, and a mutation installed then would be stranded. return null; } const current = await this.readGoalFile(input.workspaceId); @@ -2112,9 +2133,13 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } - if (!(await this.isWorkspaceStreaming(input.workspaceId))) { + if ( + !(await this.isWorkspaceStreaming(input.workspaceId)) || + (this.streamEndDrainGenerations.get(input.workspaceId) ?? 0) !== drainGenerationAtEntry + ) { // Avoid queueing after the one stream-end drain has already observed no - // pending mutation. + // pending mutation (stale-streaming reads included — see the + // drain-generation comment on the first recheck above). return null; } // Codex P1 (PRRT_kwDOPxxmWM6b-orH): the mutation must be installed @@ -2375,7 +2400,7 @@ export class WorkspaceGoalService { return Ok(updated); } - const next = this.createGoal({ + let next = this.createGoal({ objective, budgetCents: input.budgetCents ?? null, turnCap: input.turnCap ?? null, @@ -2408,6 +2433,25 @@ export class WorkspaceGoalService { } await this.writeGoal(input.workspaceId, next); await this.pushSnapshot(input.workspaceId, next); + if (options?.replacementCreatedAtMs == null) { + // Codex P2 (PRRT_kwDOPxxmWM6cBr9B): direct creations must also carry a + // publication-time createdAtMs. The construction stamp above predates + // the kickoff-model validation, history-archive, and write/push awaits + // — a message the user authored during those awaits would postdate it + // and be misread as an intervention against a goal not yet visible. + // No await sits between the snapshot push resolving and this re-stamp, + // so nothing can be admitted in between; the durable record carries + // the publication stamp (crash between the writes leaves the + // provisional stamp — today's behavior). Drained queued mutations pass + // replacementCreatedAtMs and already carry their publication stamp. + const publishedAtMs = Date.now(); + next = GoalRecordV1Schema.parse({ + ...next, + createdAtMs: publishedAtMs, + updatedAtMs: publishedAtMs, + }); + await this.writeGoal(input.workspaceId, next); + } this.emitBudgetChanged(current, next, input); this.emitLifecycle(current ? "goal_replaced" : "goal_created", { sameObjective: current?.objective === objective, @@ -2865,18 +2909,20 @@ export class WorkspaceGoalService { const isGoalDrivenStream = originKind === "goal_continuation" || originKind === "goal_budget_limit"; if (current.status !== "active" && !isGoalDrivenStream) { - // Codex P2 (PRRT_kwDOPxxmWM6cBACb): a skipped maintenance stream on a - // budget_limited goal must not overwrite the goal-driven stamp that - // keeps the pending budget wrap-up eligible — replacing it with a - // user/other stamp would make checkGoalContinuationEligibility - // classify the wrap-up as budget_wrapup_suppressed and delete its - // candidate, stranding the goal without its final wrap-up turn. + // Codex P2 (PRRT_kwDOPxxmWM6cBACb, PRRT_kwDOPxxmWM6cBr9I): a skipped + // maintenance stream on a budget_limited goal must never overwrite + // the stamp of the stream that hit the budget. A goal-driven stamp + // keeps the pending wrap-up eligible (overwriting it would suppress + // the final wrap-up turn); a user-origin stamp deliberately + // suppresses the wrap-up (overwriting it with a wake's "other" stamp + // would dispatch an autonomous wrap-up the user's own budget-limited + // stream intentionally blocked). Only stamp when no stamp exists for + // this goal (e.g. after restart, where suppression is handled by the + // durable budgetLimitInjectedForGoalId gate). const existingStamp = this.lastGoalStreamStamps.get(input.workspaceId); - const preservesWrapupEligibility = - current.status === "budget_limited" && - existingStamp?.goalId === current.goalId && - this.isBudgetWrapupEligibleOrigin(existingStamp.originKind); - if (!preservesWrapupEligibility) { + const preserveExistingStamp = + current.status === "budget_limited" && existingStamp?.goalId === current.goalId; + if (!preserveExistingStamp) { this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); } await this.pushSnapshot(input.workspaceId, current); @@ -3065,8 +3111,20 @@ export class WorkspaceGoalService { } } + private bumpStreamEndDrainGeneration(workspaceId: string): void { + this.streamEndDrainGenerations.set( + workspaceId, + (this.streamEndDrainGenerations.get(workspaceId) ?? 0) + 1 + ); + } + async applyPendingAfterStreamEnd(workspaceId: string): Promise { this.liveGoalPreviewSnapshots.delete(workspaceId); + // Codex P2 (PRRT_kwDOPxxmWM6cBr9Q): bump the drain generation at entry so + // setters admitted BEFORE this drain detect it at their in-lock recheck + // and persist directly instead of installing a mutation this drain may + // already have stopped watching for. + this.bumpStreamEndDrainGeneration(workspaceId); let drained: GoalRecordV1 | null = null; // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): a queued setGoal may be holding the @@ -3151,6 +3209,13 @@ export class WorkspaceGoalService { } } + // Codex P2 (PRRT_kwDOPxxmWM6cBr9Q): bump again on exit, synchronously with + // the loop's final empty-map check (no await sits between them). A setter + // admitted DURING this drain whose in-lock recheck runs after that final + // check therefore sees a changed generation and persists directly; one + // whose recheck ran earlier installed a mutation the loop drained. + this.bumpStreamEndDrainGeneration(workspaceId); + // Stream-end deferred auto-promotion. // // Runs AFTER any queued setGoal drains so the deferred setGoal can From 5be7ae230d54e4fd280415920f791e2448493ca2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 10:39:01 +0000 Subject: [PATCH 14/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2012=20?= =?UTF-8?q?=E2=80=94=20reject=20stop-spanning=20setters,=20settle=20worksp?= =?UTF-8?q?aces=20after=20drains=20and=20stops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: a user abort landing while a setGoal was still in its pre-install awaits deleted nothing and the setter then installed or directly persisted a goal from the aborted turn. setGoalInternal now captures the last user-stop timestamp at entry and rejects with a typed error at both in-lock gates and the direct fall-through when a stop landed mid-flight. Codex P2: setters admitted after the drain's final empty-map check captured the already-bumped generation and could still read a stale live streaming flag. Workspaces are now marked settled at drain exit (synchronously with the final check) and on user stops (which skip the drain entirely); settled workspaces bypass queueing and persist directly until the next stream start clears the flag. --- .../services/workspaceGoalService.test.ts | 54 ++++++++++++-- src/node/services/workspaceGoalService.ts | 74 ++++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 41e37b17382..4be2cd2a38b 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2343,16 +2343,18 @@ describe("WorkspaceGoalService", () => { // applying the discarded goal at the end of the NEXT stream (AgentSession // deliberately skips the stream-end drain for user aborts). await extensionMetadata.setStreaming(workspaceId, true); + const mutationAccess = service as unknown as { pendingGoalMutations: Map }; const stopPromises: Array> = []; let fireStops = false; const originalGetSnapshot = extensionMetadata.getSnapshot.bind(extensionMetadata); spyOn(extensionMetadata, "getSnapshot").mockImplementation(async (id: string) => { const snapshot = await originalGetSnapshot(id); - if (fireStops) { - // Fire the abort's synchronous mutation delete at every await inside - // the queued-setGoal path, including the activity read inside - // publishPendingGoalSnapshot; the abort then queues on the goal file - // lock behind the setter. + if (fireStops && mutationAccess.pendingGoalMutations.get(workspaceId) != null) { + // Fire the abort's synchronous mutation delete during the activity + // read inside publishPendingGoalSnapshot — the only await with the + // mutation already installed (a stop during the pre-install awaits is + // rejected outright; see the span-a-user-stop test). The abort then + // queues on the goal file lock behind the setter. stopPromises.push(service.recordUserStoppedStream(workspaceId)); } return snapshot; @@ -2502,6 +2504,48 @@ describe("WorkspaceGoalService", () => { expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Late goal" }); }); + test("goal setters that span a user stop are rejected", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cCH_H): if the user aborts while a setGoal is + // still in its pre-install awaits, recordUserStoppedStream finds no + // mutation to delete — the setter must detect the stop and reject instead + // of installing (stale streaming flag) or persisting directly, either of + // which would create and arm a goal from a turn the user just aborted. + await extensionMetadata.setStreaming(workspaceId, true); + + // The setter captures the pre-stop state at entry; the stop's synchronous + // prefix runs while the setter is inside its pre-lock streaming check. + const setterPromise = service.setGoal({ workspaceId, objective: "Aborted turn goal" }); + const stopPromise = service.recordUserStoppedStream(workspaceId); + const [setter] = await Promise.all([setterPromise, stopPromise]); + + expect(setter.success).toBe(false); + if (!setter.success) { + expect(setter.error.type).toBe("invalid_transition"); + } + const serviceAccess = service as unknown as { pendingGoalMutations: Map }; + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); + expect(await service.getGoal(workspaceId)).toBeNull(); + }); + + test("setters admitted after the drain settles persist directly", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cCH_L): a setter admitted after the drain's + // final empty-map check captures the already-bumped generation, and the + // streaming flag can still read stale-live — it must observe the settled + // state and persist directly instead of installing a mutation that stays + // non-durable until some unrelated later stream ends. + await extensionMetadata.setStreaming(workspaceId, true); + const drained = await service.applyPendingAfterStreamEnd(workspaceId); + expect(drained).toBeNull(); + + // Admitted strictly after the drain returned; streaming flag still true. + const setter = await service.setGoal({ workspaceId, objective: "Post-drain goal" }); + + expect(setter.success).toBe(true); + const serviceAccess = service as unknown as { pendingGoalMutations: Map }; + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); + expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Post-drain goal" }); + }); + test("stream-end drain racing publication persists the publication stamp", async () => { // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): the stream can end while the queued // setter is still inside its publication await. The drain used to take the diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index ce6b35dc4f5..4c0bdb4d48e 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -58,6 +58,8 @@ const GOAL_FILE = "goal.json"; const GOAL_BOARD_FILE = "goal-board.json"; const PENDING_GOAL_EDIT_MESSAGE = "Goal is still being saved. Wait for the current stream to finish before editing it."; +const GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE = + "Goal change discarded: the stream was stopped while this change was in flight."; const REPLACE_GUARDED_STATUSES: ReadonlySet = new Set([ "active", "budget_limited", @@ -436,6 +438,15 @@ export class WorkspaceGoalService { * PRRT_kwDOPxxmWM6cBr9Q). */ private readonly streamEndDrainGenerations = new Map(); + /** + * Workspaces whose last stream has ended and fully drained (or was user + * stopped, which skips the drain). The extension-metadata streaming flag + * clears asynchronously after stream end, so setters admitted after the + * drain's final empty-map check could otherwise still read a stale "live" + * flag and install a mutation nothing will drain (Codex P2 + * PRRT_kwDOPxxmWM6cCH_L). Cleared when the next stream start is recorded. + */ + private readonly drainSettledWorkspaces = new Set(); private nextGoalStreamStampSequence = 1; private goalContinuationBridge: GoalContinuationRuntimeBridge | null = null; private goalContinuationDispatcher: IdleDispatcher | null = null; @@ -906,6 +917,9 @@ export class WorkspaceGoalService { await this.fileLocks.withLock(workspaceId, async () => { this.liveGoalPreviewSnapshots.delete(workspaceId); if (options.streamStartedAtMs != null) { + // A new stream is starting: queued mid-stream setGoal is meaningful + // again, so leave the settled fast-path (see drainSettledWorkspaces). + this.drainSettledWorkspaces.delete(workspaceId); this.recordedStreamStartedAtMsByWorkspace.set(workspaceId, options.streamStartedAtMs); } const current = await this.readGoalFile(workspaceId); @@ -1164,6 +1178,11 @@ export class WorkspaceGoalService { assert(workspaceId.trim().length > 0, "recordUserStoppedStream requires workspaceId"); assert(Number.isFinite(stoppedAtMs) && stoppedAtMs >= 0, "user stop timestamp must be valid"); this.lastUserStopAtMsByWorkspace.set(workspaceId, stoppedAtMs); + // A user stop ends the stream WITHOUT a stream-end drain (AgentSession + // deliberately skips it), so treat the workspace as settled: setters + // admitted after this stop must persist directly instead of queueing a + // mutation nothing will drain (see drainSettledWorkspaces). + this.drainSettledWorkspaces.add(workspaceId); this.pendingContinuationCandidates.delete(workspaceId); this.pendingGoalSnapshots.delete(workspaceId); this.liveGoalPreviewSnapshots.delete(workspaceId); @@ -2054,6 +2073,13 @@ export class WorkspaceGoalService { // hold a stale "live" long enough for a setter to queue a mutation the // drain has already stopped watching for. const drainGenerationAtEntry = this.streamEndDrainGenerations.get(input.workspaceId) ?? 0; + // Codex P1 (PRRT_kwDOPxxmWM6cCH_H): also captured synchronously at entry. + // A user stop landing while this setter is in flight means the stopped + // turn's goal change must be discarded — recordUserStoppedStream deletes + // only already-installed mutations, so a setter still in its pre-install + // awaits would otherwise install (or directly persist) a goal the abort + // meant to discard. + const userStopAtMsAtEntry = this.lastUserStopAtMsByWorkspace.get(input.workspaceId) ?? null; if (!objective && this.pendingGoalSnapshots.has(input.workspaceId)) { // Until stream-end persists the queued objective, status/budget-only edits @@ -2073,17 +2099,29 @@ export class WorkspaceGoalService { // Without carrying this id into the drain, a transcript-persisted set_goal // result could point complete_goal at a throwaway pre-persistence id. // ----------------------------------------------------------------------- - if (objective && (await this.isWorkspaceStreaming(input.workspaceId))) { + if ( + objective && + !this.drainSettledWorkspaces.has(input.workspaceId) && + (await this.isWorkspaceStreaming(input.workspaceId)) + ) { const deferredResult = await this.fileLocks.withLock(input.workspaceId, async () => { + if (this.userStopLandedSince(input.workspaceId, userStopAtMsAtEntry)) { + return Err({ + type: "invalid_transition" as const, + message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, + }); + } if ( !(await this.isWorkspaceStreaming(input.workspaceId)) || + this.drainSettledWorkspaces.has(input.workspaceId) || (this.streamEndDrainGenerations.get(input.workspaceId) ?? 0) !== drainGenerationAtEntry ) { // The stream can end while this caller waits for the goal file lock. // Persist immediately instead of queueing after stream-end already - // drained. The drain-generation check covers the stale-streaming - // window: the async streaming flag can still read "live" after the - // drain finished, and a mutation installed then would be stranded. + // drained. The drain-generation and settled checks cover the + // stale-streaming window: the async streaming flag can still read + // "live" after the drain finished, and a mutation installed then + // would be stranded. return null; } const current = await this.readGoalFile(input.workspaceId); @@ -2133,8 +2171,15 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } + if (this.userStopLandedSince(input.workspaceId, userStopAtMsAtEntry)) { + return Err({ + type: "invalid_transition" as const, + message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, + }); + } if ( !(await this.isWorkspaceStreaming(input.workspaceId)) || + this.drainSettledWorkspaces.has(input.workspaceId) || (this.streamEndDrainGenerations.get(input.workspaceId) ?? 0) !== drainGenerationAtEntry ) { // Avoid queueing after the one stream-end drain has already observed no @@ -2221,9 +2266,23 @@ export class WorkspaceGoalService { } } + if (this.userStopLandedSince(input.workspaceId, userStopAtMsAtEntry)) { + // Codex P1 (PRRT_kwDOPxxmWM6cCH_H): the abort can also land after the + // pre-lock admission read `streaming=false` — do not fall through to + // immediate persistence for a setter the stop meant to discard. + return Err({ + type: "invalid_transition" as const, + message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, + }); + } return this.setGoalImmediately({ ...input, objective }); } + /** Whether a user stop was recorded after the caller captured `stopAtMsAtEntry`. */ + private userStopLandedSince(workspaceId: string, stopAtMsAtEntry: number | null): boolean { + return (this.lastUserStopAtMsByWorkspace.get(workspaceId) ?? null) !== stopAtMsAtEntry; + } + private async canRunBudgetedGoalOnKickoffModel( workspaceId: string, goal: GoalRecordV1 @@ -3215,6 +3274,13 @@ export class WorkspaceGoalService { // check therefore sees a changed generation and persists directly; one // whose recheck ran earlier installed a mutation the loop drained. this.bumpStreamEndDrainGeneration(workspaceId); + // Codex P2 (PRRT_kwDOPxxmWM6cCH_L): setters admitted AFTER this point + // capture the already-bumped generation, so the generation gate cannot + // help them — mark the workspace settled (also synchronously with the + // final empty-map check) so they observe a deterministic "no stream-end + // hook is coming" state and persist directly until the next stream start + // clears it. + this.drainSettledWorkspaces.add(workspaceId); // Stream-end deferred auto-promotion. // From 3b794dcd27cf26e9c658a588525aea37d06a83c0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 11:26:29 +0000 Subject: [PATCH 15/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2013=20?= =?UTF-8?q?=E2=80=94=20clear=20settled=20marker=20on=20stream=20start,=20s?= =?UTF-8?q?top-gate=20persistence,=20guard=20stale=20arming,=20suspend=20c?= =?UTF-8?q?andidates=20during=20classification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 13 findings: - P1 PRRT_kwDOPxxmWM6cClKS: drainSettledWorkspaces was only cleared by terminal-error restoration, so every subsequent successful stream bypassed mid-stream setGoal deferral. AgentSession's stream-start handler now notifies the goal service synchronously (recordStreamStarted). - P1 PRRT_kwDOPxxmWM6cClKV: the stop check before setGoalImmediately was not the last word — persistence re-checks a monotonic user-stop generation after every await preceding a durable write (and finalization skips arming) so an abort landing mid-persistence discards the goal. Generations replace timestamp equality: same-ms stops compared equal and resume/promotion paths delete the timestamp, which a !== gate misread as a fresh stop. - P2 PRRT_kwDOPxxmWM6cClKY: kickoff/wrap-up arming re-verifies the durable goal identity after the unlocked kickoff-options await, so a stale finalizer cannot overwrite a newer goal's candidate (stranding the durable goal with no kickoff); stale candidates for replaced goals are still replaced. - P1 PRRT_kwDOPxxmWM6cClKd: manual-send goal safety now takes the continuation candidate synchronously before the acknowledgment await, so an eligibility check during classification cannot consume it; the pre-goal queue-race branch restores the suspended candidate. --- .../agentSession.goalAutoPause.test.ts | 82 ++++++- src/node/services/agentSession.ts | 40 ++-- .../services/workspaceGoalService.test.ts | 98 ++++++++ src/node/services/workspaceGoalService.ts | 223 ++++++++++++++++-- 4 files changed, 409 insertions(+), 34 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 719338f34f5..33980fd6297 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -241,23 +241,40 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + // Shared harness for the candidate-suspension tests: a busy runtime keeps + // eligibility deferring so armed kickoff candidates stay inspectable instead + // of being consumed by a live dispatch. + function registerBusyKickoffConsumer(goalService: WorkspaceGoalService): Map { + goalService.registerGoalContinuationConsumer(new IdleDispatcher(), { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true, isBusy: true }), + executeGoalContinuation: () => Promise.resolve(true), + getKickoffSendOptions: () => Promise.resolve(SEND_OPTIONS), + }); + return (goalService as unknown as { pendingContinuationCandidates: Map }) + .pendingContinuationCandidates; + } + test("acknowledgment failure still clears the kickoff candidate", async () => { // Codex P2 (PRRT_kwDOPxxmWM6b-Uln): the manual row is durably appended // before goal safety runs. If acknowledgeUser() throws (goal / // extension-metadata write failure), the pre-goal queue-race guard can // never prove the message predates the goal, so the kickoff candidate must - // still be cleared conservatively — a stale candidate could otherwise - // dispatch a continuation against the user's persisted intervention once - // the failed send returns the workspace to idle. + // stay cleared conservatively — a stale candidate could otherwise dispatch + // a continuation against the user's persisted intervention once the failed + // send returns the workspace to idle. The candidate is taken synchronously + // before the acknowledgment await (Codex P1 PRRT_kwDOPxxmWM6cClKd), so a + // throw leaves it cleared without a separate clear call. const workspaceId = "ack-failure-clears-candidate"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); cleanups.push(cleanup); + const candidates = registerBusyKickoffConsumer(goalService); const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); + expect(candidates.has(workspaceId)).toBe(true); spyOn(goalService, "acknowledgeUser").mockImplementation(() => Promise.reject(new Error("goal write failed")) ); - const clearSpy = spyOn(goalService, "clearPendingContinuationForManualUserMessage"); let thrown: unknown = null; try { @@ -269,7 +286,62 @@ describe("AgentSession goal safety hooks", () => { } expect(thrown).toBeInstanceOf(Error); - expect(clearSpy).toHaveBeenCalledWith(workspaceId); + expect(candidates.has(workspaceId)).toBe(false); + session.dispose(); + }); + + test("kickoff candidate is not consumable while a manual send is classified", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cClKd): a direct send appends its durable row + // before the session reports busy, so an eligibility check running during + // the acknowledgment await must not find (and consume) the kickoff + // candidate — it would dispatch a continuation against the user's + // intervention. + const workspaceId = "manual-classification-suspends-candidate"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const candidates = registerBusyKickoffConsumer(goalService); + const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); + expect(candidates.has(workspaceId)).toBe(true); + + const originalAcknowledge = goalService.acknowledgeUser.bind(goalService); + let eligibilityDuringAck: string | undefined; + spyOn(goalService, "acknowledgeUser").mockImplementation(async (id: string) => { + // Runs mid-classification: the candidate must already be suspended. + const eligibility = await goalService.checkGoalContinuationEligibility(id, Date.now()); + eligibilityDuringAck = eligibility.eligible ? "eligible" : eligibility.reason; + return originalAcknowledge(id); + }); + + const result = await session.sendMessage("Typed with the goal in view", SEND_OPTIONS, { + enqueuedAtMs: created.createdAtMs + 1, + }); + + expect(result.success).toBe(true); + expect(eligibilityDuringAck).toBe("no_pending_candidate"); + expect(candidates.has(workspaceId)).toBe(false); + session.dispose(); + }); + + test("pre-goal queued sends restore the suspended kickoff candidate", async () => { + // Complement to the suspension test above: a queued send authored before + // the goal existed is not an intervention, so the taken candidate must be + // restored and the fresh goal must keep its kickoff continuation. + const workspaceId = "pre-goal-restores-candidate"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const candidates = registerBusyKickoffConsumer(goalService); + const enqueuedAtMs = Date.now(); + const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); + expect(created.createdAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + expect(candidates.has(workspaceId)).toBe(true); + + const result = await session.sendMessage("Queued before the goal existed", SEND_OPTIONS, { + enqueuedAtMs, + }); + + expect(result.success).toBe(true); + expect(candidates.has(workspaceId)).toBe(true); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" }); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c2a9f3a4e21..25542d1c65a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1630,20 +1630,22 @@ export class AgentSession { // anything manually typed by the user pauses until Resume appends a fresh // continuation. Legacy clients may still send the old "steer" policy; treat // it as pause so the invariant holds at this backend boundary. - let goal: Awaited>; - try { - goal = await goalService.acknowledgeUser(this.workspaceId); - } catch (error) { - // Codex P2 (PRRT_kwDOPxxmWM6b-Uln): the manual row is already durably - // appended by this point. When acknowledgment fails we cannot read the - // goal to prove the pre-goal queue race below, so conservatively clear - // the kickoff candidate first (the pre-reorder behavior): once the - // failed send returns the workspace to idle, a stale candidate could - // otherwise dispatch a continuation despite the user's persisted - // intervention. - goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); - throw error; - } + // + // Codex P1 (PRRT_kwDOPxxmWM6cClKd): the manual row is already durable, but + // a direct send has not marked the session busy yet — an eligibility check + // during the acknowledgment await below would see the workspace idle and + // consume a still-armed kickoff candidate, dispatching a continuation + // against the user's intervention. Take the candidate synchronously BEFORE + // any await; the pre-goal queue-race branch restores it. + const suspendedCandidate = goalService.takePendingContinuationCandidateForManualUserMessage( + this.workspaceId + ); + // Codex P2 (PRRT_kwDOPxxmWM6b-Uln): on acknowledgment failure we cannot + // read the goal to prove the pre-goal queue race below, so leave the + // candidate cleared conservatively (it was taken above): once the failed + // send returns the workspace to idle, a stale candidate could otherwise + // dispatch a continuation despite the user's persisted intervention. + const goal = await goalService.acknowledgeUser(this.workspaceId); // Queue race: a message the user typed while the goal-creating turn was // still streaming predates the goal itself — the model's queued set_goal @@ -1654,9 +1656,14 @@ export class AgentSession { // "paused by heartbeats" were actually killed here, then heartbeat turns // kept the workspace moving while the goal sat paused). if (input.enqueuedAtMs != null && goal != null && goal.createdAtMs >= input.enqueuedAtMs) { + if (suspendedCandidate != null) { + goalService.restorePendingContinuationCandidate(this.workspaceId, suspendedCandidate); + } return; } + // Also clears any candidate armed during the acknowledgment await — a + // post-goal intervention must not leave a consumable continuation behind. goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); if (goal?.status !== "active") { return; @@ -5363,6 +5370,11 @@ export class AgentSession { this.dispatchingQueuedEntryMuxMetadata = undefined; this.preparingWorkspaceTurnMetadata = undefined; this.activeStreamStartedAtMs = payload.startTime; + // Codex P1 (PRRT_kwDOPxxmWM6cClKS): a new live stream makes mid-stream + // setGoal deferral meaningful again — clear the goal service's settled + // fast-path synchronously so a model set_goal in THIS stream queues + // for its stream-end drain instead of writing goal.json mid-stream. + this.workspaceGoalService?.recordStreamStarted(this.workspaceId); this.queuedProviderToolEndAbortInFlight = false; this.activeToolCallIds.clear(); } diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 4be2cd2a38b..5a12ffe9e7b 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2546,6 +2546,104 @@ describe("WorkspaceGoalService", () => { expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Post-drain goal" }); }); + test("recordStreamStarted clears the settled marker so the next stream defers mid-stream setGoal", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cClKS): every drain exit (and user stop) marks + // the workspace settled, but production only cleared the marker on + // terminal-error restoration. Without an explicit stream-start + // notification, a model set_goal in the NEXT successful stream bypassed + // deferral and wrote goal.json mid-stream. + await extensionMetadata.setStreaming(workspaceId, true); + const drained = await service.applyPendingAfterStreamEnd(workspaceId); + expect(drained).toBeNull(); + + // AgentSession's stream-start handler notifies synchronously. + service.recordStreamStarted(workspaceId); + + const setter = await service.setGoal({ workspaceId, objective: "Mid-stream goal" }); + expect(setter.success).toBe(true); + const serviceAccess = service as unknown as { pendingGoalMutations: Map }; + // Deferred again: the mutation queues for THIS stream's stream-end drain + // instead of persisting mid-stream. + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeDefined(); + expect(await goalFileExists(config, workspaceId)).toBe(false); + }); + + test("a user stop landing while direct persistence awaits the goal lock discards the setter", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cClKV): the pre-persistence stop check is not + // the last word — setGoalImmediately still awaits the goal file lock and + // the durable writes. A stop landing during those awaits must discard the + // change instead of durably creating a goal from the aborted turn. + const serviceAccess = service as unknown as { + fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; + }; + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const gateTenure = serviceAccess.fileLocks.withLock(workspaceId, () => gate); + + // Direct path (no live stream): the setter passes its pre-lock stop check + // and queues behind the gate. + const setterPromise = service.setGoal({ workspaceId, objective: "Aborted direct goal" }); + await new Promise((resolve) => setTimeout(resolve, 25)); + // The stop bumps the stop generation synchronously; its own locked section + // queues behind the setter's tenure. + const stopPromise = service.recordUserStoppedStream(workspaceId); + releaseGate(); + await gateTenure; + const [setter] = await Promise.all([setterPromise, stopPromise]); + + expect(setter.success).toBe(false); + if (!setter.success) { + expect(setter.error.type).toBe("invalid_transition"); + } + expect(await service.getGoal(workspaceId)).toBeNull(); + expect(await goalFileExists(config, workspaceId)).toBe(false); + }); + + test("a stale kickoff finalizer does not overwrite a newer goal's candidate", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cClKY): kickoff finalization runs outside the + // goal file lock. While finalizer A awaits kickoff options, a newer setter + // B can persist directly and arm B's kickoff; A resuming afterwards must + // not replace B's candidate with a stale one that eligibility would drop + // for goal-ID mismatch — that would strand durable goal B with no kickoff. + const dispatcher = new IdleDispatcher(); + let kickoffCalls = 0; + let releaseFirstKickoff!: () => void; + const firstKickoffGate = new Promise((resolve) => { + releaseFirstKickoff = resolve; + }); + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + // Busy runtime keeps eligibility deferring so armed candidates stay + // inspectable instead of being consumed by a live dispatch. + getRuntimeState: () => ({ isRuntimeCompatible: true, isBusy: true }), + executeGoalContinuation: () => Promise.resolve(true), + getKickoffSendOptions: async () => { + kickoffCalls += 1; + if (kickoffCalls === 1) { + await firstKickoffGate; + } + return { model: "openai:gpt-4o", agentId: "exec" }; + }, + }); + + const setterAPromise = service.setGoal({ workspaceId, objective: "Stale finalizer goal A" }); + await waitForCondition(() => kickoffCalls === 1, { timeoutMs: 1_000 }); + // B persists and arms its kickoff while A's finalizer is still suspended. + const goalB = await setGoalOk(service, { workspaceId, objective: "Newer goal B" }); + releaseFirstKickoff(); + const setterA = await setterAPromise; + expect(setterA.success).toBe(true); + + const candidates = ( + service as unknown as { + pendingContinuationCandidates: Map; + } + ).pendingContinuationCandidates; + expect(candidates.get(workspaceId)?.goalId).toBe(goalB.goalId); + }); + test("stream-end drain racing publication persists the publication stamp", async () => { // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): the stream can end while the queued // setter is still inside its publication await. The drain used to take the diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 4c0bdb4d48e..1cfa647f59a 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -193,7 +193,7 @@ export interface GoalContinuationRuntimeBridge { type PendingGoalContinuationSource = "stream_end" | "kickoff" | "budget_wrapup"; -interface PendingGoalContinuationCandidate { +export interface PendingGoalContinuationCandidate { goalId: string; requestedAtMs: number; streamEndedAtMs: number; @@ -201,6 +201,19 @@ interface PendingGoalContinuationCandidate { sendOptions: SendMessageOptions; } +interface GoalPersistenceOptions { + replacementGoalId?: string | null; + replacementCreatedAtMs?: number | null; + /** + * When provided, persistence re-checks the user-stop generation after every + * await preceding a durable write and discards the mutation if a stop landed + * (Codex P1 PRRT_kwDOPxxmWM6cClKV). Only the direct setter path passes this; + * the stream-end drain relies on `recordUserStoppedStream` deleting pending + * mutations before the drain claims them. + */ + userStopGate?: { generationAtEntry: number }; +} + /** * Defensive validation for persisted timestamps: chat.jsonl rows are unchecked * JSON, so metadata numbers can arrive malformed (negative, NaN, or a string @@ -428,6 +441,16 @@ export class WorkspaceGoalService { private pendingContinuationCandidates = new Map(); private continuationReRequestTimers = new Map>(); private lastUserStopAtMsByWorkspace = new Map(); + /** + * Monotonic per-workspace user-stop counter, bumped synchronously by + * `recordUserStoppedStream`. Setters capture it at entry and treat any + * change as "a stop landed while I was in flight" (Codex P1 + * PRRT_kwDOPxxmWM6cClKV). The timestamp map above cannot serve this role: + * two stops in the same millisecond compare equal, and user-resume / + * promotion paths DELETE the timestamp, which a `!==` comparison would + * misread as a fresh stop and falsely discard an unrelated in-flight setter. + */ + private readonly userStopGenerationsByWorkspace = new Map(); private recordedStreamStartedAtMsByWorkspace = new Map(); private lastGoalStreamStamps = new Map(); /** @@ -1099,6 +1122,58 @@ export class WorkspaceGoalService { this.pendingContinuationCandidates.delete(workspaceId); } + /** + * Synchronously remove and return the pending continuation candidate so a + * manual user message can be classified without leaving the candidate + * consumable. + * + * Codex P1 (PRRT_kwDOPxxmWM6cClKd): a direct send on an idle workspace does + * not mark the session busy until after its durable row is appended, so an + * eligibility check running while `acknowledgeUser` is awaited would see an + * idle session, skip chat-tail sync for a kickoff candidate, and dispatch a + * continuation despite the user's intervention. Taking the candidate before + * that await closes the window; the pre-goal queue-race branch restores it + * via `restorePendingContinuationCandidate`. + */ + takePendingContinuationCandidateForManualUserMessage( + workspaceId: string + ): PendingGoalContinuationCandidate | null { + assert( + workspaceId.trim().length > 0, + "takePendingContinuationCandidateForManualUserMessage requires workspaceId" + ); + const candidate = this.pendingContinuationCandidates.get(workspaceId) ?? null; + this.pendingContinuationCandidates.delete(workspaceId); + return candidate; + } + + /** + * Restore a candidate suspended by + * `takePendingContinuationCandidateForManualUserMessage` after the manual + * message proved to be a pre-goal queue-race send (authored before the goal + * existed — not an intervention). No-op when something newer armed during + * the suspension. Re-requests dispatch because a dispatch consumed during + * the suspension found no candidate and nothing else would retry. + */ + restorePendingContinuationCandidate( + workspaceId: string, + candidate: PendingGoalContinuationCandidate + ): void { + assert( + workspaceId.trim().length > 0, + "restorePendingContinuationCandidate requires workspaceId" + ); + if (this.pendingContinuationCandidates.has(workspaceId)) { + return; + } + this.pendingContinuationCandidates.set(workspaceId, candidate); + this.goalContinuationDispatcher + ?.requestDispatch(workspaceId, GOAL_CONTINUATION_IDLE_CONSUMER_NAME) + .catch((error: unknown) => { + log.warn("Failed to re-request dispatch after candidate restore", { workspaceId, error }); + }); + } + /** * Treat an agent's text-only `goal_continuation` turn as implicit * completion. The continuation prompt asks the agent to call @@ -1174,10 +1249,38 @@ export class WorkspaceGoalService { return result.data; } + /** + * Notify the goal service that a new stream actually started. Synchronous on + * purpose: it must land before any setter admitted during the new stream + * checks the settled fast-path. + * + * Codex P1 (PRRT_kwDOPxxmWM6cClKS): `applyPendingAfterStreamEnd` marks the + * workspace settled after every drain (and `recordUserStoppedStream` after + * every abort), but production only cleared the marker on terminal-error + * restoration. Without this hook, every subsequent successful stream saw the + * stale settled marker and a model `set_goal` persisted goal.json mid-stream + * instead of deferring to the stream-end drain — archiving the outgoing goal + * before its stream accounting, skipping the new goal's accounting + * (createdAtMs postdates stream start), and leaving nothing for a user stop + * to discard. + */ + recordStreamStarted(workspaceId: string): void { + assert(workspaceId.trim().length > 0, "recordStreamStarted requires workspaceId"); + // Only the settled marker: recording the new stream's start time in + // `recordedStreamStartedAtMsByWorkspace` would suppress the stream's own + // accounting previews (a match there means "deltas from this stream are + // stale", set by terminal-error restoration). + this.drainSettledWorkspaces.delete(workspaceId); + } + async recordUserStoppedStream(workspaceId: string, stoppedAtMs = Date.now()): Promise { assert(workspaceId.trim().length > 0, "recordUserStoppedStream requires workspaceId"); assert(Number.isFinite(stoppedAtMs) && stoppedAtMs >= 0, "user stop timestamp must be valid"); this.lastUserStopAtMsByWorkspace.set(workspaceId, stoppedAtMs); + this.userStopGenerationsByWorkspace.set( + workspaceId, + (this.userStopGenerationsByWorkspace.get(workspaceId) ?? 0) + 1 + ); // A user stop ends the stream WITHOUT a stream-end drain (AgentSession // deliberately skips it), so treat the workspace as settled: setters // admitted after this stop must persist directly instead of queueing a @@ -2079,7 +2182,8 @@ export class WorkspaceGoalService { // only already-installed mutations, so a setter still in its pre-install // awaits would otherwise install (or directly persist) a goal the abort // meant to discard. - const userStopAtMsAtEntry = this.lastUserStopAtMsByWorkspace.get(input.workspaceId) ?? null; + const userStopGenerationAtEntry = + this.userStopGenerationsByWorkspace.get(input.workspaceId) ?? 0; if (!objective && this.pendingGoalSnapshots.has(input.workspaceId)) { // Until stream-end persists the queued objective, status/budget-only edits @@ -2105,7 +2209,7 @@ export class WorkspaceGoalService { (await this.isWorkspaceStreaming(input.workspaceId)) ) { const deferredResult = await this.fileLocks.withLock(input.workspaceId, async () => { - if (this.userStopLandedSince(input.workspaceId, userStopAtMsAtEntry)) { + if (this.userStopLandedSince(input.workspaceId, userStopGenerationAtEntry)) { return Err({ type: "invalid_transition" as const, message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, @@ -2171,7 +2275,7 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } - if (this.userStopLandedSince(input.workspaceId, userStopAtMsAtEntry)) { + if (this.userStopLandedSince(input.workspaceId, userStopGenerationAtEntry)) { return Err({ type: "invalid_transition" as const, message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, @@ -2266,7 +2370,7 @@ export class WorkspaceGoalService { } } - if (this.userStopLandedSince(input.workspaceId, userStopAtMsAtEntry)) { + if (this.userStopLandedSince(input.workspaceId, userStopGenerationAtEntry)) { // Codex P1 (PRRT_kwDOPxxmWM6cCH_H): the abort can also land after the // pre-lock admission read `streaming=false` — do not fall through to // immediate persistence for a setter the stop meant to discard. @@ -2275,12 +2379,21 @@ export class WorkspaceGoalService { message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, }); } - return this.setGoalImmediately({ ...input, objective }); + // Codex P1 (PRRT_kwDOPxxmWM6cClKV): the check above is not the last word — + // setGoalImmediately still awaits the file lock, kickoff-model validation, + // history archival, and writes. Carry the stop generation through the + // locked persistence so an abort landing during any of those awaits + // discards the change instead of durably creating a goal from the aborted + // turn. + return this.setGoalImmediately( + { ...input, objective }, + { userStopGate: { generationAtEntry: userStopGenerationAtEntry } } + ); } - /** Whether a user stop was recorded after the caller captured `stopAtMsAtEntry`. */ - private userStopLandedSince(workspaceId: string, stopAtMsAtEntry: number | null): boolean { - return (this.lastUserStopAtMsByWorkspace.get(workspaceId) ?? null) !== stopAtMsAtEntry; + /** Whether a user stop was recorded after the caller captured `generationAtEntry`. */ + private userStopLandedSince(workspaceId: string, generationAtEntry: number): boolean { + return (this.userStopGenerationsByWorkspace.get(workspaceId) ?? 0) !== generationAtEntry; } private async canRunBudgetedGoalOnKickoffModel( @@ -2299,12 +2412,12 @@ export class WorkspaceGoalService { private async setGoalImmediately( input: SetGoalInput & { objective?: string }, - options?: { replacementGoalId?: string | null; replacementCreatedAtMs?: number | null } + options?: GoalPersistenceOptions ): Promise> { const result = await this.fileLocks.withLock(input.workspaceId, () => this.persistGoalMutationLocked(input, options) ); - return this.finalizeGoalPersistence(input, result); + return this.finalizeGoalPersistence(input, result, options); } /** @@ -2317,9 +2430,26 @@ export class WorkspaceGoalService { */ private async persistGoalMutationLocked( input: SetGoalInput & { objective?: string }, - options?: { replacementGoalId?: string | null; replacementCreatedAtMs?: number | null } + options?: GoalPersistenceOptions ): Promise> { + // Codex P1 (PRRT_kwDOPxxmWM6cClKV): recordUserStoppedStream bumps the stop + // generation synchronously (it does NOT wait for this lock), so a stop can + // land during any await inside this tenure. Re-check after every await + // that precedes a durable write so the abort discards the change instead + // of acknowledging an already-written goal. + const discardIfUserStopLanded = (): Result | null => + options?.userStopGate != null && + this.userStopLandedSince(input.workspaceId, options.userStopGate.generationAtEntry) + ? Err({ + type: "invalid_transition" as const, + message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, + }) + : null; { + const stoppedBeforeRead = discardIfUserStopLanded(); + if (stoppedBeforeRead) { + return stoppedBeforeRead; + } const current = await this.readGoalFile(input.workspaceId); const conflict = this.conflictForExpectedGoalId(current, input.expectedGoalId) ?? @@ -2384,6 +2514,10 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } + const stoppedBeforeEditWrite = discardIfUserStopLanded(); + if (stoppedBeforeEditWrite) { + return stoppedBeforeEditWrite; + } await this.writeGoal(input.workspaceId, withEdits); await this.pushSnapshot(input.workspaceId, withEdits); await this.pushLiveGoalPreviewOverlay(input.workspaceId, withEdits); @@ -2425,6 +2559,10 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } + const stoppedBeforeMutableWrite = discardIfUserStopLanded(); + if (stoppedBeforeMutableWrite) { + return stoppedBeforeMutableWrite; + } // User resume is an explicit opt-in after a stop/crash gate; clear // both the in-memory stop marker and persisted acknowledgment gate. @@ -2477,6 +2615,10 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } + const stoppedBeforeArchive = discardIfUserStopLanded(); + if (stoppedBeforeArchive) { + return stoppedBeforeArchive; + } this.liveGoalPreviewSnapshots.delete(input.workspaceId); // Archive the outgoing goal to history before we overwrite goal.json. // The new goal gets a fresh `goalId` so the right-sidebar GoalTab needs @@ -2489,6 +2631,13 @@ export class WorkspaceGoalService { current, current.status === "complete" ? "completed" : "replaced" ); + // A stop landing during the archive append leaves a cosmetic history + // entry, but the durable goal.json write below must still be + // discarded. + const stoppedAfterArchive = discardIfUserStopLanded(); + if (stoppedAfterArchive) { + return stoppedAfterArchive; + } } await this.writeGoal(input.workspaceId, next); await this.pushSnapshot(input.workspaceId, next); @@ -2529,11 +2678,22 @@ export class WorkspaceGoalService { */ private async finalizeGoalPersistence( input: SetGoalInput & { objective?: string }, - result: Result + result: Result, + options?: GoalPersistenceOptions ): Promise> { if (!result.success) { return result; } + // Codex P1 (PRRT_kwDOPxxmWM6cClKV): a stop landing after the durable write + // but before finalization already cleared continuation candidates — arming + // here would resurrect the autonomous loop the abort meant to halt. Only + // the arming side effects are stop-opposed: pause boundaries and chat-tail + // syncs make the written record stick and must still run. Evaluated lazily + // at each arm site so stops landing during earlier finalization awaits are + // seen too. + const stopVetoesArming = (): boolean => + options?.userStopGate != null && + this.userStopLandedSince(input.workspaceId, options.userStopGate.generationAtEntry); if (input.objective != null) { this.recordGoalSet(input.workspaceId, result.data); @@ -2561,7 +2721,9 @@ export class WorkspaceGoalService { } if (result.data.status === "active") { - await this.armKickoffContinuationIfIdle(input.workspaceId, result.data); + if (!stopVetoesArming()) { + await this.armKickoffContinuationIfIdle(input.workspaceId, result.data); + } if (input.initiator === "model") { // A model-created set_goal starts from an ordinary user turn, not a // goal-continuation row. Do not reconcile it against chat tail here or @@ -2571,7 +2733,7 @@ export class WorkspaceGoalService { const synced = await this.syncGoalStatusToChatTail(input.workspaceId); return Ok(synced ?? result.data); } - if (result.data.status === "budget_limited") { + if (result.data.status === "budget_limited" && !stopVetoesArming()) { await this.armBudgetWrapupForBudgetLimitedGoal(input.workspaceId, result.data); } return result; @@ -2637,6 +2799,23 @@ export class WorkspaceGoalService { if (sendOptions.agentId === "plan" || sendOptions.agentId === "compact") { return; } + // Codex P2 (PRRT_kwDOPxxmWM6cClKY): the kickoff-options await above runs + // outside the goal file lock, so a newer setter can persist a replacement + // goal AND arm its own candidate while this stale finalizer is suspended. + // Arming now would overwrite the newer goal's candidate with one that + // eligibility drops for goal-ID mismatch, leaving the durable goal with no + // kickoff. Re-verify this goal is still the durable active record, and do + // not overwrite a candidate that already belongs to it (no await sits + // between these checks and the arm below). A candidate for a DIFFERENT + // goal is stale by definition here — we just proved ours is durable — and + // must be replaced, or the durable goal is the one left kickoff-less. + const durable = await this.readGoalFile(workspaceId); + if (durable?.goalId !== goal.goalId || durable.status !== "active") { + return; + } + if (this.pendingContinuationCandidates.get(workspaceId)?.goalId === goal.goalId) { + return; + } this.armImmediateContinuationCandidate(workspaceId, goal, "kickoff", sendOptions); try { @@ -2756,6 +2935,20 @@ export class WorkspaceGoalService { if (!sendOptions || sendOptions.agentId === "plan" || sendOptions.agentId === "compact") { return; } + // Codex P2 (PRRT_kwDOPxxmWM6cClKY): mirror the kickoff arming re-check — + // the options await runs unlocked, so a candidate armed (or a replacement + // goal persisted) during it must win over this stale wrap-up finalizer. + if (this.pendingContinuationCandidates.has(workspaceId)) { + return; + } + const durable = await this.readGoalFile(workspaceId); + if ( + durable?.goalId !== goal.goalId || + durable.status !== "budget_limited" || + this.pendingContinuationCandidates.has(workspaceId) + ) { + return; + } this.recordLastGoalStream(workspaceId, GOAL_CONTINUATION_KIND, goal.goalId); this.armImmediateContinuationCandidate(workspaceId, goal, "budget_wrapup", sendOptions); From 40c503bf784bd08789dda76d48f34627aebc643d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 11:56:36 +0000 Subject: [PATCH 16/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2014=20?= =?UTF-8?q?=E2=80=94=20atomic=20publication=20stamp,=20durable=20user-orig?= =?UTF-8?q?in=20wrap-up=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2 PRRT_kwDOPxxmWM6cDhNO: direct idle creations now take the publication stamp immediately before the single durable write instead of re-stamping in a second write after the snapshot push. A crash can no longer strand the provisional construction stamp on disk, which restart reconciliation misread by pausing a never-driven goal over a manual row authored during validation. The residual visibility gap is only the write+push awaits (milliseconds) and fails toward a user-resumable pause. - P2 PRRT_kwDOPxxmWM6cDhNX: the maintenance-stamp fallback on budget_limited goals now consults the durable budgetLimitOriginKind before recording. A post-restart wake re-records the suppressing 'user' stamp instead of a wrap-up-eligible 'other' stamp, so persisted user-origin suppression survives background activity. --- .../services/workspaceGoalService.test.ts | 107 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 41 +++++-- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 5a12ffe9e7b..279f45d4e64 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2477,6 +2477,113 @@ describe("WorkspaceGoalService", () => { expect(await service.getGoal(workspaceId)).toMatchObject({ createdAtMs: created.createdAtMs }); }); + test("direct idle creation persists the publication stamp in a single durable write", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cDhNO): the previous re-stamp scheme wrote the + // construction stamp first and re-wrote the publication stamp after the + // snapshot push — a crash between the two writes durably stranded the + // earlier stamp, so restart reconciliation misread a manual row authored + // during validation as a post-goal intervention and paused the + // never-driven goal. The record and its visibility stamp must commit in + // one atomic write. + const dispatcher = new IdleDispatcher(); + service.registerGoalContinuationConsumer(dispatcher, { + ...continuationBridge(), + getKickoffSendOptions: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { model: "openai:gpt-4o", agentId: "exec" }; + }, + }); + const serviceAccess = service as unknown as { + writeGoal: (workspaceId: string, goal: GoalRecordV1) => Promise; + }; + const originalWriteGoal = serviceAccess.writeGoal.bind(service); + const writtenStamps: number[] = []; + spyOn(serviceAccess, "writeGoal").mockImplementation(async (id: string, goal: GoalRecordV1) => { + writtenStamps.push(goal.createdAtMs); + return originalWriteGoal(id, goal); + }); + + const created = await setGoalOk(service, { + workspaceId, + objective: "Atomic publication stamp", + budgetCents: 500, + }); + + // Every durable write of this goal already carries the final publication + // stamp — no window exists where a crash leaves an earlier construction + // stamp on disk. + expect(writtenStamps.length).toBeGreaterThan(0); + expect(writtenStamps).toEqual(writtenStamps.map(() => created.createdAtMs)); + }); + + test("maintenance streams after a restart preserve durable user-origin wrap-up suppression", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cDhNX): a user-origin budget_limited goal + // recovers from restart without a wrap-up candidate, but the suppressing + // in-memory stamp is gone too. The first background wake's stream-end + // accounting must not record a wrap-up-eligible maintenance stamp — that + // would let the next stream-end request dispatch the autonomous wrap-up + // the persisted budgetLimitOriginKind: "user" was meant to suppress. + const created = await setGoalOk(service, { + workspaceId, + objective: "User exhausts budget then restarts", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "user", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitOriginKind: "user", + }); + + // Simulate restart: fresh service, empty in-memory stream stamps. + const restartedService = new WorkspaceGoalService( + config, + historyService, + extensionMetadata, + analytics + ); + const dispatcher = new IdleDispatcher(); + const execute = mock(() => Promise.resolve(true)); + restartedService.registerGoalContinuationConsumer(dispatcher, { + ...continuationBridge(execute), + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + + // A background wake turn ends on the restarted service. + await restartedService.recordStreamAccounting({ + workspaceId, + costUsd: 0.01, + streamStartedAtMs: Date.now(), + streamOriginKind: "other", + }); + + const stamps = ( + restartedService as unknown as { + lastGoalStreamStamps: Map; + } + ).lastGoalStreamStamps; + expect(stamps.get(workspaceId)?.originKind).toBe("user"); + + // Behavioral proof: a stream-end continuation request must not dispatch + // the wrap-up that user-origin suppression blocked. + await restartedService.requestContinuationAfterStreamEnd({ + workspaceId, + sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + streamEndedAtMs: Date.now(), + }); + await drainPendingDispatches(); + + expect(execute).not.toHaveBeenCalled(); + expect(await restartedService.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitInjectedForGoalId: null, + }); + }); + test("setters that span a stream-end drain persist directly instead of queueing", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cBr9Q): a setGoal admitted while the (stale) // streaming flag still reads live can reach its in-lock recheck after the diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 1cfa647f59a..be6f9349f0a 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2639,18 +2639,22 @@ export class WorkspaceGoalService { return stoppedAfterArchive; } } - await this.writeGoal(input.workspaceId, next); - await this.pushSnapshot(input.workspaceId, next); if (options?.replacementCreatedAtMs == null) { // Codex P2 (PRRT_kwDOPxxmWM6cBr9B): direct creations must also carry a - // publication-time createdAtMs. The construction stamp above predates - // the kickoff-model validation, history-archive, and write/push awaits - // — a message the user authored during those awaits would postdate it - // and be misread as an intervention against a goal not yet visible. - // No await sits between the snapshot push resolving and this re-stamp, - // so nothing can be admitted in between; the durable record carries - // the publication stamp (crash between the writes leaves the - // provisional stamp — today's behavior). Drained queued mutations pass + // publication-time createdAtMs. The construction stamp predates the + // kickoff-model validation and history-archive awaits — a message the + // user authored during those long awaits would postdate it and be + // misread as an intervention against a goal not yet visible. + // + // Codex P2 (PRRT_kwDOPxxmWM6cDhNO): the stamp is taken BEFORE the + // single durable write below (not re-stamped after the snapshot push) + // so the record and its visibility stamp commit atomically — a crash + // can never leave the provisional construction stamp on disk for + // restart reconciliation to misread. The residual gap is only the + // write+push awaits themselves (local file I/O, milliseconds): a + // message authored inside that gap fails toward a pause the user can + // Resume, whereas a crash-stranded stale stamp silently paused a + // never-driven goal with no signal. Drained queued mutations pass // replacementCreatedAtMs and already carry their publication stamp. const publishedAtMs = Date.now(); next = GoalRecordV1Schema.parse({ @@ -2658,8 +2662,9 @@ export class WorkspaceGoalService { createdAtMs: publishedAtMs, updatedAtMs: publishedAtMs, }); - await this.writeGoal(input.workspaceId, next); } + await this.writeGoal(input.workspaceId, next); + await this.pushSnapshot(input.workspaceId, next); this.emitBudgetChanged(current, next, input); this.emitLifecycle(current ? "goal_replaced" : "goal_created", { sameObjective: current?.objective === objective, @@ -3175,7 +3180,19 @@ export class WorkspaceGoalService { const preserveExistingStamp = current.status === "budget_limited" && existingStamp?.goalId === current.goalId; if (!preserveExistingStamp) { - this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); + // Codex P2 (PRRT_kwDOPxxmWM6cDhNX): with no in-memory stamp (e.g. + // after a restart), consult the durable origin before stamping. A + // user-origin budget hit was deliberately suppressed pre-restart + // (`recoverPendingDispatchAfterRestart` honors it) — recording the + // maintenance stream's wrap-up-eligible origin here would let the + // next stream-end request dispatch the autonomous wrap-up that + // suppression blocked. Re-record "user" instead so the durable + // suppression survives maintenance activity. + const stampOriginKind = + current.status === "budget_limited" && current.budgetLimitOriginKind === "user" + ? "user" + : originKind; + this.recordLastGoalStream(input.workspaceId, stampOriginKind, current.goalId); } await this.pushSnapshot(input.workspaceId, current); return current; From 3aaeccc368338095506c6aa8a0392a3f4d58131e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 12:25:10 +0000 Subject: [PATCH 17/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2015=20?= =?UTF-8?q?=E2=80=94=20preflight=20busy=20predicate,=20stale-pause=20guard?= =?UTF-8?q?,=20gate-ordered=20acknowledgment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 PRRT_kwDOPxxmWM6cECpR: goal-continuation eligibility now treats in-preflight direct sends as busy (WorkspaceService.preflightSendCounts, already incremented synchronously at sendMessage entry, wired into getGoalContinuationRuntimeState.isBusy). A kickoff candidate restored while a pre-goal manual send is mid-flight can no longer be consumed and dispatched ahead of the user's turn; queue dispatches were already covered by their synchronous PREPARING transition. - P2 PRRT_kwDOPxxmWM6cECpZ: pause finalization re-verifies the paused goal is still the durable record before applying side effects, so a stale pause resuming after a replacement persisted cannot delete the newer goal's candidate or append a pause boundary that chat-tail sync applies to it. - P1 PRRT_kwDOPxxmWM6cECpj: acknowledgeUser only clears the acknowledgment gate for messages authored at/after the gate was set. A pre-stop send delayed in preflight can no longer acknowledge a newer Stop, so the durable gate survives restarts and recovery stays blocked until a real user acknowledgment. --- .../agentSession.goalAutoPause.test.ts | 26 +++++++ src/node/services/agentSession.ts | 7 +- .../services/workspaceGoalService.test.ts | 69 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 32 ++++++++- src/node/services/workspaceService.test.ts | 18 +++++ src/node/services/workspaceService.ts | 12 +++- 6 files changed, 161 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 33980fd6297..25447d1ad39 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -322,6 +322,32 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("delayed pre-stop sends do not clear a newer stop's acknowledgment gate", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cECpj): a pre-goal send stuck in preflight + // until after a user stop must not acknowledge that stop. Clearing the + // durable requireUserAcknowledgmentSinceMs gate here would let restart + // recovery re-arm the active goal despite the newer Stop action (the + // in-memory stop timestamp does not survive restarts). + const workspaceId = "pre-stop-send-keeps-gate"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const enqueuedAtMs = Date.now(); + const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); + await goalService.recordUserStoppedStream(workspaceId, created.createdAtMs + 5_000); + + const result = await session.sendMessage("Queued before the goal existed", SEND_OPTIONS, { + enqueuedAtMs, + }); + + expect(result.success).toBe(true); + // Pre-goal classification keeps the goal active, but the stop's gate must + // survive so continuations stay blocked until the user acknowledges. + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" }); + const goal = await goalService.getGoal(workspaceId); + expect(goal?.requireUserAcknowledgmentSinceMs).not.toBeNull(); + session.dispose(); + }); + test("pre-goal queued sends restore the suspended kickoff candidate", async () => { // Complement to the suspension test above: a queued send authored before // the goal existed is not an intervention, so the taken candidate must be diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 25542d1c65a..b61814d0764 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1645,7 +1645,12 @@ export class AgentSession { // candidate cleared conservatively (it was taken above): once the failed // send returns the workspace to idle, a stale candidate could otherwise // dispatch a continuation despite the user's persisted intervention. - const goal = await goalService.acknowledgeUser(this.workspaceId); + // + // The authoring time keeps a delayed pre-stop send from clearing a NEWER + // stop's acknowledgment gate (Codex P1 PRRT_kwDOPxxmWM6cECpj). + const goal = await goalService.acknowledgeUser(this.workspaceId, { + authoredAtMs: input.enqueuedAtMs, + }); // Queue race: a message the user typed while the goal-creating turn was // still streaming predates the goal itself — the model's queued set_goal diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 279f45d4e64..c186f4bab23 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2708,6 +2708,75 @@ describe("WorkspaceGoalService", () => { expect(await goalFileExists(config, workspaceId)).toBe(false); }); + test("stale pause finalization does not pause a newer replacement goal", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cECpZ): pause finalization runs outside the + // goal file lock — a replacement queued behind the pause's persist can + // land before the pause's finalization resumes. The stale pause must not + // delete the newer goal's kickoff candidate or append a pause boundary + // that chat-tail sync applies to the newer goal. + const dispatcher = new IdleDispatcher(); + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + // Busy runtime keeps armed candidates inspectable (not consumed). + getRuntimeState: () => ({ isRuntimeCompatible: true, isBusy: true }), + executeGoalContinuation: () => Promise.resolve(true), + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + const goalA = await setGoalOk(service, { workspaceId, objective: "Goal A" }); + const goalB = await setGoalOk(service, { workspaceId, objective: "Goal B" }); + + // Replay goal A's pause finalization as if its setter resumed only after + // B replaced A (MutexMap admitted B between A's persist and finalize). + const internals = service as unknown as { + finalizeGoalPersistence: ( + input: { workspaceId: string; status: GoalStatus }, + result: { success: true; data: GoalRecordV1 } + ) => Promise; + pendingContinuationCandidates: Map; + }; + await internals.finalizeGoalPersistence( + { workspaceId, status: "paused" }, + { success: true, data: { ...goalA, status: "paused" } } + ); + + expect(internals.pendingContinuationCandidates.get(workspaceId)?.goalId).toBe(goalB.goalId); + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "active", + }); + const history = await historyService.getLastMessages(workspaceId, 20); + expect(history.success).toBe(true); + if (history.success) { + const boundaryRows = history.data.filter( + (message) => message.metadata?.muxMetadata?.type === "goal-pause-boundary" + ); + expect(boundaryRows).toHaveLength(0); + } + }); + + test("acknowledgeUser ignores messages authored before the acknowledgment gate", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cECpj): a send authored before a user stop + // set the acknowledgment gate cannot acknowledge that stop — clearing the + // durable gate would let restart recovery re-arm the goal despite the + // newer Stop action. + const created = await setGoalOk(service, { workspaceId, objective: "Gated goal" }); + await service.recordUserStoppedStream(workspaceId, created.createdAtMs + 5_000); + const gated = await service.getGoal(workspaceId); + expect(gated?.requireUserAcknowledgmentSinceMs).not.toBeNull(); + + // Authored before the stop: the gate must survive. + const afterStale = await service.acknowledgeUser(workspaceId, { + authoredAtMs: created.createdAtMs + 1_000, + }); + expect(afterStale?.requireUserAcknowledgmentSinceMs).not.toBeNull(); + + // Authored after the stop: an informed user action clears the gate. + const afterFresh = await service.acknowledgeUser(workspaceId, { + authoredAtMs: created.createdAtMs + 6_000, + }); + expect(afterFresh?.requireUserAcknowledgmentSinceMs).toBeNull(); + }); + test("a stale kickoff finalizer does not overwrite a newer goal's candidate", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cClKY): kickoff finalization runs outside the // goal file lock. While finalizer A awaits kickoff options, a newer setter diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index be6f9349f0a..4b63becf460 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2716,6 +2716,19 @@ export class WorkspaceGoalService { } if (input.status === "paused" && result.data.status === "paused") { + // Codex P2 (PRRT_kwDOPxxmWM6cECpZ): finalization runs outside the goal + // file lock, so a replacement or clear-and-promote queued behind our + // pause can persist before this resumes. Applying stale pause side + // effects would delete the NEWER goal's continuation candidate and + // append a pause boundary that chat-tail sync applies to the newer goal + // — silently pausing a replacement the user just created. Re-verify the + // paused goal is still the durable record (mirrors the arming identity + // check below); no await sits between this read and the candidate + // delete. + const durableAtPause = await this.readGoalFile(input.workspaceId); + if (durableAtPause?.goalId !== result.data.goalId || durableAtPause.status !== "paused") { + return result; + } this.pendingContinuationCandidates.delete(input.workspaceId); const pauseBoundaryReady = await this.appendGoalPauseBoundaryIfNeeded(input.workspaceId); if (!pauseBoundaryReady) { @@ -2991,7 +3004,10 @@ export class WorkspaceGoalService { }); } - async acknowledgeUser(workspaceId: string): Promise { + async acknowledgeUser( + workspaceId: string, + options?: { authoredAtMs?: number | null } + ): Promise { assert(workspaceId.trim().length > 0, "acknowledgeUser requires workspaceId"); return this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); @@ -3003,6 +3019,20 @@ export class WorkspaceGoalService { await this.pushSnapshot(workspaceId, current); return current; } + // Codex P1 (PRRT_kwDOPxxmWM6cECpj): a message authored BEFORE the + // acknowledgment gate was set cannot acknowledge it. A pre-goal send + // stuck in preflight across a user stop would otherwise clear the + // stop's durable gate; its pre-goal classification then skips the + // auto-pause, so after a restart (which loses the in-memory stop + // timestamp) recovery re-arms the goal despite the newer Stop. Callers + // acting for an explicit fresh user action (slash workflows, direct + // sends carrying their request-entry authoring time) postdate the gate + // and clear it as before. + const authoredAtMs = toValidEpochMs(options?.authoredAtMs); + if (authoredAtMs != null && authoredAtMs < current.requireUserAcknowledgmentSinceMs) { + await this.pushSnapshot(workspaceId, current); + return current; + } const next = this.applyMutableFields(current, { workspaceId, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index daa1e1d2ca5..df106ff564a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -14525,6 +14525,24 @@ describe("WorkspaceService.getGoalContinuationRuntimeState", () => { expect(service.getGoalContinuationRuntimeState("ws-1").isInitializing).toBe(true); }); + test("in-preflight direct sends report the workspace busy for goal continuations", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cECpR): a direct send does not set PREPARING + // until late in AgentSession.sendMessage, so a kickoff candidate restored + // while the send is mid-preflight (manual row already durable, session + // still phase-idle) could otherwise be consumed by goal-continuation + // eligibility and dispatched ahead of the user's turn. The runtime busy + // predicate must include sendMessage's preflight counter. + const service = await makeService(undefined); + expect(service.getGoalContinuationRuntimeState("ws-1").isBusy).toBe(false); + + const counts = (service as unknown as { preflightSendCounts: Map }) + .preflightSendCounts; + counts.set("ws-1", 1); + expect(service.getGoalContinuationRuntimeState("ws-1").isBusy).toBe(true); + counts.delete("ws-1"); + expect(service.getGoalContinuationRuntimeState("ws-1").isBusy).toBe(false); + }); + test("kickoff continuation fires on a freshly-init'd workspace", async () => { const workspaceId = "kickoff-after-init"; const service = await makeService({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5afe08df4c5..062c87908f0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -12081,7 +12081,17 @@ export class WorkspaceService extends EventEmitter { // Finished init states remain cached; only "running" should block continuations. isInitializing: initState?.status === "running", isRuntimeCompatible: true, - isBusy: session?.isBusy() === true, + // Codex P1 (PRRT_kwDOPxxmWM6cECpR): a direct send does not set PREPARING + // until late in AgentSession.sendMessage, so goal-continuation + // eligibility must also treat in-preflight sends as busy. Otherwise a + // kickoff candidate restored while a pre-goal manual send is mid-flight + // (row already durable, session still phase-idle) can be consumed and + // dispatched concurrently with — or ahead of — the user's turn. + // `preflightSendCounts` is incremented synchronously at sendMessage + // entry and held until the send settles; admitted sends have set + // PREPARING (busy) by the time it releases. Queue-dispatched sends set + // PREPARING synchronously before dispatch and are covered by isBusy(). + isBusy: session?.isBusy() === true || (this.preflightSendCounts.get(workspaceId) ?? 0) > 0, hasQueuedMessages: session?.hasPendingManualFollowUp() === true, hasPendingFollowUp: false, }; From 795fe8760aafef14b2a9e999e169caa80c666909 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 12:53:29 +0000 Subject: [PATCH 18/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2016=20?= =?UTF-8?q?=E2=80=94=20drain=20stream-start=20generation,=20goal-scoped=20?= =?UTF-8?q?pause=20boundaries,=20ineligible=20preview=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 PRRT_kwDOPxxmWM6cEl37: the stream-end drain captures a per-workspace stream-start generation at entry and only marks the workspace settled at exit when no newer stream started while it ran. A provider-error drain (launched un-awaited) racing an auto-retry's stream-start can no longer re-add the settled marker the retry just cleared, so set_goal in the retry stream still defers to its own stream-end drain. - P2 PRRT_kwDOPxxmWM6cEl4F: pause boundary rows are goal-scoped (muxMetadata.goalId) and chat-tail reconciliation ignores boundaries stamped for a different goal, so a stale pause finalizer whose boundary append lands after a replacement persisted can never silently pause the newer goal. Legacy rows without a goalId keep the old any-goal semantics. - P2 PRRT_kwDOPxxmWM6cEl4P: when a mid-stream status transition makes cost previews ineligible (paused/complete, budget edit to budget_limited), the cached live preview is cleared and the durable snapshot published once, so pushLiveGoalPreviewOverlay stops re-emitting cost that final accounting discards. --- src/common/types/message.ts | 8 ++ .../services/workspaceGoalService.test.ts | 124 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 97 +++++++++++++- 3 files changed, 222 insertions(+), 7 deletions(-) diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 30d249e2454..1a9119eadd6 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -594,6 +594,14 @@ export type MuxMessageMetadata = MuxMessageMetadataBase & } | { type: "goal-pause-boundary"; + /** + * Goal this boundary pauses. Chat-tail reconciliation ignores + * boundaries stamped for a different goal so a stale pause finalizer + * racing a replacement cannot silently pause the newer goal (Codex P2 + * PRRT_kwDOPxxmWM6cEl4F). Optional for legacy rows, which keep the + * old any-goal semantics. + */ + goalId?: string; } | { // Durable, provider-visible summary of an abandoned history branch diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index c186f4bab23..d5e9b9221d3 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2708,6 +2708,130 @@ describe("WorkspaceGoalService", () => { expect(await goalFileExists(config, workspaceId)).toBe(false); }); + test("a stream starting during the stream-end drain leaves the workspace unsettled", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cEl37): the provider-error path launches the + // drain un-awaited, so an automatic retry's stream-start can land while + // the drain is persisting. The drain's exit must not re-add the settled + // marker the retry's recordStreamStarted just cleared — a set_goal in the + // retry stream would then persist mid-stream, bypassing abort-time + // discard and stream-end accounting. + await extensionMetadata.setStreaming(workspaceId, true); + const queued = await service.setGoal({ workspaceId, objective: "Queued mid-error" }); + expect(queued.success).toBe(true); + + const serviceAccess = service as unknown as { + fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; + pendingGoalMutations: Map; + drainSettledWorkspaces: Set; + }; + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const gateTenure = serviceAccess.fileLocks.withLock(workspaceId, () => gate); + + // The drain's locked claim queues behind the gate; the retry stream + // starts while it waits. + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + service.recordStreamStarted(workspaceId); + releaseGate(); + await gateTenure; + await drainPromise; + + expect(serviceAccess.drainSettledWorkspaces.has(workspaceId)).toBe(false); + // Behavioral proof: a set_goal in the retry stream still defers to that + // stream's own stream-end drain instead of persisting mid-stream. + const setter = await service.setGoal({ workspaceId, objective: "Retry-stream goal" }); + expect(setter.success).toBe(true); + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeDefined(); + }); + + test("pause boundaries for a replaced goal do not reconcile the newer goal to paused", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cEl4F): a stale pause finalizer's boundary + // append awaits history I/O after its identity check, so the row can land + // AFTER a replacement persisted. Boundaries are goal-scoped: chat-tail + // reconciliation must ignore one stamped for a different goal instead of + // silently pausing the replacement. + const goalA = await setGoalOk(service, { workspaceId, objective: "Goal A" }); + const goalB = await setGoalOk(service, { workspaceId, objective: "Goal B" }); + + const staleBoundary = createMuxMessage( + `goal-paused-stale-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary", goalId: goalA.goalId }, + } + ); + const appendResult = await historyService.appendToHistory(workspaceId, staleBoundary); + expect(appendResult.success).toBe(true); + + // Reconciliation ignores the mismatched boundary — B stays active. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "active", + }); + + // Legacy boundaries without a goalId keep the old any-goal semantics. + const legacyBoundary = createMuxMessage( + `goal-paused-legacy-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary" }, + } + ); + const legacyAppend = await historyService.appendToHistory(workspaceId, legacyBoundary); + expect(legacyAppend.success).toBe(true); + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "paused", + }); + }); + + test("cost previews reset when the goal becomes ineligible for accounting mid-stream", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cEl4P): a status transition during the stream + // (pause, complete, or a budget edit flipping the goal budget_limited) + // makes further previews ineligible. The cached live preview must be + // cleared and the durable snapshot published — otherwise + // pushLiveGoalPreviewOverlay keeps re-emitting cost that final accounting + // discards, and the Goal UI snaps backward only at stream end. + const created = await setGoalOk(service, { + workspaceId, + objective: "Preview reset", + budgetCents: 500, + }); + await service.previewStreamAccounting({ + workspaceId, + costUsd: 0.5, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "other", + }); + const previews = (service as unknown as { liveGoalPreviewSnapshots: Map }) + .liveGoalPreviewSnapshots; + expect(previews.has(workspaceId)).toBe(true); + + // Mid-stream pause makes the next delta ineligible. + await setGoalOk(service, { workspaceId, status: "paused" }); + const snapshots = captureGoalActivity(service); + const returned = await service.previewStreamAccounting({ + workspaceId, + costUsd: 0.6, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "other", + }); + + expect(previews.has(workspaceId)).toBe(false); + // The durable record (no accounted cost) is what gets published and + // returned — not the discarded preview cost. + expect(returned?.costCents).toBe(0); + expect(snapshots.at(-1)?.goal?.costCents).toBe(0); + }); + test("stale pause finalization does not pause a newer replacement goal", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cECpZ): pause finalization runs outside the // goal file lock — a replacement queued behind the pause's persist can diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 4b63becf460..3bf1845a08d 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -233,6 +233,13 @@ interface ChatTailGoalModeResult { * freshly armed kickoff (see `applyChatTailGoalMode`). */ pausedBy?: "pause_boundary" | "manual_user"; + /** + * When `pausedBy === "pause_boundary"`: the goal the boundary row was + * stamped for. Reconciliation ignores boundaries belonging to a different + * goal (Codex P2 PRRT_kwDOPxxmWM6cEl4F); absent on legacy rows, which keep + * the old any-goal semantics. + */ + boundaryGoalId?: string; /** * When `pausedBy === "manual_user"`: the moment the user authored the pausing * row — its persisted enqueue time (queued sends) or the row timestamp. @@ -470,6 +477,16 @@ export class WorkspaceGoalService { * PRRT_kwDOPxxmWM6cCH_L). Cleared when the next stream start is recorded. */ private readonly drainSettledWorkspaces = new Set(); + /** + * Monotonic per-workspace stream-start counter, bumped synchronously by + * `recordStreamStarted`. The stream-end drain captures it at entry and only + * marks the workspace settled at exit when no newer stream started while it + * ran (Codex P1 PRRT_kwDOPxxmWM6cEl37): a provider-error drain runs + * un-awaited and can still be persisting when an automatic retry emits + * stream-start — re-adding the settled marker then would let a set_goal in + * the retry bypass mid-stream deferral. + */ + private readonly streamStartGenerations = new Map(); private nextGoalStreamStampSequence = 1; private goalContinuationBridge: GoalContinuationRuntimeBridge | null = null; private goalContinuationDispatcher: IdleDispatcher | null = null; @@ -569,7 +586,12 @@ export class WorkspaceGoalService { return { mode: "active" }; } if (message.metadata?.muxMetadata?.type === "goal-pause-boundary") { - return { mode: "paused", pausedBy: "pause_boundary" }; + const boundaryGoalId = message.metadata.muxMetadata.goalId; + return { + mode: "paused", + pausedBy: "pause_boundary", + ...(boundaryGoalId != null ? { boundaryGoalId } : {}), + }; } if (message.metadata?.synthetic === true) { continue; @@ -604,6 +626,20 @@ export class WorkspaceGoalService { return goal; } + // Codex P2 (PRRT_kwDOPxxmWM6cEl4F): a pause boundary stamped for a + // DIFFERENT goal is a stale pause finalizer's artifact — its append raced + // a replacement's persistence and landed after the newer goal was written. + // Applying it here would silently pause a goal the user never paused. + // Treat it as no signal; legacy boundaries without a goalId keep pausing + // whatever goal is current. + if ( + chatTailMode.pausedBy === "pause_boundary" && + chatTailMode.boundaryGoalId != null && + chatTailMode.boundaryGoalId !== goal.goalId + ) { + return goal; + } + // Kickoff window: a freshly activated goal (model set_goal / user Resume) // arms a kickoff continuation candidate before its first goal_continuation // row is appended, so the chat tail still ends at a pre-goal manual user @@ -687,7 +723,10 @@ export class WorkspaceGoalService { }); } - private async appendGoalPauseBoundaryIfNeeded(workspaceId: string): Promise { + private async appendGoalPauseBoundaryIfNeeded( + workspaceId: string, + goalId: string + ): Promise { const chatTailMode = await this.readChatTailGoalMode(workspaceId); if (chatTailMode.mode !== "active") { return true; @@ -697,6 +736,8 @@ export class WorkspaceGoalService { // declarative state model as Resume without rewriting prior continuation // history. The row is model-visible but not rendered unless synthetic debug // messages are enabled, matching other context-only system breadcrumbs. + // The goalId scope keeps a stale pause's boundary from ever reconciling a + // replacement goal to paused (Codex P2 PRRT_kwDOPxxmWM6cEl4F). const message = createMuxMessage( `goal-paused-${Date.now()}-${crypto.randomUUID()}`, "user", @@ -704,7 +745,7 @@ export class WorkspaceGoalService { { timestamp: Date.now(), synthetic: true, - muxMetadata: { type: "goal-pause-boundary" }, + muxMetadata: { type: "goal-pause-boundary", goalId }, } ); const appendResult = await this.historyService.appendToHistory(workspaceId, message); @@ -1271,6 +1312,10 @@ export class WorkspaceGoalService { // accounting previews (a match there means "deltas from this stream are // stale", set by terminal-error restoration). this.drainSettledWorkspaces.delete(workspaceId); + this.streamStartGenerations.set( + workspaceId, + (this.streamStartGenerations.get(workspaceId) ?? 0) + 1 + ); } async recordUserStoppedStream(workspaceId: string, stoppedAtMs = Date.now()): Promise { @@ -2730,7 +2775,10 @@ export class WorkspaceGoalService { return result; } this.pendingContinuationCandidates.delete(input.workspaceId); - const pauseBoundaryReady = await this.appendGoalPauseBoundaryIfNeeded(input.workspaceId); + const pauseBoundaryReady = await this.appendGoalPauseBoundaryIfNeeded( + input.workspaceId, + result.data.goalId + ); if (!pauseBoundaryReady) { return result; } @@ -3076,6 +3124,29 @@ export class WorkspaceGoalService { return stamp; } + /** + * A stream whose cost previews became ineligible mid-flight (the goal was + * paused/completed, or a budget edit flipped it to budget_limited while a + * maintenance stream ran) must not keep showing its earlier live preview. + * + * Codex P2 (PRRT_kwDOPxxmWM6cEl4P): merely returning the durable snapshot + * left the stale preview cached in `liveGoalPreviewSnapshots`, where + * `pushLiveGoalPreviewOverlay` kept re-emitting cost that final accounting + * discards — the Goal UI then snapped backward only at stream end. Clear the + * cache and publish the durable record once, so later deltas (no cached + * preview) stay cheap no-ops. + */ + private async resetIneligibleCostPreview( + workspaceId: string, + current: GoalRecordV1 + ): Promise { + const hadPreview = this.liveGoalPreviewSnapshots.delete(workspaceId); + if (hadPreview) { + return this.pushSnapshot(workspaceId, current); + } + return toGoalSnapshot(current); + } + /** * Push a live cost preview to the activity snapshot. The cost is the * cumulative current-stream cost on top of the durable base; @@ -3126,7 +3197,7 @@ export class WorkspaceGoalService { } if (current.status === "paused" || current.status === "complete") { - return toGoalSnapshot(current); + return this.resetIneligibleCostPreview(input.workspaceId, current); } // Mirror recordStreamAccounting's maintenance skip: final accounting // discards non-goal-driven cost on a budget_limited goal, so previewing @@ -3137,7 +3208,7 @@ export class WorkspaceGoalService { previewOriginKind !== "goal_continuation" && previewOriginKind !== "goal_budget_limit" ) { - return toGoalSnapshot(current); + return this.resetIneligibleCostPreview(input.workspaceId, current); } const preview = GoalRecordV1Schema.parse({ @@ -3419,6 +3490,11 @@ export class WorkspaceGoalService { async applyPendingAfterStreamEnd(workspaceId: string): Promise { this.liveGoalPreviewSnapshots.delete(workspaceId); + // Codex P1 (PRRT_kwDOPxxmWM6cEl37): captured synchronously at entry. The + // provider-error path launches this drain un-awaited, so an automatic + // retry's stream-start can land while the drain is persisting — the exit + // below must not mark that newer live stream settled. + const streamStartGenerationAtEntry = this.streamStartGenerations.get(workspaceId) ?? 0; // Codex P2 (PRRT_kwDOPxxmWM6cBr9Q): bump the drain generation at entry so // setters admitted BEFORE this drain detect it at their in-lock recheck // and persist directly instead of installing a mutation this drain may @@ -3520,7 +3596,14 @@ export class WorkspaceGoalService { // final empty-map check) so they observe a deterministic "no stream-end // hook is coming" state and persist directly until the next stream start // clears it. - this.drainSettledWorkspaces.add(workspaceId); + // + // Codex P1 (PRRT_kwDOPxxmWM6cEl37): unless a newer stream already started + // while this drain ran — its recordStreamStarted cleared the marker, and + // re-adding it here would let a set_goal in that live stream persist + // mid-stream, bypassing abort-time discard and stream-end accounting. + if ((this.streamStartGenerations.get(workspaceId) ?? 0) === streamStartGenerationAtEntry) { + this.drainSettledWorkspaces.add(workspaceId); + } // Stream-end deferred auto-promotion. // From 05babe146e7cbdd0baf092bba81c6d5be0670649 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 12:59:47 +0000 Subject: [PATCH 19/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2016=20add?= =?UTF-8?q?endum=20=E2=80=94=20verify=20goal=20state=20under=20lock=20befo?= =?UTF-8?q?re=20restoring=20a=20suspended=20kickoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (PRRT_kwDOPxxmWM6cErQ7): kickoff eligibility deliberately accepts paused goals (durable kickoff window), so restoring a suspended candidate after a concurrent Pause persisted would reactivate the autonomous loop despite the pause. restorePendingContinuationCandidate now re-verifies goal identity + active status under the goal file lock; pauses persist under the same lock and delete candidates in finalization afterwards, closing both orderings. --- src/node/services/agentSession.ts | 5 ++- .../services/workspaceGoalService.test.ts | 31 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 27 +++++++++++++--- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b61814d0764..45a9f78f03d 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1662,7 +1662,10 @@ export class AgentSession { // kept the workspace moving while the goal sat paused). if (input.enqueuedAtMs != null && goal != null && goal.createdAtMs >= input.enqueuedAtMs) { if (suspendedCandidate != null) { - goalService.restorePendingContinuationCandidate(this.workspaceId, suspendedCandidate); + // The restore re-verifies goal identity + active status under the + // goal file lock (Codex P2 PRRT_kwDOPxxmWM6cErQ7): a pause landing + // during classification must win over the suspended kickoff. + await goalService.restorePendingContinuationCandidate(this.workspaceId, suspendedCandidate); } return; } diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index d5e9b9221d3..0d849324c64 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2832,6 +2832,37 @@ describe("WorkspaceGoalService", () => { expect(snapshots.at(-1)?.goal?.costCents).toBe(0); }); + test("restoring a suspended kickoff candidate is dropped when the goal was paused meanwhile", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cErQ7): kickoff eligibility deliberately + // accepts paused goals (the durable kickoff window), so restoring a + // suspended candidate after a concurrent Pause persisted would reactivate + // the autonomous loop despite the pause. The restore must verify the goal + // is still active under the goal file lock. + const dispatcher = new IdleDispatcher(); + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true, isBusy: true }), + executeGoalContinuation: () => Promise.resolve(true), + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + await setGoalOk(service, { workspaceId, objective: "Fresh goal" }); + const candidates = ( + service as unknown as { pendingContinuationCandidates: Map } + ).pendingContinuationCandidates; + expect(candidates.has(workspaceId)).toBe(true); + + const suspended = service.takePendingContinuationCandidateForManualUserMessage(workspaceId); + expect(suspended).not.toBeNull(); + if (!suspended) { + throw new Error("expected a suspended candidate"); + } + // The user pauses while the manual send is being classified. + await setGoalOk(service, { workspaceId, status: "paused" }); + + await service.restorePendingContinuationCandidate(workspaceId, suspended); + expect(candidates.has(workspaceId)).toBe(false); + }); + test("stale pause finalization does not pause a newer replacement goal", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cECpZ): pause finalization runs outside the // goal file lock — a replacement queued behind the pause's persist can diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 3bf1845a08d..3bf171e7bdf 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -1195,19 +1195,38 @@ export class WorkspaceGoalService { * existed — not an intervention). No-op when something newer armed during * the suspension. Re-requests dispatch because a dispatch consumed during * the suspension found no candidate and nothing else would retry. + * + * Codex P2 (PRRT_kwDOPxxmWM6cErQ7): verified under the goal file lock — a + * concurrent pause can persist while the manual send was being classified, + * and kickoff eligibility deliberately accepts paused goals (the durable + * kickoff window), so an unverified restore would reactivate the autonomous + * loop despite Pause. Pauses persist under this same lock and delete + * candidates in their finalization afterwards, so either the pause is + * already durable here (we drop the stale candidate) or its finalization + * runs after this restore and deletes it. */ - restorePendingContinuationCandidate( + async restorePendingContinuationCandidate( workspaceId: string, candidate: PendingGoalContinuationCandidate - ): void { + ): Promise { assert( workspaceId.trim().length > 0, "restorePendingContinuationCandidate requires workspaceId" ); - if (this.pendingContinuationCandidates.has(workspaceId)) { + const restored = await this.fileLocks.withLock(workspaceId, async () => { + if (this.pendingContinuationCandidates.has(workspaceId)) { + return false; + } + const current = await this.readGoalFile(workspaceId); + if (current?.goalId !== candidate.goalId || current.status !== "active") { + return false; + } + this.pendingContinuationCandidates.set(workspaceId, candidate); + return true; + }); + if (!restored) { return; } - this.pendingContinuationCandidates.set(workspaceId, candidate); this.goalContinuationDispatcher ?.requestDispatch(workspaceId, GOAL_CONTINUATION_IDLE_CONSUMER_NAME) .catch((error: unknown) => { From 89d250f779261b2134e9c192858860db6014b71b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 13:16:12 +0000 Subject: [PATCH 20/50] ci: re-trigger PR checks (dropped webhook event for 05babe146) From 6a50c17d3c7d92701899a431a642749604a63cb4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 14:17:07 +0000 Subject: [PATCH 21/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2018=20?= =?UTF-8?q?=E2=80=94=20idle=20admission=20atomic=20with=20preflight,=20sca?= =?UTF-8?q?n=20past=20replaced-goal=20boundaries,=20deterministic=20lock-o?= =?UTF-8?q?rder=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1 PRRT_kwDOPxxmWM6cGSPP: requireIdle sends (goal continuations, heartbeats) now refuse when another send is in preflight (preflightSendCounts > 1, self included) — a dispatch closure captured before a manual send entered preflight can no longer win idle admission ahead of the user's turn; idle-only callers treat the refusal as a transient skip and retry. - P2 PRRT_kwDOPxxmWM6cGSPK: readChatTailGoalMode now SKIPS goal-scoped pause boundaries stamped for a different goal while scanning (instead of treating the first one as the tail's final signal), so a genuine post-goal manual row beneath a stale finalizer's boundary still reconciles the replacement to paused after a crash. Tail reads stay outside the goal file lock (shared workspaceFileLocks with the history service — a locked read deadlocks); callers pre-read the goal identity unlocked and re-verify it under the lock, skipping reconciliation for the round on identity drift. - P2 PRRT_kwDOPxxmWM6cGSPX: the drain lock-order test now counts lock admissions (gate, drain claim, replacement setter) instead of sleeping 25ms, removing the loaded-CI-worker flake. --- .../services/workspaceGoalService.test.ts | 43 +++++++++- src/node/services/workspaceGoalService.ts | 80 ++++++++++++------- src/node/services/workspaceService.ts | 19 +++++ 3 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 0d849324c64..bcbe012c6f2 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2774,6 +2774,31 @@ describe("WorkspaceGoalService", () => { status: "active", }); + // Codex P2 (PRRT_kwDOPxxmWM6cGSPK): a mismatched boundary is skipped, not + // treated as the tail's final signal — a genuine post-goal manual row + // beneath it must still reconcile the replacement to paused (covers a + // crash that lost the dispatch-time auto-pause). + await appendUserHistoryMessage(historyService, workspaceId, "Post-goal intervention", { + timestamp: goalB.createdAtMs + 5_000, + }); + const buriedBoundary = createMuxMessage( + `goal-paused-buried-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary", goalId: goalA.goalId }, + } + ); + expect((await historyService.appendToHistory(workspaceId, buriedBoundary)).success).toBe(true); + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "paused", + }); + // Reset for the legacy assertion below: resume B. + await setGoalOk(service, { workspaceId, status: "active" }); + // Legacy boundaries without a goalId keep the old any-goal semantics. const legacyBoundary = createMuxMessage( `goal-paused-legacy-${crypto.randomUUID()}`, @@ -3037,6 +3062,18 @@ describe("WorkspaceGoalService", () => { // deterministic: [gate] -> drain claim -> replacement setter -> drain // persistence. The replacement setter therefore provably lands between // the drain's claim and its persistence tenure. + // + // Codex P2 (PRRT_kwDOPxxmWM6cGSPX): count lock admissions instead of + // sleeping — the replacement setter's asynchronous pre-lock streaming + // check has no time bound on a loaded worker, and releasing the gate + // before it enqueues would let the drain settle first and fail the + // `drained` assertion spuriously. + let lockCalls = 0; + const originalWithLock = serviceAccess.fileLocks.withLock.bind(serviceAccess.fileLocks); + serviceAccess.fileLocks.withLock = (key: string, fn: () => Promise): Promise => { + lockCalls += 1; + return originalWithLock(key, fn); + }; let releaseGate!: () => void; const gate = new Promise((resolve) => { releaseGate = resolve; @@ -3057,9 +3094,9 @@ describe("WorkspaceGoalService", () => { // coherent when the drain replays this mutation on the next pass. expectedGoalId: first.goalId, }); - // Let the replacement setter finish its pre-lock streaming check and - // queue on the lock before the gate opens. - await new Promise((resolve) => setTimeout(resolve, 25)); + // Deterministic admission signal: gate (1), drain claim (2), replacement + // setter (3). Only then may the gate open. + await waitForCondition(() => lockCalls >= 3, { timeoutMs: 5_000 }); releaseGate(); await gateTenure; diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 3bf171e7bdf..8ca0de0f788 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -233,13 +233,6 @@ interface ChatTailGoalModeResult { * freshly armed kickoff (see `applyChatTailGoalMode`). */ pausedBy?: "pause_boundary" | "manual_user"; - /** - * When `pausedBy === "pause_boundary"`: the goal the boundary row was - * stamped for. Reconciliation ignores boundaries belonging to a different - * goal (Codex P2 PRRT_kwDOPxxmWM6cEl4F); absent on legacy rows, which keep - * the old any-goal semantics. - */ - boundaryGoalId?: string; /** * When `pausedBy === "manual_user"`: the moment the user authored the pausing * row — its persisted enqueue time (queued sends) or the row timestamp. @@ -562,7 +555,10 @@ export class WorkspaceGoalService { this.streamInterrupter = interrupter; } - private async readChatTailGoalMode(workspaceId: string): Promise { + private async readChatTailGoalMode( + workspaceId: string, + currentGoalId?: string | null + ): Promise { const historyResult = await this.historyService.getLastMessages(workspaceId, 100); if (!historyResult.success) { log.warn("Failed to read chat tail for goal mode reconciliation", { @@ -587,11 +583,18 @@ export class WorkspaceGoalService { } if (message.metadata?.muxMetadata?.type === "goal-pause-boundary") { const boundaryGoalId = message.metadata.muxMetadata.goalId; - return { - mode: "paused", - pausedBy: "pause_boundary", - ...(boundaryGoalId != null ? { boundaryGoalId } : {}), - }; + if (currentGoalId != null && boundaryGoalId != null && boundaryGoalId !== currentGoalId) { + // Codex P2 (PRRT_kwDOPxxmWM6cEl4F, PRRT_kwDOPxxmWM6cGSPK): a stale + // pause finalizer's boundary can land AFTER a replacement goal (and + // even after that goal's own manual rows). A mismatched goal-scoped + // boundary is not the tail's final signal — skip it and keep + // scanning so a genuine post-goal manual row beneath it still + // reconciles the replacement (e.g. to paused after a crash lost the + // dispatch-time auto-pause). Legacy rows without a goalId keep the + // old any-goal semantics. + continue; + } + return { mode: "paused", pausedBy: "pause_boundary" }; } if (message.metadata?.synthetic === true) { continue; @@ -626,19 +629,10 @@ export class WorkspaceGoalService { return goal; } - // Codex P2 (PRRT_kwDOPxxmWM6cEl4F): a pause boundary stamped for a - // DIFFERENT goal is a stale pause finalizer's artifact — its append raced - // a replacement's persistence and landed after the newer goal was written. - // Applying it here would silently pause a goal the user never paused. - // Treat it as no signal; legacy boundaries without a goalId keep pausing - // whatever goal is current. - if ( - chatTailMode.pausedBy === "pause_boundary" && - chatTailMode.boundaryGoalId != null && - chatTailMode.boundaryGoalId !== goal.goalId - ) { - return goal; - } + // Mismatched goal-scoped pause boundaries (a stale pause finalizer's + // artifact racing a replacement) never reach here: readChatTailGoalMode + // skips them while scanning when given the current goal's identity (Codex + // P2 PRRT_kwDOPxxmWM6cEl4F, PRRT_kwDOPxxmWM6cGSPK). // Kickoff window: a freshly activated goal (model set_goal / user Resume) // arms a kickoff continuation candidate before its first goal_continuation @@ -702,13 +696,25 @@ export class WorkspaceGoalService { } private async syncGoalStatusToChatTail(workspaceId: string): Promise { - const chatTailMode = await this.readChatTailGoalMode(workspaceId); + // Chat-tail reads go through the history service, which shares + // `workspaceFileLocks` — reading the tail while holding the goal file lock + // deadlocks. Pre-read the goal identity unlocked so goal-scoped pause + // boundaries from replaced goals are skipped while scanning (Codex P2 + // PRRT_kwDOPxxmWM6cGSPK); the locked section re-verifies the identity and + // skips reconciliation for this round when a concurrent setter changed it + // (the next read re-syncs against the fresh identity). + const preRead = await this.readGoalFile(workspaceId); + const chatTailMode = + preRead != null ? await this.readChatTailGoalMode(workspaceId, preRead.goalId) : null; return this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); if (!current) { await this.pushGoalReadSnapshot(workspaceId, null); return null; } + if (chatTailMode == null || current.goalId !== preRead?.goalId) { + return current; + } const next = this.applyChatTailGoalMode(workspaceId, current, chatTailMode); if (next === current) { @@ -727,7 +733,7 @@ export class WorkspaceGoalService { workspaceId: string, goalId: string ): Promise { - const chatTailMode = await this.readChatTailGoalMode(workspaceId); + const chatTailMode = await this.readChatTailGoalMode(workspaceId, goalId); if (chatTailMode.mode !== "active") { return true; } @@ -1680,14 +1686,23 @@ export class WorkspaceGoalService { workspaceId: string, options: { syncChatTail?: boolean } = {} ): Promise { - const chatTailMode = - options.syncChatTail === true ? await this.readChatTailGoalMode(workspaceId) : null; + // Tail reads must stay outside the goal file lock (shared with the + // history service — see syncGoalStatusToChatTail). Pre-read the identity + // for boundary scoping; identity drift under the lock skips chat-tail + // reconciliation for this round only. + const preRead = options.syncChatTail === true ? await this.readGoalFile(workspaceId) : null; + const preReadChatTailMode = + preRead != null ? await this.readChatTailGoalMode(workspaceId, preRead.goalId) : null; return this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); if (!current) { await this.pushGoalReadSnapshot(workspaceId, null); return null; } + const chatTailMode = + preReadChatTailMode != null && current.goalId === preRead?.goalId + ? preReadChatTailMode + : null; const budgetNormalized = this.applyBudgetDrivenStatus(current); const next = chatTailMode ? this.applyChatTailGoalMode(workspaceId, budgetNormalized, chatTailMode) @@ -1709,7 +1724,10 @@ export class WorkspaceGoalService { expectedGoalId: string, firedAtMs: number ): Promise { - const chatTailMode = await this.readChatTailGoalMode(workspaceId); + // Read outside the goal file lock (shared with the history service) and + // scope boundary skipping to the expected goal — the locked section below + // already refuses when the durable goal is not that goal. + const chatTailMode = await this.readChatTailGoalMode(workspaceId, expectedGoalId); await this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); if (current?.goalId !== expectedGoalId) { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5b8d0390216..1c058f107f9 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -10843,6 +10843,25 @@ export class WorkspaceService extends EventEmitter { const shouldQueue = !normalizedOptions?.editMessageId && session.isBusy(); + // Codex P1 (PRRT_kwDOPxxmWM6cGSPP): a goal-continuation dispatch closure + // captured before a manual send entered preflight would otherwise win + // idle admission here — the session only reports busy late in + // AgentSession.sendMessage, so `isBusy()` alone cannot see the user's + // in-flight turn. `preflightSendCounts` includes THIS send (incremented + // synchronously at entry above), so any other in-preflight send makes + // the count exceed 1; refusing is safe because idle-only callers + // (continuations, heartbeats) treat this as a transient skip and retry. + if ( + !shouldQueue && + internal?.requireIdle && + (this.preflightSendCounts.get(workspaceId) ?? 0) > 1 + ) { + return Err({ + type: "unknown", + raw: IDLE_ONLY_BUSY_SKIP_MESSAGE, + }); + } + if (shouldQueue) { // Everything from here to queueMessage is synchronous, so a probe pass here cannot go // stale before the entry is enqueued. From b36b94263eadc600d7a1609eb4ee3c1150a179d4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 14:46:27 +0000 Subject: [PATCH 22/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2019=20?= =?UTF-8?q?=E2=80=94=20treat=20same-millisecond=20authoring=20as=20stale?= =?UTF-8?q?=20for=20the=20stop=20acknowledgment=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/workspaceGoalService.test.ts | 7 +++++++ src/node/services/workspaceGoalService.ts | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index bcbe012c6f2..4a04c223802 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2950,6 +2950,13 @@ describe("WorkspaceGoalService", () => { }); expect(afterStale?.requireUserAcknowledgmentSinceMs).not.toBeNull(); + // Codex P1 (PRRT_kwDOPxxmWM6cHJVn): same-millisecond authoring cannot + // prove the send was admitted after the Stop — equality keeps the gate. + const afterEqual = await service.acknowledgeUser(workspaceId, { + authoredAtMs: created.createdAtMs + 5_000, + }); + expect(afterEqual?.requireUserAcknowledgmentSinceMs).not.toBeNull(); + // Authored after the stop: an informed user action clears the gate. const afterFresh = await service.acknowledgeUser(workspaceId, { authoredAtMs: created.createdAtMs + 6_000, diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 8ca0de0f788..2fda9d81220 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -3112,9 +3112,12 @@ export class WorkspaceGoalService { // timestamp) recovery re-arms the goal despite the newer Stop. Callers // acting for an explicit fresh user action (slash workflows, direct // sends carrying their request-entry authoring time) postdate the gate - // and clear it as before. + // and clear it as before. Equality is treated as stale (Codex P1 + // PRRT_kwDOPxxmWM6cHJVn): a same-millisecond timestamp cannot prove the + // send was admitted after the Stop, and keeping the gate is the safe + // failure mode — the user can always acknowledge with a later message. const authoredAtMs = toValidEpochMs(options?.authoredAtMs); - if (authoredAtMs != null && authoredAtMs < current.requireUserAcknowledgmentSinceMs) { + if (authoredAtMs != null && authoredAtMs <= current.requireUserAcknowledgmentSinceMs) { await this.pushSnapshot(workspaceId, current); return current; } From 6521c32f236d753f3f1164ad557bac284fa34aae Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 15:21:06 +0000 Subject: [PATCH 23/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2020=20?= =?UTF-8?q?=E2=80=94=20goal-scope=20continuation=20rows=20so=20a=20replace?= =?UTF-8?q?d=20goal=20cannot=20reactivate=20its=20successor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/run.ts | 3 + src/common/types/message.ts | 8 +++ src/node/services/agentSession.ts | 5 ++ .../services/workspaceGoalService.test.ts | 69 ++++++++++++++++++- src/node/services/workspaceGoalService.ts | 16 +++++ src/node/services/workspaceService.ts | 6 ++ 6 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 2047b81e30d..6e88549ea89 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -832,6 +832,7 @@ async function main(): Promise { }); let goalStopReason: string | null = null; + let cliGoalId: string | undefined; if (hasGoal) { const setGoalResult = await workspaceGoalService.setGoal({ workspaceId, @@ -843,6 +844,7 @@ async function main(): Promise { if (!setGoalResult.success) { throw new Error(`Failed to set CLI goal: ${setGoalResult.error.type}`); } + cliGoalId = setGoalResult.data.goalId; const warning = goalBudgetCents == null && goalTurnCap == null ? "CLI Goal Run has no --goal-budget or --goal-turns limit. It will continue until the goal is complete or another stop condition occurs." @@ -1023,6 +1025,7 @@ async function main(): Promise { synthetic: true, agentInitiated: true, goalKind: GOAL_CONTINUATION_KIND, + goalId: cliGoalId, goalContinuation: true, } : undefined diff --git a/src/common/types/message.ts b/src/common/types/message.ts index f737a4ca035..de236ba4bb3 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -882,6 +882,14 @@ export interface MuxMetadata { muxMetadata?: MuxMessageMetadata; // Command metadata used by both frontend and backend message flows /** Persisted discriminator for synthetic user turns created by the active-goal loop. */ kind?: "goal_continuation" | "goal_budget_limit"; + /** + * Goal identity for `kind` rows. Chat-tail reconciliation only accepts a + * continuation row as "goal is active" evidence when it was dispatched for + * the goal being reconciled — a replaced goal's continuation must not + * reactivate its successor (Codex P2 PRRT_kwDOPxxmWM6cH3kV). Legacy rows + * without a goalId keep the old any-goal semantics. + */ + goalId?: string; /** * ACP-only correlation id propagated through stream events so prompt() can diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 3d2253df879..d6154972766 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2754,6 +2754,8 @@ export class AgentSession { agentInitiated?: boolean; goalContinuation?: boolean; goalKind?: GoalSyntheticMessageKind; + /** Goal identity persisted alongside goalKind so chat-tail reconciliation can scope the row. */ + goalId?: string; startStreamInBackground?: boolean; onAccepted?: () => Promise | void; onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise | void; @@ -3324,6 +3326,9 @@ export class AgentSession { muxMetadata: stampedMuxMetadata, // Pass through frontend metadata as black-box ...(acpPromptId != null ? { acpPromptId } : {}), ...(goalKind != null ? { kind: goalKind } : {}), + // Scope goal-loop rows to their goal so a replaced goal's continuation + // cannot reactivate its successor during chat-tail reconciliation. + ...(goalKind != null && internal?.goalId != null ? { goalId: internal.goalId } : {}), // Persist the queue-entry authoring time so goal-safety reconciliation // can re-derive the pre-goal/post-goal distinction after a restart. ...(internal?.enqueuedAtMs != null ? { enqueuedAtMs: internal.enqueuedAtMs } : {}), diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 4a04c223802..5cf55d9f3fc 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -348,22 +348,31 @@ describe("WorkspaceGoalService", () => { test("arms a kickoff continuation when a brand-new goal is set on an idle workspace", async () => { const dispatcher = new IdleDispatcher(); - const executed: Array<{ message: string; kind: string | undefined }> = []; + const executed: Array<{ + message: string; + kind: string | undefined; + goalId: string | undefined; + }> = []; service.registerGoalContinuationConsumer(dispatcher, { hasActiveDescendantTasks: () => false, getRuntimeState: () => ({ isRuntimeCompatible: true }), executeGoalContinuation: (input) => { - executed.push({ message: input.message, kind: input.kind }); + executed.push({ message: input.message, kind: input.kind, goalId: input.goalId }); return Promise.resolve(true); }, getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), }); - await setGoalOk(service, { workspaceId, objective: "Kick off without a prior stream" }); + const created = await setGoalOk(service, { + workspaceId, + objective: "Kick off without a prior stream", + }); await waitForCondition(() => executed.length > 0, { timeoutMs: 1_000 }); expect(executed[0]?.message).toContain(""); expect(executed[0]?.kind).toBe("goal_continuation"); + // The dispatch carries the goal identity so the persisted row is goal-scoped. + expect(executed[0]?.goalId).toBe(created.goalId); }); test("arms a kickoff continuation when resuming a paused goal on an idle workspace", async () => { @@ -2818,6 +2827,60 @@ describe("WorkspaceGoalService", () => { }); }); + test("continuation rows for a replaced goal do not reconcile the newer goal to active", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cH3kV): goal A fired a continuation and was + // paused, then replaced with goal B which the user pauses. When B's pause + // finalizer cannot land its own boundary (crash / append failure), any + // later reconciliation skips A's goal-scoped boundary and would otherwise + // reach A's older continuation row and silently reactivate B. + // Continuation evidence is goal-scoped. + const goalA = await setGoalOk(service, { workspaceId, objective: "Goal A" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: goalA.goalId, + }); + const pausedBoundary = createMuxMessage( + `goal-paused-a-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary", goalId: goalA.goalId }, + } + ); + expect((await historyService.appendToHistory(workspaceId, pausedBoundary)).success).toBe(true); + + const goalB = await setGoalOk(service, { workspaceId, objective: "Goal B" }); + // Simulate B's pause boundary append being lost (crash-equivalent): the + // finalizer skips its post-pause chat-tail sync, leaving the tail ending + // at goal A's rows while goal.json durably says B is paused. + const appendSpy = spyOn(historyService, "appendToHistory").mockImplementationOnce(() => + Promise.resolve({ success: false, error: "injected: boundary append lost" }) + ); + await setGoalOk(service, { workspaceId, status: "paused" }); + appendSpy.mockRestore(); + + // Reconciliation skips A's boundary AND A's continuation row — B stays paused. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "paused", + }); + + // Legacy continuation rows without a goalId keep the old any-goal semantics. + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "active", + }); + }); + test("cost previews reset when the goal becomes ineligible for accounting mid-stream", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cEl4P): a status transition during the stream // (pause, complete, or a budget edit flipping the goal budget_limited) diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 2fda9d81220..737248af6b9 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -182,6 +182,8 @@ export interface GoalContinuationRuntimeBridge { options: SendMessageOptions; startStreamInBackground?: boolean; kind?: GoalSyntheticMessageKind; + /** Stamped on the synthetic user row so chat-tail reconciliation can scope it to this goal. */ + goalId?: string; }): Promise; /** * Build default SendMessageOptions for a kickoff continuation that is armed @@ -579,6 +581,18 @@ export class WorkspaceGoalService { } if (message.metadata?.kind === GOAL_CONTINUATION_KIND) { + // Codex P2 (PRRT_kwDOPxxmWM6cH3kV): a replaced goal's continuation row + // is not proof that the CURRENT goal is active. When goal A is paused + // and replaced with an explicitly paused goal B, a reconciliation that + // runs before B's pause finalizer appends its boundary skips A's + // goal-scoped boundary above and would otherwise reach A's older + // continuation row and silently reactivate B. Rows scoped to a + // different goal are invisible here, mirroring the boundary skip; + // legacy rows without a goalId keep the old any-goal semantics. + const rowGoalId = message.metadata.goalId; + if (currentGoalId != null && rowGoalId != null && rowGoalId !== currentGoalId) { + continue; + } return { mode: "active" }; } if (message.metadata?.muxMetadata?.type === "goal-pause-boundary") { @@ -1430,6 +1444,7 @@ export class WorkspaceGoalService { options: candidate.sendOptions, startStreamInBackground: false, kind: GOAL_BUDGET_LIMIT_KIND, + goalId: goal.goalId, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); @@ -1471,6 +1486,7 @@ export class WorkspaceGoalService { options: candidate.sendOptions, startStreamInBackground: candidate.source === "kickoff", kind: GOAL_CONTINUATION_KIND, + goalId: goal.goalId, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 1c058f107f9..0adcfc6cced 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -10563,6 +10563,8 @@ export class WorkspaceService extends EventEmitter { goalContinuation?: boolean; /** Specific active-goal synthetic turn kind to persist on the user message. */ goalKind?: GoalSyntheticMessageKind; + /** Goal identity persisted alongside goalKind so reconciliation can scope the row. */ + goalId?: string; /** Force Copilot billing classification to "agent" for internal sends. */ agentInitiated?: boolean; onAccepted?: () => Promise | void; @@ -10820,6 +10822,7 @@ export class WorkspaceService extends EventEmitter { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, goalKind: internal?.goalKind, + goalId: internal?.goalId, cancelState: internal?.cancelState, cancelSignal: internal?.cancelSignal, onCanceled: internal?.onCanceled, @@ -11046,6 +11049,7 @@ export class WorkspaceService extends EventEmitter { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, goalKind: internal?.goalKind, + goalId: internal?.goalId, goalContinuation: internal?.goalContinuation, startStreamInBackground: internal?.startStreamInBackground, cancelState: internal?.cancelState, @@ -13549,6 +13553,7 @@ export class WorkspaceService extends EventEmitter { message: string; startStreamInBackground?: boolean; kind?: GoalSyntheticMessageKind; + goalId?: string; options: SendMessageOptions; }): Promise { assert(input.workspaceId.trim().length > 0, "executeGoalContinuation requires workspaceId"); @@ -13575,6 +13580,7 @@ export class WorkspaceService extends EventEmitter { : undefined, requireIdle: true, goalKind, + goalId: input.goalId, goalContinuation: true, } ); From b714c12173fefbe67f3fed55099c1a79db491d13 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 16:16:03 +0000 Subject: [PATCH 24/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2021=20?= =?UTF-8?q?=E2=80=94=20carry=20goalId=20through=20compaction=20follow-ups/?= =?UTF-8?q?retries;=20hold=20pause=20finalization=20against=20tail=20recon?= =?UTF-8?q?ciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/types/message.ts | 14 +++ .../agentSession.autoCompaction.test.ts | 7 +- src/node/services/agentSession.ts | 73 ++++++++++-- .../services/workspaceGoalService.test.ts | 52 ++++++++ src/node/services/workspaceGoalService.ts | 111 ++++++++++++++++-- 5 files changed, 233 insertions(+), 24 deletions(-) diff --git a/src/common/types/message.ts b/src/common/types/message.ts index de236ba4bb3..505698d5b6e 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -103,6 +103,13 @@ export type StartupRetrySendOptions = Pick< agentInitiated?: boolean; /** Internal goal continuation classification for startup auto-retry accounting. */ goalKind?: GoalSyntheticMessageKind; + /** + * Goal identity matching `goalKind`. Not persisted by + * pickStartupRetrySendOptions (the user row's own metadata.goalId is the + * durable copy); startup recovery re-derives it so resumed streams keep + * goal-scoped compaction follow-ups (Codex P2 PRRT_kwDOPxxmWM6cIv2E). + */ + goalId?: string; }; /** @@ -164,6 +171,13 @@ export interface CompactionFollowUpRequest extends CompactionFollowUpInput, Pres agentInitiated?: boolean; /** Internal goal continuation classification for synthetic follow-up accounting. */ goalKind?: GoalSyntheticMessageKind; + /** + * Goal identity matching `goalKind`. Preserved through compaction so the + * re-dispatched follow-up row stays goal-scoped for chat-tail + * reconciliation instead of degrading to a legacy unscoped row (Codex P2 + * PRRT_kwDOPxxmWM6cIv2E). + */ + goalId?: string; /** Internal dispatch guardrails for crash-safe follow-up recovery. */ dispatchOptions?: CompactionFollowUpDispatchOptions; /** diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 53ceb0d016e..e41f349caa8 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -303,7 +303,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { expect(unstamped).toBeUndefined(); }); - test("preserves goal kind on auto-compaction follow-up requests", async () => { + test("preserves goal kind and goal identity on auto-compaction follow-up requests", async () => { const { session } = await createSessionHarness({ workspaceId: "ws-auto-compaction-goal-kind", }); @@ -315,6 +315,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { options: SendMessageOptions; modelForStream: string; goalKind?: typeof GOAL_CONTINUATION_KIND; + goalId?: string; }) => CompactionFollowUpRequest; } ).buildAutoCompactionFollowUp({ @@ -322,9 +323,13 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { options: { model: "openai:gpt-4o", agentId: "exec" }, modelForStream: "openai:gpt-4o", goalKind: GOAL_CONTINUATION_KIND, + goalId: "goal-compaction-scope", }); expect(followUp.goalKind).toBe(GOAL_CONTINUATION_KIND); + // Codex P2 (PRRT_kwDOPxxmWM6cIv2E): the re-dispatched follow-up row must + // stay goal-scoped instead of degrading to a legacy unscoped row. + expect(followUp.goalId).toBe("goal-compaction-scope"); session.dispose(); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d6154972766..fdc4e16f8f3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -216,6 +216,8 @@ interface AutoRetryResumeRequest { options: SendMessageOptions; agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; + /** Goal identity matching goalKind; keeps retried streams goal-scoped. */ + goalId?: string; } function stripGoalInterventionPolicy(options: SendMessageOptions): SendMessageOptions { @@ -777,6 +779,8 @@ export class AgentSession { openaiTruncationModeOverride?: "auto" | "disabled"; providersConfig: ProvidersConfigMap | null; goalKind?: GoalSyntheticMessageKind; + /** Goal identity matching goalKind, so mid-stream compaction follow-ups stay goal-scoped. */ + goalId?: string; workspaceTurnMetadata?: Extract; }; @@ -1166,7 +1170,8 @@ export class AgentSession { private setAutoRetryResumeState( options: SendMessageOptions | undefined, agentInitiated?: boolean, - goalKind?: GoalSyntheticMessageKind + goalKind?: GoalSyntheticMessageKind, + goalId?: string ): void { if (!options) { this.lastAutoRetryResumeRequest = undefined; @@ -1177,6 +1182,7 @@ export class AgentSession { options, ...(agentInitiated === true ? { agentInitiated: true } : {}), ...(goalKind != null ? { goalKind } : {}), + ...(goalId != null ? { goalId } : {}), }; } @@ -1204,6 +1210,7 @@ export class AgentSession { const result = await this.resumeStream(request.options, { agentInitiated: request.agentInitiated === true ? true : undefined, goalKind: request.goalKind, + goalId: request.goalId, }); if (result.success) { if (!result.data.started) { @@ -1787,6 +1794,13 @@ export class AgentSession { const persistedGoalKind = coerceGoalSyntheticMessageKind(persistedRetrySendOptions?.goalKind) ?? coerceGoalSyntheticMessageKind(lastUserMessage?.metadata?.kind); + // The user row's own metadata.goalId is the durable copy (stamped next to + // `kind`); recover it so resumed streams keep goal-scoped compaction + // follow-ups (Codex P2 PRRT_kwDOPxxmWM6cIv2E). + const persistedGoalId = + persistedGoalKind != null && typeof lastUserMessage?.metadata?.goalId === "string" + ? lastUserMessage.metadata.goalId + : undefined; const workspaceAgentIdCandidates = resolvePersistedAgentIdCandidates(workspaceMetadata); const workspaceAgentId = workspaceAgentIdCandidates[0] ?? WORKSPACE_DEFAULTS.agentId; @@ -1896,6 +1910,9 @@ export class AgentSession { if (persistedGoalKind != null) { compactionRequest.goalKind = persistedGoalKind; } + if (persistedGoalId != null) { + compactionRequest.goalId = persistedGoalId; + } return compactionRequest; } @@ -1936,6 +1953,9 @@ export class AgentSession { if (persistedGoalKind != null) { retryRequest.goalKind = persistedGoalKind; } + if (persistedGoalId != null) { + retryRequest.goalId = persistedGoalId; + } if (typeof persistedAllowAgentSetGoal === "boolean") { retryRequest.allowAgentSetGoal = persistedAllowAgentSetGoal; } @@ -2091,8 +2111,8 @@ export class AgentSession { return "completed"; } - const { agentInitiated, goalKind, ...resumeOptions } = retryRequest; - this.setAutoRetryResumeState(resumeOptions, agentInitiated, goalKind); + const { agentInitiated, goalKind, goalId, ...resumeOptions } = retryRequest; + this.setAutoRetryResumeState(resumeOptions, agentInitiated, goalKind, goalId); } // Disk reads above may race with user actions; retry once the current work settles @@ -3426,6 +3446,7 @@ export class AgentSession { fileParts: followUpFileParts, agentInitiated, goalKind, + goalId: internal?.goalId, muxMetadata: typedMuxMetadata, workspaceTurnMetadata: inheritedWorkspaceTurnMetadata, }); @@ -3760,7 +3781,7 @@ export class AgentSession { // Same-session retry should resume the exact accepted request we just finalized // in history, even if runtime warmup fails before streamWithHistory() starts. - this.setAutoRetryResumeState(optionsForStream, agentInitiated, goalKind); + this.setAutoRetryResumeState(optionsForStream, agentInitiated, goalKind, internal?.goalId); try { await internal?.onAccepted?.(); } catch (error) { @@ -3849,6 +3870,7 @@ export class AgentSession { agentInitiated, preparedTurnAbortController.signal, goalKind, + internal?.goalId, turnThinkingOverride ); if (streamResult.success && preparedTurnAbortController.signal.aborted) { @@ -3918,7 +3940,7 @@ export class AgentSession { async resumeStream( options: SendMessageOptions, - internal?: { agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind } + internal?: { agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; goalId?: string } ): Promise> { this.assertNotDisposed("resumeStream"); @@ -3959,7 +3981,12 @@ export class AgentSession { // A resumed attempt becomes the latest live resume request as soon as we // accept its options, even if startup fails before the stream fully begins. - this.setAutoRetryResumeState(optionsForStream, internal?.agentInitiated, internal?.goalKind); + this.setAutoRetryResumeState( + optionsForStream, + internal?.agentInitiated, + internal?.goalKind, + internal?.goalId + ); this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata(optionsForStream.muxMetadata); this.setTurnPhase(TurnPhase.PREPARING); // Open the mid-turn thinking override window for the resumed turn (after @@ -3977,6 +4004,7 @@ export class AgentSession { internal?.agentInitiated, undefined, internal?.goalKind, + internal?.goalId, turnThinkingOverride ); if (!result.success) { @@ -4246,6 +4274,7 @@ export class AgentSession { fileParts?: FilePart[]; agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; + goalId?: string; muxMetadata?: MuxMessageMetadata; workspaceTurnMetadata?: Extract; }): CompactionFollowUpRequest { @@ -4264,6 +4293,10 @@ export class AgentSession { followUp.goalKind = params.goalKind; } + if (params.goalId != null) { + followUp.goalId = params.goalId; + } + if (params.fileParts && params.fileParts.length > 0) { followUp.fileParts = params.fileParts; } @@ -4503,6 +4536,7 @@ export class AgentSession { options: streamContext.options, agentInitiated: streamContext.agentInitiated, goalKind: streamContext.goalKind, + goalId: streamContext.goalId, modelForStream: streamContext.modelString, muxMetadata: streamContext.workspaceTurnMetadata, }); @@ -4620,6 +4654,7 @@ export class AgentSession { agentInitiated?: boolean, abortSignal?: AbortSignal, goalKind?: GoalSyntheticMessageKind, + goalId?: string, // Session-owned per-turn holder for mid-turn thinking changes. Passed // explicitly (not read from the field) so a preempted turn can never pick // up its replacement's holder. Absent for internal retry paths. @@ -4646,6 +4681,7 @@ export class AgentSession { agentInitiated, openaiTruncationModeOverride, ...(goalKind != null ? { goalKind } : {}), + ...(goalId != null ? { goalId } : {}), providersConfig, }; this.activeStreamUserMessageId = undefined; @@ -5091,6 +5127,7 @@ export class AgentSession { // Capture attribution before finalizeCompactionRetry() clears active stream state. const retryAgentInitiated = this.activeStreamContext?.agentInitiated; const retryGoalKind = this.activeStreamContext?.goalKind; + const retryGoalId = this.activeStreamContext?.goalId; const retryOptionsForResume = retryOptions ?? { model: context.modelString, agentId: WORKSPACE_DEFAULTS.agentId, @@ -5110,7 +5147,12 @@ export class AgentSession { return false; } - this.setAutoRetryResumeState(retryOptionsForResume, retryAgentInitiated, retryGoalKind); + this.setAutoRetryResumeState( + retryOptionsForResume, + retryAgentInitiated, + retryGoalKind, + retryGoalId + ); this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata( retryOptionsForResume.muxMetadata ); @@ -5124,7 +5166,8 @@ export class AgentSession { undefined, retryAgentInitiated, undefined, - retryGoalKind + retryGoalKind, + retryGoalId ); } finally { if (this.turnPhase === TurnPhase.PREPARING) { @@ -5230,7 +5273,8 @@ export class AgentSession { true, context.agentInitiated, undefined, - context.goalKind + context.goalKind, + context.goalId ); } finally { if (this.turnPhase === TurnPhase.PREPARING) { @@ -6897,7 +6941,12 @@ export class AgentSession { // The compaction summary is now the source of truth for the next live resume // request. Pre-arm retry state from the reconstructed follow-up so failures // before stream startup do not fall back to the already-completed compact turn. - this.setAutoRetryResumeState(options, followUp.agentInitiated, followUp.goalKind); + this.setAutoRetryResumeState( + options, + followUp.agentInitiated, + followUp.goalKind, + followUp.goalId + ); // Await sendMessage to ensure the follow-up is persisted before returning. // This guarantees ordering: the follow-up message is written to history @@ -6908,6 +6957,10 @@ export class AgentSession { synthetic: true, agentInitiated: followUp.agentInitiated, goalKind: followUp.goalKind, + // Keep the re-dispatched continuation row goal-scoped so a replaced + // goal's follow-up cannot reactivate its successor during chat-tail + // reconciliation (Codex P2 PRRT_kwDOPxxmWM6cIv2E). + goalId: followUp.goalId, goalContinuation: followUp.goalKind === GOAL_CONTINUATION_KIND, }); if (!sendResult.success) { diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 5cf55d9f3fc..9e0620a7351 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2881,6 +2881,58 @@ describe("WorkspaceGoalService", () => { }); }); + test("getGoal during pause finalization does not reactivate the goal being paused", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cIyKW): between the durable pause write and + // the finalizer appending the goal-pause-boundary, the tail still ends at + // the goal's own continuation row. A concurrent getGoal reconciliation + // must not flip the goal back to active — that would make the finalizer's + // identity guard bail (status drifted) and skip the candidate delete + + // boundary append while the Pause call reports a stale paused result. + const created = await setGoalOk(service, { workspaceId, objective: "Pause under load" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + + // Gate the finalizer's boundary append so the test can observe the window + // while the pause hold is armed. + const realAppend = historyService.appendToHistory.bind(historyService); + let releaseBoundaryAppend!: () => void; + const boundaryGate = new Promise((resolve) => (releaseBoundaryAppend = resolve)); + let boundaryAppendStarted = false; + const appendSpy = spyOn(historyService, "appendToHistory").mockImplementationOnce( + async (id, message) => { + boundaryAppendStarted = true; + await boundaryGate; + return realAppend(id, message); + } + ); + + const pausePromise = service.setGoal({ workspaceId, status: "paused" }); + await waitForCondition(() => boundaryAppendStarted, { timeoutMs: 5_000 }); + + // Mid-window: durable record is paused, boundary not yet in the tail. + // Reconciliation must hold the pause instead of trusting the stale tail. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + + releaseBoundaryAppend(); + const pauseResult = await pausePromise; + expect(pauseResult.success).toBe(true); + appendSpy.mockRestore(); + + // Post-finalization: the boundary landed, so the pause is durable against + // reconciliation without the in-memory hold. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + test("cost previews reset when the goal becomes ineligible for accounting mid-stream", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cEl4P): a status transition during the stream // (pause, complete, or a budget edit flipping the goal budget_limited) diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 737248af6b9..06eb2d053b8 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -472,6 +472,51 @@ export class WorkspaceGoalService { * PRRT_kwDOPxxmWM6cCH_L). Cleared when the next stream start is recorded. */ private readonly drainSettledWorkspaces = new Set(); + + /** + * workspaceId → goalId whose pause finalization is in flight (armed under + * the goal file lock alongside the durable paused write, released after + * `finalizeGoalPersistence` returns). Between the paused write and the + * finalizer's boundary append the chat tail still ends at this goal's own + * continuation row, so a concurrent getGoal reconciliation would flip the + * goal back to active — making the finalizer's identity guard bail and skip + * the candidate delete + boundary append while the Pause call reports a + * stale paused result (Codex P1 PRRT_kwDOPxxmWM6cIyKW). While the hold is + * set, chat-tail reconciliation must not reactivate the held goal; genuine + * user mutations (resume, replacement) write through setGoal and are + * unaffected, so a newer user action still wins over the pause. + */ + private readonly pauseFinalizationHolds = new Map(); + + private armPauseFinalizationHold(workspaceId: string, goalId: string): void { + const existing = this.pauseFinalizationHolds.get(workspaceId); + if (existing?.goalId === goalId) { + existing.depth += 1; + return; + } + // A newer pause (e.g. for a replacement goal) supersedes the older hold; + // the superseded finalizer bails on its identity re-check anyway. + this.pauseFinalizationHolds.set(workspaceId, { goalId, depth: 1 }); + } + + private releasePauseFinalizationHold(workspaceId: string, goalId: string): void { + const existing = this.pauseFinalizationHolds.get(workspaceId); + if (existing?.goalId !== goalId) { + return; + } + existing.depth -= 1; + if (existing.depth <= 0) { + this.pauseFinalizationHolds.delete(workspaceId); + } + } + + /** True when a pause persisted durably and its finalization side effects are still owed. */ + private pauseFinalizationHoldApplies( + input: SetGoalInput, + result: Result + ): boolean { + return input.status === "paused" && result.success && result.data.status === "paused"; + } /** * Monotonic per-workspace stream-start counter, bumped synchronously by * `recordStreamStarted`. The stream-end drain captures it at entry and only @@ -692,6 +737,22 @@ export class WorkspaceGoalService { } } + // Codex P1 (PRRT_kwDOPxxmWM6cIyKW): between a durable pause write and its + // finalizer appending the goal-pause-boundary, the tail still ends at this + // goal's own continuation row. Reactivating here would make the + // finalizer's identity guard bail (status drifted), skipping the candidate + // delete and boundary append — silently unwinding a pause the caller was + // just told succeeded. Suppress same-goal automatic reactivation while the + // finalization is in flight; genuine user mutations (resume, replacement) + // write through setGoal directly and still win. + if ( + goal.status === "paused" && + chatTailMode.mode === "active" && + this.pauseFinalizationHolds.get(workspaceId)?.goalId === goal.goalId + ) { + return goal; + } + const desiredStatus = chatTailMode.mode; if (goal.status === desiredStatus) { return goal; @@ -2512,10 +2573,22 @@ export class WorkspaceGoalService { input: SetGoalInput & { objective?: string }, options?: GoalPersistenceOptions ): Promise> { - const result = await this.fileLocks.withLock(input.workspaceId, () => - this.persistGoalMutationLocked(input, options) - ); - return this.finalizeGoalPersistence(input, result, options); + const result = await this.fileLocks.withLock(input.workspaceId, async () => { + const persisted = await this.persistGoalMutationLocked(input, options); + // Arm under the same lock tenure as the paused write so no other locked + // writer can observe the paused record before the hold exists. + if (this.pauseFinalizationHoldApplies(input, persisted) && persisted.success) { + this.armPauseFinalizationHold(input.workspaceId, persisted.data.goalId); + } + return persisted; + }); + try { + return await this.finalizeGoalPersistence(input, result, options); + } finally { + if (this.pauseFinalizationHoldApplies(input, result) && result.success) { + this.releasePauseFinalizationHold(input.workspaceId, result.data.goalId); + } + } } /** @@ -3612,19 +3685,31 @@ export class WorkspaceGoalService { this.pendingGoalSnapshots.delete(workspaceId); const { projectedGoalId, projectedCreatedAtMs, ...pendingInput } = claimed; const input = { workspaceId, ...pendingInput }; - return { - input, - result: await this.persistGoalMutationLocked(input, { - replacementGoalId: projectedGoalId ?? null, - replacementCreatedAtMs: projectedCreatedAtMs ?? null, - }), - }; + const result = await this.persistGoalMutationLocked(input, { + replacementGoalId: projectedGoalId ?? null, + replacementCreatedAtMs: projectedCreatedAtMs ?? null, + }); + // Mirror setGoalImmediately: arm the pause-finalization hold under + // the same lock tenure as the paused write. + if (this.pauseFinalizationHoldApplies(input, result) && result.success) { + this.armPauseFinalizationHold(workspaceId, result.data.goalId); + } + return { input, result }; }); if (tenure == null) { break; } - const finalized = await this.finalizeGoalPersistence(tenure.input, tenure.result); - drained = finalized.success ? finalized.data : drained; + try { + const finalized = await this.finalizeGoalPersistence(tenure.input, tenure.result); + drained = finalized.success ? finalized.data : drained; + } finally { + if ( + this.pauseFinalizationHoldApplies(tenure.input, tenure.result) && + tenure.result.success + ) { + this.releasePauseFinalizationHold(workspaceId, tenure.result.data.goalId); + } + } } catch (error) { log.warn("applyPendingAfterStreamEnd: dropped invalid queued goal mutation", { workspaceId, From 5543b6e02eddd431e449b8a9093a313c201d25ed Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 16:51:17 +0000 Subject: [PATCH 25/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2022=20?= =?UTF-8?q?=E2=80=94=20stream-scoped=20drain=20claims,=20live=20idle-admis?= =?UTF-8?q?sion=20probe,=20mixed-version=20tail=20scan,=20durable=20wrap-u?= =?UTF-8?q?p=20suppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentSession.ts | 6 + .../services/workspaceGoalService.test.ts | 147 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 73 ++++++++- src/node/services/workspaceService.test.ts | 58 +++++++ src/node/services/workspaceService.ts | 18 +++ 5 files changed, 301 insertions(+), 1 deletion(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index fdc4e16f8f3..30a6a4b0bea 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1680,6 +1680,12 @@ export class AgentSession { // Also clears any candidate armed during the acknowledgment await — a // post-goal intervention must not leave a consumable continuation behind. goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); + if (goal?.status === "budget_limited") { + // Codex P2 (PRRT_kwDOPxxmWM6cJ6NM): the candidate delete above is + // in-memory only — persist the suppression so a restart cannot + // re-synthesize the autonomous wrap-up over the user's intervention. + await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId); + } if (goal?.status !== "active") { return; } diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 9e0620a7351..0df2e877425 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2755,6 +2755,54 @@ describe("WorkspaceGoalService", () => { expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeDefined(); }); + test("an old stream's drain leaves a retry stream's mutation for that stream's own drain", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cJ6M-): an un-awaited provider-error drain can + // still be looping when an automatic retry starts and installs its own + // set_goal. Claims are stream-scoped by stream-start generation: the old + // drain must not claim the retry's mutation — persisting it early would + // archive/replace the goal before the retry's accounting and strip a + // later user abort of its discard window. + await extensionMetadata.setStreaming(workspaceId, true); + const queued = await service.setGoal({ workspaceId, objective: "Queued mid-error" }); + expect(queued.success).toBe(true); + + const serviceAccess = service as unknown as { + fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; + pendingGoalMutations: Map; + }; + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const gateTenure = serviceAccess.fileLocks.withLock(workspaceId, () => gate); + + // Old drain (stream 1) blocks at its locked claim; the retry stream then + // starts and queues a replacement set_goal behind the same lock. Lock + // ordering guarantees the retry's install lands before the old drain's + // second claim pass (its finalization's chat-tail sync queues behind the + // setter's tenure). + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + service.recordStreamStarted(workspaceId); + const retrySetterPromise = service.setGoal({ workspaceId, objective: "Retry-stream goal" }); + releaseGate(); + await gateTenure; + await drainPromise; + const retrySetter = await retrySetterPromise; + expect(retrySetter.success).toBe(true); + + // The old drain claimed only its own stream's mutation; the retry's + // mutation is still queued for the retry's own stream-end drain. + expect(serviceAccess.pendingGoalMutations.get(workspaceId)?.objective).toBe( + "Retry-stream goal" + ); + expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Queued mid-error" }); + + // The retry stream's own drain (entry generation matches) claims it. + const retryDrained = await service.applyPendingAfterStreamEnd(workspaceId); + expect(retryDrained).toMatchObject({ objective: "Retry-stream goal" }); + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); + }); + test("pause boundaries for a replaced goal do not reconcile the newer goal to paused", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cEl4F): a stale pause finalizer's boundary // append awaits history I/O after its identity check, so the row can land @@ -2881,6 +2929,105 @@ describe("WorkspaceGoalService", () => { }); }); + test("legacy unscoped continuation rows behind another goal's rows do not reactivate the current goal", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cJ6NC): mixed-version history. A pre-upgrade + // continuation row has no goalId; once the scan crosses a row scoped to a + // DIFFERENT goal (goal A's boundary), it is inside A's history — the + // unscoped legacy row beneath belongs to A and must not reactivate the + // explicitly paused replacement B. + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + // no goalId: written before goal scoping existed + }); + const goalA = await setGoalOk(service, { workspaceId, objective: "Goal A legacy era" }); + const scopedBoundary = createMuxMessage( + `goal-paused-a-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary", goalId: goalA.goalId }, + } + ); + expect((await historyService.appendToHistory(workspaceId, scopedBoundary)).success).toBe(true); + + const goalB = await setGoalOk(service, { workspaceId, objective: "Goal B replacement" }); + // Simulate B's pause boundary append being lost (crash-equivalent), as in + // the scoped-row regression above. + const appendSpy = spyOn(historyService, "appendToHistory").mockImplementationOnce(() => + Promise.resolve({ success: false, error: "injected: boundary append lost" }) + ); + await setGoalOk(service, { workspaceId, status: "paused" }); + appendSpy.mockRestore(); + + // The legacy row sits behind A's mismatched boundary — not evidence for B. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: goalB.goalId, + status: "paused", + }); + }); + + test("a manual message during budget_limited durably suppresses the wrap-up across restarts", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cJ6NM): deleting the in-memory wrap-up + // candidate is not enough — after a restart the durable record still + // looks wrap-up-eligible (goal-attributable origin, not yet injected) and + // recovery re-synthesizes the autonomous wrap-up over the user's + // intervening message. + const created = await setGoalOk(service, { + workspaceId, + objective: "Budget hit, then user intervened", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitInjectedForGoalId: null, + }); + + // The manual-message hook persists the suppression. + await service.suppressBudgetWrapupForManualUserMessage(workspaceId); + expect(await service.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitOriginKind: "user", + }); + + // Simulate restart: recovery must honor the durable suppression. + const restartedService = new WorkspaceGoalService( + config, + historyService, + extensionMetadata, + analytics + ); + const dispatcher = new IdleDispatcher(); + const executed: Array<{ kind: string | undefined }> = []; + restartedService.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + executed.push({ kind: input.kind }); + return Promise.resolve(true); + }, + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + await restartedService.recoverPendingDispatchAfterRestart(workspaceId); + await drainPendingDispatches(); + + expect(executed).toHaveLength(0); + expect(await restartedService.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitInjectedForGoalId: null, + budgetLimitOriginKind: "user", + }); + }); + test("getGoal during pause finalization does not reactivate the goal being paused", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cIyKW): between the durable pause write and // the finalizer appending the goal-pause-boundary, the tail still ends at diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 06eb2d053b8..4c7e5b9275e 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -267,6 +267,15 @@ interface PendingGoalMutation { forceNewGoal?: boolean | null; /** Stable id for the optimistic record returned before the deferred write drains. */ projectedGoalId?: string | null; + /** + * Stream-start generation at install time. A drain claims only mutations + * belonging to the stream it settles: an un-awaited provider-error drain + * overlapping an automatic retry must not claim a set_goal installed by the + * retry stream — persisting it early would archive/replace the goal before + * the retry's accounting and outlive a later user abort meant to discard it + * (Codex P1 PRRT_kwDOPxxmWM6cJ6M-). + */ + streamStartGeneration?: number; /** * Creation time of the optimistic record. The drain re-creates the durable * goal, but the user could already see (and react to) the projected goal @@ -619,6 +628,14 @@ export class WorkspaceGoalService { return { mode: null }; } + // Codex P2 (PRRT_kwDOPxxmWM6cJ6NC): once the scan has skipped a row + // scoped to a DIFFERENT goal, it has crossed into an older goal's + // history. Legacy unscoped continuation rows beneath that crossing are + // that older goal's rows (written before goal scoping existed) and must + // not reactivate the current goal; unscoped rows keep their any-goal + // compatibility semantics only while the scan is still inside + // unattributed history. + let crossedOtherGoalHistory = false; for (let index = historyResult.data.length - 1; index >= 0; index -= 1) { const message = historyResult.data[index]; if (message.role !== "user" || isSyntheticSnapshotUserMessage(message)) { @@ -636,6 +653,10 @@ export class WorkspaceGoalService { // legacy rows without a goalId keep the old any-goal semantics. const rowGoalId = message.metadata.goalId; if (currentGoalId != null && rowGoalId != null && rowGoalId !== currentGoalId) { + crossedOtherGoalHistory = true; + continue; + } + if (rowGoalId == null && crossedOtherGoalHistory) { continue; } return { mode: "active" }; @@ -643,6 +664,7 @@ export class WorkspaceGoalService { if (message.metadata?.muxMetadata?.type === "goal-pause-boundary") { const boundaryGoalId = message.metadata.muxMetadata.goalId; if (currentGoalId != null && boundaryGoalId != null && boundaryGoalId !== currentGoalId) { + crossedOtherGoalHistory = true; // Codex P2 (PRRT_kwDOPxxmWM6cEl4F, PRRT_kwDOPxxmWM6cGSPK): a stale // pause finalizer's boundary can land AFTER a replacement goal (and // even after that goal's own manual rows). A mismatched goal-scoped @@ -2461,6 +2483,7 @@ export class WorkspaceGoalService { // stream-end drain for user aborts). const pendingMutation: PendingGoalMutation = { objective, + streamStartGeneration: this.streamStartGenerations.get(input.workspaceId) ?? 0, ...(Object.hasOwn(input, "budgetCents") ? { budgetCents: input.budgetCents ?? null } : {}), @@ -3221,6 +3244,35 @@ export class WorkspaceGoalService { }); } + /** + * Durably suppress the autonomous budget wrap-up after a post-limit manual + * user message. Deleting the in-memory candidate is not enough: after a + * restart, `recoverPendingDispatchAfterRestart` sees a goal-attributable + * `budgetLimitOriginKind` with `budgetLimitInjectedForGoalId === null` and + * re-synthesizes the wrap-up despite the user's intervening message (Codex + * P2 PRRT_kwDOPxxmWM6cJ6NM). Re-stamping the origin as `"user"` reuses the + * existing durable suppression the recovery path already honors. + */ + async suppressBudgetWrapupForManualUserMessage(workspaceId: string): Promise { + assert( + workspaceId.trim().length > 0, + "suppressBudgetWrapupForManualUserMessage requires workspaceId" + ); + return this.fileLocks.withLock(workspaceId, async () => { + const current = await this.readGoalFile(workspaceId); + if (current?.status !== "budget_limited" || current.budgetLimitOriginKind === "user") { + return; + } + const next = GoalRecordV1Schema.parse({ + ...current, + budgetLimitOriginKind: "user", + updatedAtMs: Date.now(), + }); + await this.writeGoal(workspaceId, next); + await this.pushSnapshot(workspaceId, next); + }); + } + async requireUserAcknowledgmentForCrashRecovery( workspaceId: string, sinceMs = Date.now() @@ -3680,10 +3732,29 @@ export class WorkspaceGoalService { // the claim. return null; } + // Codex P1 (PRRT_kwDOPxxmWM6cJ6M-): claims are stream-scoped. A + // mutation stamped by a NEWER stream (an automatic retry that + // started while this un-awaited provider-error drain was still + // looping) belongs to that stream's own stream-end drain — claiming + // it here would persist/archive the goal before the retry's + // accounting and strip a later user abort of its discard window. + // Legacy unstamped mutations (none in practice — installs always + // stamp) fall back to claimable. + if ( + (claimed.streamStartGeneration ?? streamStartGenerationAtEntry) !== + streamStartGenerationAtEntry + ) { + return null; + } claimedMutation = true; this.pendingGoalMutations.delete(workspaceId); this.pendingGoalSnapshots.delete(workspaceId); - const { projectedGoalId, projectedCreatedAtMs, ...pendingInput } = claimed; + const { + projectedGoalId, + projectedCreatedAtMs, + streamStartGeneration: _claimedGeneration, + ...pendingInput + } = claimed; const input = { workspaceId, ...pendingInput }; const result = await this.persistGoalMutationLocked(input, { replacementGoalId: projectedGoalId ?? null, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 23c0bd696fe..8815db90e77 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -3850,6 +3850,64 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { return { aiService, config, historyService, workspaceService, goalService, cleanup }; } + test("requireIdle sends carry a live idle-admission probe re-evaluated at session gates", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cJ6NI): the preflight count check at + // sendMessage entry is a one-shot snapshot — a manual send can enter + // preflight during the later admission awaits, before the continuation + // makes the session busy. The forwarded admissionStale probe must sample + // the LIVE preflight count so AgentSession's admission gates (re-evaluated + // up to the last gate before the pre-turn batch becomes irrevocable) can + // refuse the continuation. + const { config, workspaceService, cleanup } = await createServices(); + const workspaceId = "require-idle-admission-probe"; + const internalAccess = workspaceService as unknown as { + sessions: Map; + preflightSendCounts: Map; + }; + try { + await config.addWorkspace("/tmp/require-idle-probe-project", { + id: workspaceId, + name: workspaceId, + projectName: "require-idle-probe-project", + projectPath: "/tmp/require-idle-probe-project", + runtimeConfig: { type: "local" }, + }); + let capturedProbe: (() => boolean) | undefined; + const fakeSession = { + isBusy: mock(() => false), + emitMetadata: mock(() => undefined), + sendMessage: mock( + (_msg: string, _opts: unknown, internal?: { admissionStale?: () => boolean }) => { + capturedProbe = internal?.admissionStale; + return Promise.resolve(Ok(undefined)); + } + ), + } as unknown as AgentSession; + internalAccess.sessions.set(workspaceId, fakeSession); + + const result = await workspaceService.sendMessage( + workspaceId, + "Continue working on the goal.", + { model: "openai:gpt-4o", agentId: "exec" }, + { synthetic: true, agentInitiated: true, requireIdle: true, goalContinuation: true } + ); + expect(result.success).toBe(true); + expect(typeof capturedProbe).toBe("function"); + + // Live sampling: idle (only the continuation itself would hold a slot). + expect(capturedProbe?.()).toBe(false); + // A manual send entering preflight while the continuation is still in + // its admission awaits (continuation slot + manual slot) flips the + // probe stale — even though the entry snapshot passed. + internalAccess.preflightSendCounts.set(workspaceId, 2); + expect(capturedProbe?.()).toBe(true); + internalAccess.preflightSendCounts.delete(workspaceId); + } finally { + internalAccess.sessions.delete(workspaceId); + await cleanup(); + } + }); + test("idle wait follows auto-retry startup into the resumed stream", async () => { const { workspaceService, cleanup } = await createServices(); const workspaceId = "idle-wait-auto-retry-starting"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 0adcfc6cced..9184dcf1230 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -10865,6 +10865,24 @@ export class WorkspaceService extends EventEmitter { }); } + // Codex P1 (PRRT_kwDOPxxmWM6cJ6NI): the count check above is a one-shot + // snapshot — a manual send can enter preflight during the awaits between + // here and the session reporting busy (markInterruptedTaskRunning, the + // admission awaits inside AgentSession.sendMessage). Compose the + // caller's staleness probe with a live preflight re-check so + // AgentSession's admission gates (including the last gate before the + // pre-turn batch becomes irrevocable) re-validate idleness; refusal + // rolls back the synthetic row and idle-only callers retry. + if (internal?.requireIdle) { + const callerAdmissionStale = internal.admissionStale; + internal = { + ...internal, + admissionStale: () => + callerAdmissionStale?.() === true || + (this.preflightSendCounts.get(workspaceId) ?? 0) > 1, + }; + } + if (shouldQueue) { // Everything from here to queueMessage is synchronous, so a probe pass here cannot go // stale before the entry is enqueued. From cc07fc025f3870f4807c709d5634028a435c3553 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 17:11:58 +0000 Subject: [PATCH 26/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2023=20?= =?UTF-8?q?=E2=80=94=20neutralize=20live=20wrap-up=20stamp=20on=20manual?= =?UTF-8?q?=20suppression;=20deterministic=20lock-admission=20wait=20in=20?= =?UTF-8?q?stop=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/workspaceGoalService.test.ts | 47 ++++++++++++++++++- src/node/services/workspaceGoalService.ts | 17 ++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 0df2e877425..4c842297d96 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2692,6 +2692,17 @@ describe("WorkspaceGoalService", () => { const serviceAccess = service as unknown as { fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; }; + // Codex P2 (PRRT_kwDOPxxmWM6cKkGV): count lock admissions instead of + // sleeping — on a loaded worker a fixed delay cannot guarantee the setter + // captured the pre-stop generation and queued behind the gate before the + // stop runs; the stop would then come first and the setter would + // legitimately persist, failing the test despite correct behavior. + let lockCalls = 0; + const originalWithLock = serviceAccess.fileLocks.withLock.bind(serviceAccess.fileLocks); + serviceAccess.fileLocks.withLock = (key: string, fn: () => Promise): Promise => { + lockCalls += 1; + return originalWithLock(key, fn); + }; let releaseGate!: () => void; const gate = new Promise((resolve) => { releaseGate = resolve; @@ -2701,7 +2712,8 @@ describe("WorkspaceGoalService", () => { // Direct path (no live stream): the setter passes its pre-lock stop check // and queues behind the gate. const setterPromise = service.setGoal({ workspaceId, objective: "Aborted direct goal" }); - await new Promise((resolve) => setTimeout(resolve, 25)); + // Deterministic admission signal: gate (1), setter's persistence tenure (2). + await waitForCondition(() => lockCalls >= 2, { timeoutMs: 5_000 }); // The stop bumps the stop generation synchronously; its own locked section // queues behind the setter's tenure. const stopPromise = service.recordUserStoppedStream(workspaceId); @@ -2999,6 +3011,39 @@ describe("WorkspaceGoalService", () => { budgetLimitOriginKind: "user", }); + // Codex P2 (PRRT_kwDOPxxmWM6cKkGL): the suppression must also neutralize + // the LIVE eligibility state — without touching the in-memory stream + // stamp, the manual turn's accounting preserves the goal-attributable + // stamp and its stream-end arms a candidate that dispatches the wrap-up + // immediately, no restart required. + const liveDispatcher = new IdleDispatcher(); + const liveExecuted: Array<{ kind: string | undefined }> = []; + service.registerGoalContinuationConsumer(liveDispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + liveExecuted.push({ kind: input.kind }); + return Promise.resolve(true); + }, + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + // The manual turn's own accounting preserves the existing budget_limited + // stamp rather than overwriting it; its stream-end then requests a + // continuation. + await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.01, + streamStartedAtMs: created.createdAtMs + 2, + streamOriginKind: "user", + }); + await service.requestContinuationAfterStreamEnd({ + workspaceId, + sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + streamEndedAtMs: created.createdAtMs + 3, + }); + await drainPendingDispatches(); + expect(liveExecuted).toHaveLength(0); + // Simulate restart: recovery must honor the durable suppression. const restartedService = new WorkspaceGoalService( config, diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 4c7e5b9275e..c3fe2c1f0e2 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -3260,7 +3260,22 @@ export class WorkspaceGoalService { ); return this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); - if (current?.status !== "budget_limited" || current.budgetLimitOriginKind === "user") { + if (current?.status !== "budget_limited") { + return; + } + // Codex P2 (PRRT_kwDOPxxmWM6cKkGL): the durable stamp below only + // protects restarts. The LIVE stream stamp stays goal-attributable + // through the manual turn's own accounting (recordStreamAccounting + // preserves budget_limited stamps), so the manual turn's stream-end + // would arm a fresh candidate that wrap-up eligibility accepts — + // dispatching the autonomous wrap-up right after the user's + // intervention. Re-mark the live stamp user-origin so eligibility + // rejects it in-process too. + const liveStamp = this.lastGoalStreamStamps.get(workspaceId); + if (liveStamp?.goalId === current.goalId && liveStamp.originKind !== "user") { + this.lastGoalStreamStamps.set(workspaceId, { ...liveStamp, originKind: "user" }); + } + if (current.budgetLimitOriginKind === "user") { return; } const next = GoalRecordV1Schema.parse({ From f9d1a02a239b53cbf8cda42e9632bcbbe27a74fa Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 17:45:15 +0000 Subject: [PATCH 27/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2024=20?= =?UTF-8?q?=E2=80=94=20stream-scoped=20drain-generation=20staleness;=20dur?= =?UTF-8?q?able-first=20wrap-up=20suppression=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentSession.ts | 12 +- .../services/workspaceGoalService.test.ts | 121 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 91 ++++++++++--- 3 files changed, 207 insertions(+), 17 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 30a6a4b0bea..109b273f0e6 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1684,7 +1684,17 @@ export class AgentSession { // Codex P2 (PRRT_kwDOPxxmWM6cJ6NM): the candidate delete above is // in-memory only — persist the suppression so a restart cannot // re-synthesize the autonomous wrap-up over the user's intervention. - await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId); + try { + await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId); + } catch (error) { + // A transient write failure must not break the user's manual send; + // suppression fails closed (durable-first, so no in-memory state was + // published) and the next manual message retries it. + log.warn("Failed to persist budget wrap-up suppression", { + workspaceId: this.workspaceId, + error: getErrorMessage(error), + }); + } } if (goal?.status !== "active") { return; diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 4c842297d96..8c0c8985a41 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2815,6 +2815,77 @@ describe("WorkspaceGoalService", () => { expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); }); + test("an old drain's exit does not force a retry stream's setter onto direct persistence", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cLA0R): the drain-generation staleness check + // was workspace-global. A setter in retry stream B that captured the + // generation, awaited its streaming read, and then observed the OLD + // stream-A drain's exit bump fell through to direct persistence while B + // was live — the replacement predated B's accounting and escaped a later + // Stop's discard window. Drain bumps are now stream-scoped: an older + // stream's bump leaves the setter on the deferral path, and its queued + // mutation is claimed by B's own drain. + await extensionMetadata.setStreaming(workspaceId, true); + const queued = await service.setGoal({ workspaceId, objective: "Queued mid-error" }); + expect(queued.success).toBe(true); + + const serviceAccess = service as unknown as { + isWorkspaceStreaming: (id: string) => Promise; + pendingGoalMutations: Map; + fileLocks: { withLock: (key: string, fn: () => Promise) => Promise }; + }; + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const gateTenure = serviceAccess.fileLocks.withLock(workspaceId, () => gate); + + // Old drain D_A (settling stream A) blocks at its locked claim; the retry + // stream B then starts. + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + service.recordStreamStarted(workspaceId); + + // Hold the setter at its pre-lock streaming read (one-shot gate) so D_A's + // exit bump lands while the setter is in flight — Codex's interleaving. + const realIsStreaming = serviceAccess.isWorkspaceStreaming.bind(service); + let releaseSetterRead!: () => void; + const readGate = new Promise((resolve) => { + releaseSetterRead = resolve; + }); + let pendingReadGate: Promise | null = readGate; + serviceAccess.isWorkspaceStreaming = async (id: string): Promise => { + const hold = pendingReadGate; + pendingReadGate = null; + if (hold) { + await hold; + } + return realIsStreaming(id); + }; + // Captures the pre-exit drain generation synchronously, then parks at the + // gated streaming read. + const setterPromise = service.setGoal({ workspaceId, objective: "Retry-stream replacement" }); + + // D_A completes: claims its own stream's mutation, then exits (bumping the + // drain generation FOR STREAM A) while the setter is still parked. + releaseGate(); + await gateTenure; + await drainPromise; + expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Queued mid-error" }); + + // The setter resumes, sees the bump — but it came from stream A, not B: + // it must defer, not persist directly. + releaseSetterRead(); + const setter = await setterPromise; + expect(setter.success).toBe(true); + expect(serviceAccess.pendingGoalMutations.get(workspaceId)?.objective).toBe( + "Retry-stream replacement" + ); + expect(await service.getGoal(workspaceId)).toMatchObject({ objective: "Queued mid-error" }); + + // B's own drain claims the deferred replacement. + const drainedByB = await service.applyPendingAfterStreamEnd(workspaceId); + expect(drainedByB).toMatchObject({ objective: "Retry-stream replacement" }); + }); + test("pause boundaries for a replaced goal do not reconcile the newer goal to paused", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cEl4F): a stale pause finalizer's boundary // append awaits history I/O after its identity check, so the row can land @@ -3073,6 +3144,56 @@ describe("WorkspaceGoalService", () => { }); }); + test("a failed suppression write publishes no in-memory suppression", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cLA0M): the durable origin must persist + // BEFORE the live stamp updates. If the write fails (disk full, transient + // fs error), no in-memory suppression may exist — otherwise this process + // suppresses the wrap-up while goal.json stays goal-attributable, and a + // restart re-arms the autonomous wrap-up despite the manual intervention. + const created = await setGoalOk(service, { + workspaceId, + objective: "Suppression write fails", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + const serviceAccess = service as unknown as { + writeGoal: (id: string, goal: GoalRecordV1) => Promise; + lastGoalStreamStamps: Map; + }; + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe( + "goal_continuation" + ); + + const writeSpy = spyOn(serviceAccess, "writeGoal").mockImplementationOnce(() => + Promise.reject(new Error("injected: suppression write lost")) + ); + let threw = false; + try { + await service.suppressBudgetWrapupForManualUserMessage(workspaceId); + } catch { + threw = true; + } + writeSpy.mockRestore(); + expect(threw).toBe(true); + + // Fail closed: memory never got ahead of disk. + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe( + "goal_continuation" + ); + expect(await service.getGoal(workspaceId)).toMatchObject({ status: "budget_limited" }); + expect((await service.getGoal(workspaceId))?.budgetLimitOriginKind).not.toBe("user"); + + // A retry (next manual message) succeeds and completes both halves. + await service.suppressBudgetWrapupForManualUserMessage(workspaceId); + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe("user"); + expect(await service.getGoal(workspaceId)).toMatchObject({ budgetLimitOriginKind: "user" }); + }); + test("getGoal during pause finalization does not reactivate the goal being paused", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cIyKW): between the durable pause write and // the finalizer appending the goal-pause-boundary, the tail still ends at diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index c3fe2c1f0e2..8b1271a689b 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -472,6 +472,17 @@ export class WorkspaceGoalService { * PRRT_kwDOPxxmWM6cBr9Q). */ private readonly streamEndDrainGenerations = new Map(); + /** + * Highest stream-start generation any drain bump above was acting for + * (recorded with max semantics at both drain entry and exit). A drain bump + * only forces an in-flight setter onto the direct-persistence path when the + * bumping drain was settling the setter's own stream (or a newer one): an + * old un-awaited error drain exiting while a retry stream is live must not + * push the retry's setters past the deferral — the mutation they queue is + * stamped with the retry's generation and claimed by the retry's own drain + * (Codex P1 PRRT_kwDOPxxmWM6cLA0R). + */ + private readonly lastDrainStreamStartGenerations = new Map(); /** * Workspaces whose last stream has ended and fully drained (or was user * stopped, which skips the drain). The extension-metadata streaming flag @@ -2357,6 +2368,14 @@ export class WorkspaceGoalService { // hold a stale "live" long enough for a setter to queue a mutation the // drain has already stopped watching for. const drainGenerationAtEntry = this.streamEndDrainGenerations.get(input.workspaceId) ?? 0; + // Codex P1 (PRRT_kwDOPxxmWM6cLA0R): captured synchronously alongside the + // drain generation so the in-lock rechecks can tell whether a later drain + // bump came from a drain settling THIS setter's stream (stale → persist + // directly) or from an older stream's un-awaited error drain exiting while + // the setter's stream is live (queue normally — that stream's own drain + // claims the stamped mutation). + const setterStreamStartGenerationAtEntry = + this.streamStartGenerations.get(input.workspaceId) ?? 0; // Codex P1 (PRRT_kwDOPxxmWM6cCH_H): also captured synchronously at entry. // A user stop landing while this setter is in flight means the stopped // turn's goal change must be discarded — recordUserStoppedStream deletes @@ -2399,7 +2418,11 @@ export class WorkspaceGoalService { if ( !(await this.isWorkspaceStreaming(input.workspaceId)) || this.drainSettledWorkspaces.has(input.workspaceId) || - (this.streamEndDrainGenerations.get(input.workspaceId) ?? 0) !== drainGenerationAtEntry + this.drainRanForRelevantStreamSince( + input.workspaceId, + drainGenerationAtEntry, + setterStreamStartGenerationAtEntry + ) ) { // The stream can end while this caller waits for the goal file lock. // Persist immediately instead of queueing after stream-end already @@ -2465,7 +2488,11 @@ export class WorkspaceGoalService { if ( !(await this.isWorkspaceStreaming(input.workspaceId)) || this.drainSettledWorkspaces.has(input.workspaceId) || - (this.streamEndDrainGenerations.get(input.workspaceId) ?? 0) !== drainGenerationAtEntry + this.drainRanForRelevantStreamSince( + input.workspaceId, + drainGenerationAtEntry, + setterStreamStartGenerationAtEntry + ) ) { // Avoid queueing after the one stream-end drain has already observed no // pending mutation (stale-streaming reads included — see the @@ -3263,7 +3290,22 @@ export class WorkspaceGoalService { if (current?.status !== "budget_limited") { return; } - // Codex P2 (PRRT_kwDOPxxmWM6cKkGL): the durable stamp below only + // Codex P2 (PRRT_kwDOPxxmWM6cLA0M): persist the durable origin FIRST. + // Publishing the in-memory suppression before the write would let a + // failed write leave this process suppressing the wrap-up while + // goal.json stays goal-attributable — after a restart, recovery would + // re-arm the autonomous wrap-up despite the manual intervention. + // Memory only updates after the durable state it mirrors exists. + if (current.budgetLimitOriginKind !== "user") { + const next = GoalRecordV1Schema.parse({ + ...current, + budgetLimitOriginKind: "user", + updatedAtMs: Date.now(), + }); + await this.writeGoal(workspaceId, next); + await this.pushSnapshot(workspaceId, next); + } + // Codex P2 (PRRT_kwDOPxxmWM6cKkGL): the durable stamp above only // protects restarts. The LIVE stream stamp stays goal-attributable // through the manual turn's own accounting (recordStreamAccounting // preserves budget_limited stamps), so the manual turn's stream-end @@ -3275,16 +3317,6 @@ export class WorkspaceGoalService { if (liveStamp?.goalId === current.goalId && liveStamp.originKind !== "user") { this.lastGoalStreamStamps.set(workspaceId, { ...liveStamp, originKind: "user" }); } - if (current.budgetLimitOriginKind === "user") { - return; - } - const next = GoalRecordV1Schema.parse({ - ...current, - budgetLimitOriginKind: "user", - updatedAtMs: Date.now(), - }); - await this.writeGoal(workspaceId, next); - await this.pushSnapshot(workspaceId, next); }); } @@ -3677,11 +3709,38 @@ export class WorkspaceGoalService { } } - private bumpStreamEndDrainGeneration(workspaceId: string): void { + private bumpStreamEndDrainGeneration(workspaceId: string, streamStartGeneration: number): void { this.streamEndDrainGenerations.set( workspaceId, (this.streamEndDrainGenerations.get(workspaceId) ?? 0) + 1 ); + // Max semantics: concurrent drains can exit out of order, and a newer + // drain's entry must not be masked by an older drain's later exit. + this.lastDrainStreamStartGenerations.set( + workspaceId, + Math.max(this.lastDrainStreamStartGenerations.get(workspaceId) ?? 0, streamStartGeneration) + ); + } + + /** + * True when a stream-end drain started or exited since the caller captured + * `drainGenerationAtEntry` AND that drain was settling the caller's own + * stream or a newer one. Bumps from drains settling OLDER streams are + * ignored: the caller's stream is still live, so a queued mutation (stamped + * with the caller's stream-start generation) will be claimed by that + * stream's own drain (Codex P1 PRRT_kwDOPxxmWM6cLA0R). + */ + private drainRanForRelevantStreamSince( + workspaceId: string, + drainGenerationAtEntry: number, + streamStartGenerationAtEntry: number + ): boolean { + if ((this.streamEndDrainGenerations.get(workspaceId) ?? 0) === drainGenerationAtEntry) { + return false; + } + return ( + (this.lastDrainStreamStartGenerations.get(workspaceId) ?? 0) >= streamStartGenerationAtEntry + ); } async applyPendingAfterStreamEnd(workspaceId: string): Promise { @@ -3695,7 +3754,7 @@ export class WorkspaceGoalService { // setters admitted BEFORE this drain detect it at their in-lock recheck // and persist directly instead of installing a mutation this drain may // already have stopped watching for. - this.bumpStreamEndDrainGeneration(workspaceId); + this.bumpStreamEndDrainGeneration(workspaceId, streamStartGenerationAtEntry); let drained: GoalRecordV1 | null = null; // Codex P2 (PRRT_kwDOPxxmWM6b_KgE): a queued setGoal may be holding the @@ -3816,7 +3875,7 @@ export class WorkspaceGoalService { // admitted DURING this drain whose in-lock recheck runs after that final // check therefore sees a changed generation and persists directly; one // whose recheck ran earlier installed a mutation the loop drained. - this.bumpStreamEndDrainGeneration(workspaceId); + this.bumpStreamEndDrainGeneration(workspaceId, streamStartGenerationAtEntry); // Codex P2 (PRRT_kwDOPxxmWM6cCH_L): setters admitted AFTER this point // capture the already-bumped generation, so the generation gate cannot // help them — mark the workspace settled (also synchronously with the From 765b817ee23ee515781735a4bac4fefa1c23c520 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 18:08:04 +0000 Subject: [PATCH 28/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2025=20?= =?UTF-8?q?=E2=80=94=20goal-scoped=20wrap-up=20suppression,=20post-write?= =?UTF-8?q?=20stop=20restore,=20Resume=20survives=20stale=20pause=20bounda?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentSession.ts | 5 +- .../services/workspaceGoalService.test.ts | 114 +++++++++++++++++- src/node/services/workspaceGoalService.ts | 60 ++++++++- 3 files changed, 172 insertions(+), 7 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 109b273f0e6..afda39abcb7 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1685,7 +1685,10 @@ export class AgentSession { // in-memory only — persist the suppression so a restart cannot // re-synthesize the autonomous wrap-up over the user's intervention. try { - await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId); + // Scoped to the acknowledged goal's identity: a replacement goal that + // persisted during the acknowledgment await must not be suppressed by + // a message that predates it (Codex P2 PRRT_kwDOPxxmWM6cLpID). + await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId, goal.goalId); } catch (error) { // A transient write failure must not break the user's manual send; // suppression fails closed (durable-first, so no in-memory state was diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 8c0c8985a41..39c6c8b291a 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3075,8 +3075,13 @@ describe("WorkspaceGoalService", () => { budgetLimitInjectedForGoalId: null, }); + // Codex P2 (PRRT_kwDOPxxmWM6cLpID): suppression is scoped to the goal the + // manual message acknowledged — a different goal's identity is a no-op. + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, "goal-someone-else"); + expect((await service.getGoal(workspaceId))?.budgetLimitOriginKind).not.toBe("user"); + // The manual-message hook persists the suppression. - await service.suppressBudgetWrapupForManualUserMessage(workspaceId); + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); expect(await service.getGoal(workspaceId)).toMatchObject({ status: "budget_limited", budgetLimitOriginKind: "user", @@ -3174,7 +3179,7 @@ describe("WorkspaceGoalService", () => { ); let threw = false; try { - await service.suppressBudgetWrapupForManualUserMessage(workspaceId); + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); } catch { threw = true; } @@ -3189,11 +3194,114 @@ describe("WorkspaceGoalService", () => { expect((await service.getGoal(workspaceId))?.budgetLimitOriginKind).not.toBe("user"); // A retry (next manual message) succeeds and completes both halves. - await service.suppressBudgetWrapupForManualUserMessage(workspaceId); + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe("user"); expect(await service.getGoal(workspaceId)).toMatchObject({ budgetLimitOriginKind: "user" }); }); + test("a stop landing inside the goal write restores the prior record", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cLpIP): a model complete_goal takes the + // direct mutable branch during the live stream. The final pre-write stop + // check can pass, then the Stop lands while writeGoal is replacing + // goal.json; the stop's locked section queues behind the setter's tenure + // and, seeing an already-complete goal, neither discards nor gates it. + // The setter must recheck after the write and restore the prior record + // before releasing the lock. + const created = await setGoalOk(service, { workspaceId, objective: "Abort mid-write" }); + await extensionMetadata.setStreaming(workspaceId, true); + + const serviceAccess = service as unknown as { + writeGoal: (id: string, goal: GoalRecordV1) => Promise; + }; + const realWrite = serviceAccess.writeGoal.bind(service); + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + let writeStarted = false; + const writeSpy = spyOn(serviceAccess, "writeGoal").mockImplementationOnce( + async (id: string, goal: GoalRecordV1) => { + writeStarted = true; + await writeGate; + return realWrite(id, goal); + } + ); + + const completePromise = service.setGoal({ + workspaceId, + status: "complete", + completionSummary: "Done before the user could stop it", + initiator: "model", + }); + await waitForCondition(() => writeStarted, { timeoutMs: 5_000 }); + // Stop lands mid-write: generation bumps synchronously, locked section queues. + const stopPromise = service.recordUserStoppedStream(workspaceId); + releaseWrite(); + + const completed = await completePromise; + expect(completed.success).toBe(false); + if (!completed.success) { + expect(completed.error.type).toBe("invalid_transition"); + } + await stopPromise; + writeSpy.mockRestore(); + + // Prior record restored; the stop's queued section then gated it normally. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "active", + }); + expect(typeof (await service.getGoal(workspaceId))?.requireUserAcknowledgmentSinceMs).toBe( + "number" + ); + }); + + test("an explicit Resume during pause finalization survives the stale boundary", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cLpIT): a same-goal Resume can durably set the + // goal active while the old pause finalizer is appending its boundary. + // The boundary matches the goalId, so the finalizer's post-append + // chat-tail sync (and any later reconciliation) would write the goal back + // to paused — undoing the Resume. Durable-active + own scoped boundary + // proves the Resume postdated the pause; it must win. + const created = await setGoalOk(service, { workspaceId, objective: "Resume during pause" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + + // Gate the finalizer's boundary append; Resume lands inside the window. + const realAppend = historyService.appendToHistory.bind(historyService); + let releaseAppend!: () => void; + const appendGate = new Promise((resolve) => { + releaseAppend = resolve; + }); + let appendStarted = false; + const appendSpy = spyOn(historyService, "appendToHistory").mockImplementationOnce( + async (id, message) => { + appendStarted = true; + await appendGate; + return realAppend(id, message); + } + ); + + const pausePromise = service.setGoal({ workspaceId, status: "paused" }); + await waitForCondition(() => appendStarted, { timeoutMs: 5_000 }); + const resumed = await service.setGoal({ workspaceId, status: "active" }); + expect(resumed.success).toBe(true); + releaseAppend(); + await pausePromise; + appendSpy.mockRestore(); + + // The stale boundary sits at the tail, but the Resume wins — both in the + // finalizer's own post-append sync and in later reconciliations. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "active", + }); + }); + test("getGoal during pause finalization does not reactivate the goal being paused", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cIyKW): between the durable pause write and // the finalizer appending the goal-pause-boundary, the tail still ends at diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 8b1271a689b..ee6419631e8 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -235,6 +235,15 @@ interface ChatTailGoalModeResult { * freshly armed kickoff (see `applyChatTailGoalMode`). */ pausedBy?: "pause_boundary" | "manual_user"; + /** + * When `pausedBy === "pause_boundary"`: true when the boundary row carried a + * goalId matching the reconciled goal. Scoped boundaries are only appended + * AFTER their pause persisted durably, so a durably ACTIVE goal beneath a + * matching scoped boundary proves a later Resume — reconciliation must not + * let the stale boundary undo it. Legacy unscoped boundaries keep the old + * any-goal semantics (Codex P2 PRRT_kwDOPxxmWM6cLpIT). + */ + boundaryGoalScoped?: boolean; /** * When `pausedBy === "manual_user"`: the moment the user authored the pausing * row — its persisted enqueue time (queued sends) or the row timestamp. @@ -686,7 +695,13 @@ export class WorkspaceGoalService { // old any-goal semantics. continue; } - return { mode: "paused", pausedBy: "pause_boundary" }; + return { + mode: "paused", + pausedBy: "pause_boundary", + ...(boundaryGoalId != null && boundaryGoalId === currentGoalId + ? { boundaryGoalScoped: true } + : {}), + }; } if (message.metadata?.synthetic === true) { continue; @@ -770,6 +785,22 @@ export class WorkspaceGoalService { } } + // Codex P2 (PRRT_kwDOPxxmWM6cLpIT): every pause path persists the durable + // paused status BEFORE its finalizer appends the goal-scoped boundary, so + // a durably ACTIVE goal beneath its own scoped boundary proves an explicit + // Resume postdated the pause (the finalizer's append raced the Resume) or + // a crash interrupted the resumed goal's kickoff window. Either way the + // Resume is the newer user intent — the stale boundary must not write the + // goal back to paused. Legacy unscoped boundaries keep any-goal semantics. + if ( + goal.status === "active" && + chatTailMode.mode === "paused" && + chatTailMode.pausedBy === "pause_boundary" && + chatTailMode.boundaryGoalScoped === true + ) { + return goal; + } + // Codex P1 (PRRT_kwDOPxxmWM6cIyKW): between a durable pause write and its // finalizer appending the goal-pause-boundary, the tail still ends at this // goal's own continuation row. Reactivating here would make the @@ -2800,6 +2831,21 @@ export class WorkspaceGoalService { }); } await this.writeGoal(input.workspaceId, updated); + // Codex P1 (PRRT_kwDOPxxmWM6cLpIP): the write itself yields. A model + // complete_goal takes this direct branch during the live stream; a + // Stop landing inside writeGoal advances the generation + // synchronously but its locked section queues behind this tenure — + // and for an already-complete goal it neither discards nor installs + // an acknowledgment gate. Recheck after the write and restore the + // prior record before releasing the lock so the aborted turn's + // mutation never survives (the stop's queued section then gates the + // restored record normally). + const stoppedDuringMutableWrite = discardIfUserStopLanded(); + if (stoppedDuringMutableWrite) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + return stoppedDuringMutableWrite; + } await this.pushSnapshot(input.workspaceId, updated); await this.pushLiveGoalPreviewOverlay(input.workspaceId, updated); this.emitBudgetChanged(current, updated, input); @@ -3280,14 +3326,22 @@ export class WorkspaceGoalService { * P2 PRRT_kwDOPxxmWM6cJ6NM). Re-stamping the origin as `"user"` reuses the * existing durable suppression the recovery path already honors. */ - async suppressBudgetWrapupForManualUserMessage(workspaceId: string): Promise { + async suppressBudgetWrapupForManualUserMessage( + workspaceId: string, + goalId: string + ): Promise { assert( workspaceId.trim().length > 0, "suppressBudgetWrapupForManualUserMessage requires workspaceId" ); + assert(goalId.trim().length > 0, "suppressBudgetWrapupForManualUserMessage requires goalId"); return this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); - if (current?.status !== "budget_limited") { + // Codex P2 (PRRT_kwDOPxxmWM6cLpID): scope the suppression to the goal + // the manual message actually acknowledged. A replacement goal that + // persisted while the acknowledgment await unwound owes its own wrap-up + // — a pre-replacement message cannot have been intervening against it. + if (current?.status !== "budget_limited" || current.goalId !== goalId) { return; } // Codex P2 (PRRT_kwDOPxxmWM6cLA0M): persist the durable origin FIRST. From 7208ef933dc14b9ee4b887beb9743165d484e578 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 18:36:13 +0000 Subject: [PATCH 29/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2026=20?= =?UTF-8?q?=E2=80=94=20stream-end=20drain=20carries=20the=20user-stop=20ga?= =?UTF-8?q?te=20through=20claimed-mutation=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Stop landing while the drain's locked persistence awaited I/O could no longer invalidate the mutation (the claim already removed the pending-map entry), so the stopped turn's goal change was durably archived/written and the Stop merely acknowledgment-gated it. The claim now captures the stop generation synchronously and carries it through persistGoalMutationLocked and finalizeGoalPersistence; the creation/replacement branch also rechecks after its write and restores (or removes) the prior record, mirroring the mutable-branch post-write restore. --- .../services/workspaceGoalService.test.ts | 58 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 39 ++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 39c6c8b291a..f5c18b905ac 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3256,6 +3256,64 @@ describe("WorkspaceGoalService", () => { ); }); + test("a stop landing during the drain's persistence discards the claimed mutation", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cMGn8): recordUserStoppedStream invalidates a + // queued mutation by deleting the pending-map entry, but the stream-end + // drain claims (removes) it before persisting. A Stop landing while the + // drain's locked persistence awaits I/O then has nothing left to delete — + // the drain would durably archive/write the stopped turn's goal change and + // the Stop merely acknowledgment-gates it. The claim must carry the stop + // generation through persistence so the write is discarded and the prior + // record survives. + const original = await setGoalOk(service, { workspaceId, objective: "Original goal" }); + await extensionMetadata.setStreaming(workspaceId, true); + const queued = await service.setGoal({ workspaceId, objective: "Replacement mid-stream" }); + expect(queued.success).toBe(true); + + const serviceAccess = service as unknown as { + writeGoal: (id: string, goal: GoalRecordV1) => Promise; + pendingGoalMutations: Map; + }; + const realWrite = serviceAccess.writeGoal.bind(service); + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + let writeStarted = false; + const writeSpy = spyOn(serviceAccess, "writeGoal").mockImplementationOnce( + async (id: string, goal: GoalRecordV1) => { + writeStarted = true; + await writeGate; + return realWrite(id, goal); + } + ); + + // The drain claims the mutation and parks inside the replacement write. + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + await waitForCondition(() => writeStarted, { timeoutMs: 5_000 }); + // Stop lands mid-persistence: the pending-map entry is already claimed, so + // only the generation recheck inside the drain's tenure can discard it. + const stopPromise = service.recordUserStoppedStream(workspaceId); + releaseWrite(); + + const drained = await drainPromise; + expect(drained).toBeNull(); + await stopPromise; + writeSpy.mockRestore(); + + // The aborted turn's replacement never became durable; the stop's queued + // section gated the restored original instead. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: original.goalId, + objective: "Original goal", + status: "active", + }); + expect(typeof (await service.getGoal(workspaceId))?.requireUserAcknowledgmentSinceMs).toBe( + "number" + ); + expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); + }); + test("an explicit Resume during pause finalization survives the stale boundary", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cLpIT): a same-goal Resume can durably set the // goal active while the old pause finalizer is appending its boundary. diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index ee6419631e8..463a290318d 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2931,6 +2931,24 @@ export class WorkspaceGoalService { }); } await this.writeGoal(input.workspaceId, next); + // Codex P1 (PRRT_kwDOPxxmWM6cMGn8): the creation/replacement write + // itself yields — this is the stream-end drain's main path for a + // mid-stream set_goal. Mirror the mutable-branch post-write recheck + // (PRRT_kwDOPxxmWM6cLpIP): a Stop landing inside the write must not + // leave the aborted turn's goal durable. The archive entry above stays + // (cosmetic, same as a stop during the archive append); the durable + // record is restored — or removed when no goal existed before. + const stoppedDuringCreateWrite = discardIfUserStopLanded(); + if (stoppedDuringCreateWrite) { + if (current) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + } else { + await fs.rm(this.getFilePath(input.workspaceId), { force: true }); + await this.pushSnapshot(input.workspaceId, null); + } + return stoppedDuringCreateWrite; + } await this.pushSnapshot(input.workspaceId, next); this.emitBudgetChanged(current, next, input); this.emitLifecycle(current ? "goal_replaced" : "goal_created", { @@ -3877,6 +3895,18 @@ export class WorkspaceGoalService { claimedMutation = true; this.pendingGoalMutations.delete(workspaceId); this.pendingGoalSnapshots.delete(workspaceId); + // Codex P1 (PRRT_kwDOPxxmWM6cMGn8): recordUserStoppedStream discards + // a queued mutation by deleting the pending-map entry, but this + // claim just removed it — a Stop landing during the persistence + // awaits below would no longer have anything to invalidate and the + // drain would durably archive/write the stopped turn's goal change. + // Capture the stop generation synchronously with the claim (stops + // BEFORE the claim already deleted the entry, so nothing older can + // false-discard) and carry it through persistence + finalization so + // every await rechecks it, mirroring setGoalImmediately. + const userStopGate = { + generationAtEntry: this.userStopGenerationsByWorkspace.get(workspaceId) ?? 0, + }; const { projectedGoalId, projectedCreatedAtMs, @@ -3887,19 +3917,24 @@ export class WorkspaceGoalService { const result = await this.persistGoalMutationLocked(input, { replacementGoalId: projectedGoalId ?? null, replacementCreatedAtMs: projectedCreatedAtMs ?? null, + userStopGate, }); // Mirror setGoalImmediately: arm the pause-finalization hold under // the same lock tenure as the paused write. if (this.pauseFinalizationHoldApplies(input, result) && result.success) { this.armPauseFinalizationHold(workspaceId, result.data.goalId); } - return { input, result }; + return { input, result, userStopGate }; }); if (tenure == null) { break; } try { - const finalized = await this.finalizeGoalPersistence(tenure.input, tenure.result); + const finalized = await this.finalizeGoalPersistence(tenure.input, tenure.result, { + // A stop landing after the durable write but before finalization + // must veto kickoff arming here too (same rule as the setter). + userStopGate: tenure.userStopGate, + }); drained = finalized.success ? finalized.data : drained; } finally { if ( From 896c97cfa8e8faba4d8b55752b6b04f1486d4b38 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 18:53:30 +0000 Subject: [PATCH 30/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2026=20?= =?UTF-8?q?=E2=80=94=20explicit-pause=20generation=20gates=20stale=20pause?= =?UTF-8?q?d=E2=86=92active=20flips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admitted continuation's recordContinuationFired (and reconciliation's syncGoalStatusToChatTail) read chat-tail evidence outside the goal lock. An explicit Pause committing mid-finalization or fully between that read and the locked apply could be flipped back to active on stale 'tail says active' evidence — and the scoped boundary then made the Resume-wins rule preserve the automatic flip as if the user had resumed. Explicit pause admissions now bump a per-workspace generation (armed with the pause finalization hold); tail reads stamp the generation captured before their history I/O, and automatic paused→active writers refuse when a pause finalization is in flight or the generation moved since the evidence was read. Reconciliation pauses do not bump it, preserving the kickoff-window auto-flip recovery. --- .../services/workspaceGoalService.test.ts | 135 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 69 ++++++++- 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index f5c18b905ac..3b578672f60 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3412,6 +3412,141 @@ describe("WorkspaceGoalService", () => { }); }); + test("an admitted continuation firing mid-pause-finalization does not undo the pause", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cMQqq): a goal continuation already admitted + // (dispatch accepted) calls recordContinuationFired while an explicit + // Pause is finalizing. The tail still ends at the goal's own continuation + // row (boundary append in flight), so the paused→active acceptance would + // flip the goal back — and once the scoped boundary lands, the + // Resume-wins rule preserves that automatic flip as if the user resumed. + // The firing must honor the pause-finalization hold. + const created = await setGoalOk(service, { workspaceId, objective: "Pause vs continuation" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + + // Gate the finalizer's boundary append so the firing runs mid-window. + const realAppend = historyService.appendToHistory.bind(historyService); + let releaseAppend!: () => void; + const appendGate = new Promise((resolve) => { + releaseAppend = resolve; + }); + let appendStarted = false; + const appendSpy = spyOn(historyService, "appendToHistory").mockImplementationOnce( + async (id, message) => { + appendStarted = true; + await appendGate; + return realAppend(id, message); + } + ); + + const pausePromise = service.setGoal({ workspaceId, status: "paused" }); + await waitForCondition(() => appendStarted, { timeoutMs: 5_000 }); + + // Mid-window: the admitted continuation fires. Without the hold check it + // would flip the paused goal back to active on "tail says active" evidence. + const serviceAccess = service as unknown as { + recordContinuationFired: (id: string, goalId: string, firedAtMs: number) => Promise; + }; + await serviceAccess.recordContinuationFired(workspaceId, created.goalId, Date.now()); + + releaseAppend(); + const pauseResult = await pausePromise; + expect(pauseResult.success).toBe(true); + appendSpy.mockRestore(); + + // The Pause wins — durably and against later reconciliation. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + + test("a continuation fired against pre-pause tail evidence does not reactivate the goal", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cMQqq): recordContinuationFired reads the + // chat tail outside the goal lock. An explicit Pause can fully commit + // (durable write, scoped boundary append, hold release) between that read + // and the locked apply — the stale "tail says active" evidence must not + // flip the paused goal back to active, because the scoped boundary at the + // tail would then make the Resume-wins rule preserve the flip durably. + const created = await setGoalOk(service, { + workspaceId, + objective: "Stale-read continuation", + }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + + // One-shot: the real tail read completes first (pre-pause evidence), then + // the full explicit Pause commits before the caller's locked section. + const serviceAccess = service as unknown as { + readChatTailGoalMode: (id: string, goalId?: string | null) => Promise; + recordContinuationFired: (id: string, goalId: string, firedAtMs: number) => Promise; + }; + const realRead = serviceAccess.readChatTailGoalMode.bind(service); + serviceAccess.readChatTailGoalMode = async (id: string, goalId?: string | null) => { + const evidence = await realRead(id, goalId); + // Restore before pausing: the pause's own finalization reads the tail. + serviceAccess.readChatTailGoalMode = realRead; + const paused = await service.setGoal({ workspaceId, status: "paused" }); + expect(paused.success).toBe(true); + return evidence; + }; + + await serviceAccess.recordContinuationFired(workspaceId, created.goalId, Date.now()); + + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + + test("reconciliation with pre-pause tail evidence does not reactivate an explicitly paused goal", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cMQqq): syncGoalStatusToChatTail shares the + // unlocked-tail-read shape, and its in-flight hold check cannot see a + // Pause that fully committed between the read and the locked apply. The + // generation stamp must refuse the stale continuation-row evidence. + const created = await setGoalOk(service, { + workspaceId, + objective: "Stale-read reconciliation", + }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + + const serviceAccess = service as unknown as { + readChatTailGoalMode: (id: string, goalId?: string | null) => Promise; + }; + const realRead = serviceAccess.readChatTailGoalMode.bind(service); + serviceAccess.readChatTailGoalMode = async (id: string, goalId?: string | null) => { + const evidence = await realRead(id, goalId); + serviceAccess.readChatTailGoalMode = realRead; + const paused = await service.setGoal({ workspaceId, status: "paused" }); + expect(paused.success).toBe(true); + return evidence; + }; + + // A routine read (heartbeat / tool assembly) reconciles with the stale + // evidence — the explicit Pause must win, now and on the next clean read. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + test("cost previews reset when the goal becomes ineligible for accounting mid-stream", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cEl4P): a status transition during the stream // (pause, complete, or a budget edit flipping the goal budget_limited) diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 463a290318d..4b2df2f26c7 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -253,6 +253,15 @@ interface ChatTailGoalModeResult { * a crash). */ manualRowAuthoredAtMs?: number; + /** + * `explicitPauseGenerations` value captured BEFORE the history read that + * produced this evidence. Tail reads run outside the goal file lock, so an + * explicit Pause can fully commit (durable write, boundary append, hold + * release) between the read and the locked apply — "tail says active" + * evidence from before that Pause must not reactivate the paused goal + * (Codex P1 PRRT_kwDOPxxmWM6cMQqq). + */ + pauseGenerationAtRead: number; } interface GoalContinuationEligibilityResult { @@ -517,7 +526,24 @@ export class WorkspaceGoalService { */ private readonly pauseFinalizationHolds = new Map(); + /** + * Monotonic per-workspace count of explicit pause admissions, bumped under + * the goal file lock alongside each durable paused write (wherever the + * pause-finalization hold is armed). Chat-tail reads stamp the value they + * captured before their history I/O; automatic paused→active writers + * (`applyChatTailGoalMode`, `recordContinuationFired`) refuse when the + * generation moved since — the "tail says active" evidence predates an + * explicit Pause and must not undo it (Codex P1 PRRT_kwDOPxxmWM6cMQqq). + * Reconciliation pauses do not bump it, preserving the kickoff-window + * auto-flip recovery. + */ + private readonly explicitPauseGenerations = new Map(); + private armPauseFinalizationHold(workspaceId: string, goalId: string): void { + this.explicitPauseGenerations.set( + workspaceId, + (this.explicitPauseGenerations.get(workspaceId) ?? 0) + 1 + ); const existing = this.pauseFinalizationHolds.get(workspaceId); if (existing?.goalId === goalId) { existing.depth += 1; @@ -635,6 +661,18 @@ export class WorkspaceGoalService { workspaceId: string, currentGoalId?: string | null ): Promise { + // Codex P1 (PRRT_kwDOPxxmWM6cMQqq): capture the explicit-pause generation + // BEFORE the history I/O so consumers can tell whether an explicit Pause + // committed after this evidence was read (see field doc on the result). + const pauseGenerationAtRead = this.explicitPauseGenerations.get(workspaceId) ?? 0; + const evidence = await this.scanChatTailGoalMode(workspaceId, currentGoalId); + return { ...evidence, pauseGenerationAtRead }; + } + + private async scanChatTailGoalMode( + workspaceId: string, + currentGoalId?: string | null + ): Promise> { const historyResult = await this.historyService.getLastMessages(workspaceId, 100); if (!historyResult.success) { log.warn("Failed to read chat tail for goal mode reconciliation", { @@ -792,6 +830,11 @@ export class WorkspaceGoalService { // a crash interrupted the resumed goal's kickoff window. Either way the // Resume is the newer user intent — the stale boundary must not write the // goal back to paused. Legacy unscoped boundaries keep any-goal semantics. + // The inference is sound only because AUTOMATIC paused→active writers + // (the branch below and recordContinuationFired) refuse across an explicit + // Pause via the finalization hold + explicit-pause generation (Codex P1 + // PRRT_kwDOPxxmWM6cMQqq) — a user Resume is the only remaining writer that + // can leave a durably active goal beneath its own scoped boundary. if ( goal.status === "active" && chatTailMode.mode === "paused" && @@ -809,10 +852,19 @@ export class WorkspaceGoalService { // just told succeeded. Suppress same-goal automatic reactivation while the // finalization is in flight; genuine user mutations (resume, replacement) // write through setGoal directly and still win. + // + // Codex P1 (PRRT_kwDOPxxmWM6cMQqq): the hold only covers the in-flight + // window. Tail reads run outside the goal lock, so an explicit Pause can + // fully commit (write, boundary append, hold release) between the read + // and this apply — the generation stamp detects that the "active" + // evidence predates the Pause. The next read sees the appended boundary + // and reconciles paused normally, so this only refuses stale evidence. if ( goal.status === "paused" && chatTailMode.mode === "active" && - this.pauseFinalizationHolds.get(workspaceId)?.goalId === goal.goalId + (this.pauseFinalizationHolds.get(workspaceId)?.goalId === goal.goalId || + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== + chatTailMode.pauseGenerationAtRead) ) { return goal; } @@ -1874,9 +1926,22 @@ export class WorkspaceGoalService { if (current?.goalId !== expectedGoalId) { return; } + // Codex P1 (PRRT_kwDOPxxmWM6cMQqq): the paused→active acceptance exists + // for reconciliation's spurious kickoff-window pause (see the paused + // kickoff comment in requestContinuationAfterStreamEnd), but an EXPLICIT + // Pause admitted after this continuation was dispatched must not be + // undone — flipping active here lands durably beneath the pause's own + // scoped boundary, which the Resume-wins reconciliation rule then + // preserves as if the user had resumed. Refuse while the pause + // finalization is in flight (hold armed) or when an explicit pause + // committed after the tail evidence above was read (stale evidence). + const explicitPauseSupersedes = + this.pauseFinalizationHolds.get(workspaceId)?.goalId === current.goalId || + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== + chatTailMode.pauseGenerationAtRead; const continuationAccepted = current.status === "active" || - (current.status === "paused" && chatTailMode.mode === "active"); + (current.status === "paused" && chatTailMode.mode === "active" && !explicitPauseSupersedes); if (!continuationAccepted) { return; } From 491269ed8e5bb94ea76905fc1f218cb6f8af2b3f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:02:10 +0000 Subject: [PATCH 31/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2026=20?= =?UTF-8?q?=E2=80=94=20edit-in-place=20stop=20restore,=20live=20suppressio?= =?UTF-8?q?n=20before=20snapshot=20publish,=20armer=20honors=20durable=20s?= =?UTF-8?q?uppression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three adjacent gaps: (1) the editInPlace branch had no post-write stop recheck, so a queued rename claimed by the stream-end drain stayed durable when a Stop landed inside its write — mirror the other branches' restore; (2) suppressBudgetWrapupForManualUserMessage updated the live stream stamp only after the snapshot publish, so a snapshot failure after the durable write left this process arming the wrap-up off the stale goal-attributable stamp — publish the live suppression immediately after writeGoal; (3) armBudgetWrapupForBudgetLimitedGoal's final recheck ignored a durable user-origin record, overwriting a completed suppression's live stamp with goal_continuation and re-arming the wrap-up — reject user-origin records. --- .../services/workspaceGoalService.test.ts | 144 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 52 +++++-- 2 files changed, 184 insertions(+), 12 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 3b578672f60..393364abbf8 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3199,6 +3199,100 @@ describe("WorkspaceGoalService", () => { expect(await service.getGoal(workspaceId)).toMatchObject({ budgetLimitOriginKind: "user" }); }); + test("a failed snapshot publish after the suppression write still updates the live stamp", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cMpob): once writeGoal committed, the durable + // record says the wrap-up is suppressed. If the follow-up snapshot publish + // fails, the method throws — but the live stamp must ALREADY be + // user-origin, or this process's stream-end arms and dispatches the + // wrap-up off the stale goal-attributable stamp while goal.json says + // "user". + const created = await setGoalOk(service, { + workspaceId, + objective: "Snapshot publish fails", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + const serviceAccess = service as unknown as { + pushSnapshot: (id: string, goal: GoalRecordV1 | null) => Promise; + lastGoalStreamStamps: Map; + }; + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe( + "goal_continuation" + ); + + const snapshotSpy = spyOn(serviceAccess, "pushSnapshot").mockImplementationOnce(() => + Promise.reject(new Error("injected: snapshot publish lost")) + ); + let threw = false; + try { + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); + } catch { + threw = true; + } + snapshotSpy.mockRestore(); + expect(threw).toBe(true); + + // Durable and live state agree: suppression is in effect. + expect((await service.getGoal(workspaceId))?.budgetLimitOriginKind).toBe("user"); + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe("user"); + }); + + test("the wrap-up armer honors a durable suppression that completed under a stale record", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cMpoe): armBudgetWrapupForBudgetLimitedGoal + // awaits kickoff options and the durable read unlocked, so a manual + // suppression can complete in that window (modeled here by passing the + // pre-suppression record). Without the durable origin recheck it would + // overwrite the live user-origin stamp with goal_continuation and arm a + // fresh candidate — resurrecting the wrap-up the suppression disarmed. + const created = await setGoalOk(service, { + workspaceId, + objective: "Armer vs suppression", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + const stale = await service.getGoal(workspaceId); + expect(stale).toMatchObject({ status: "budget_limited" }); + + // Registered after setup so no kickoff candidate exists — the armer must + // fall through to its durable recheck rather than an earlier guard. + const dispatcher = new IdleDispatcher(); + const executed: Array<{ kind: string | undefined }> = []; + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + executed.push({ kind: input.kind }); + return Promise.resolve(true); + }, + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); + + const serviceAccess = service as unknown as { + armBudgetWrapupForBudgetLimitedGoal: (id: string, goal: GoalRecordV1) => Promise; + lastGoalStreamStamps: Map; + pendingContinuationCandidates: Map; + }; + await serviceAccess.armBudgetWrapupForBudgetLimitedGoal(workspaceId, stale!); + await drainPendingDispatches(); + + // The live suppression survives, no candidate was armed, nothing fired. + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe("user"); + expect(serviceAccess.pendingContinuationCandidates.has(workspaceId)).toBe(false); + expect(executed).toHaveLength(0); + }); + test("a stop landing inside the goal write restores the prior record", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cLpIP): a model complete_goal takes the // direct mutable branch during the live stream. The final pre-write stop @@ -3314,6 +3408,56 @@ describe("WorkspaceGoalService", () => { expect(serviceAccess.pendingGoalMutations.get(workspaceId)).toBeUndefined(); }); + test("a stop landing during a drained rename's write restores the prior record", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cMpoV): the editInPlace branch persists via + // its own writeGoal and, unlike the same-objective and creation branches, + // had no post-write stop recheck. A queued rename claimed by the + // stream-end drain would keep the renamed record durable when the Stop + // landed inside that write. + const original = await setGoalOk(service, { workspaceId, objective: "Original name" }); + await extensionMetadata.setStreaming(workspaceId, true); + const queued = await service.setGoal({ + workspaceId, + objective: "Renamed mid-stream", + editInPlace: true, + }); + expect(queued.success).toBe(true); + + const serviceAccess = service as unknown as { + writeGoal: (id: string, goal: GoalRecordV1) => Promise; + }; + const realWrite = serviceAccess.writeGoal.bind(service); + let releaseWrite!: () => void; + const writeGate = new Promise((resolve) => { + releaseWrite = resolve; + }); + let writeStarted = false; + const writeSpy = spyOn(serviceAccess, "writeGoal").mockImplementationOnce( + async (id: string, goal: GoalRecordV1) => { + writeStarted = true; + await writeGate; + return realWrite(id, goal); + } + ); + + const drainPromise = service.applyPendingAfterStreamEnd(workspaceId); + await waitForCondition(() => writeStarted, { timeoutMs: 5_000 }); + const stopPromise = service.recordUserStoppedStream(workspaceId); + releaseWrite(); + + const drained = await drainPromise; + expect(drained).toBeNull(); + await stopPromise; + writeSpy.mockRestore(); + + // The rename never survived; the stop gated the restored original. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: original.goalId, + objective: "Original name", + status: "active", + }); + }); + test("an explicit Resume during pause finalization survives the stale boundary", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cLpIT): a same-goal Resume can durably set the // goal active while the old pause finalizer is appending its boundary. diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 4b2df2f26c7..87ca4478de1 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2836,6 +2836,18 @@ export class WorkspaceGoalService { return stoppedBeforeEditWrite; } await this.writeGoal(input.workspaceId, withEdits); + // Codex P1 (PRRT_kwDOPxxmWM6cMpoV): same post-write window as the + // same-objective and creation branches — a queued rename claimed by + // the stream-end drain (or a live-stream edit) can have the Stop land + // inside writeGoal, after the pre-write check passed. Restore the + // prior record before releasing the lock so the aborted turn's edit + // never survives. + const stoppedDuringEditWrite = discardIfUserStopLanded(); + if (stoppedDuringEditWrite) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + return stoppedDuringEditWrite; + } await this.pushSnapshot(input.workspaceId, withEdits); await this.pushLiveGoalPreviewOverlay(input.workspaceId, withEdits); this.emitBudgetChanged(current, withEdits, input); @@ -3316,6 +3328,13 @@ export class WorkspaceGoalService { if ( durable?.goalId !== goal.goalId || durable.status !== "budget_limited" || + // Codex P2 (PRRT_kwDOPxxmWM6cMpoe): manual suppression can complete + // during the unlocked awaits above (or before a caller passing a stale + // record). A durable user-origin record means the user already + // intervened — overwriting the live user-origin stamp with a + // goal-attributable one here would resurrect the wrap-up the + // suppression just disarmed. + durable.budgetLimitOriginKind === "user" || this.pendingContinuationCandidates.has(workspaceId) ) { return; @@ -3433,6 +3452,19 @@ export class WorkspaceGoalService { // goal.json stays goal-attributable — after a restart, recovery would // re-arm the autonomous wrap-up despite the manual intervention. // Memory only updates after the durable state it mirrors exists. + // Codex P2 (PRRT_kwDOPxxmWM6cKkGL): the durable stamp only protects + // restarts. The LIVE stream stamp stays goal-attributable through the + // manual turn's own accounting (recordStreamAccounting preserves + // budget_limited stamps), so the manual turn's stream-end would arm a + // fresh candidate that wrap-up eligibility accepts — dispatching the + // autonomous wrap-up right after the user's intervention. Re-mark the + // live stamp user-origin so eligibility rejects it in-process too. + const markLiveStampUserOrigin = (): void => { + const liveStamp = this.lastGoalStreamStamps.get(workspaceId); + if (liveStamp?.goalId === current.goalId && liveStamp.originKind !== "user") { + this.lastGoalStreamStamps.set(workspaceId, { ...liveStamp, originKind: "user" }); + } + }; if (current.budgetLimitOriginKind !== "user") { const next = GoalRecordV1Schema.parse({ ...current, @@ -3440,19 +3472,15 @@ export class WorkspaceGoalService { updatedAtMs: Date.now(), }); await this.writeGoal(workspaceId, next); + // Codex P2 (PRRT_kwDOPxxmWM6cMpob): the durable origin is committed — + // publish the live suppression BEFORE the snapshot await. A snapshot + // failure after the write would otherwise throw out of this method + // with the live stamp still goal-attributable, letting this process's + // stream-end arm the wrap-up while goal.json already says "user". + markLiveStampUserOrigin(); await this.pushSnapshot(workspaceId, next); - } - // Codex P2 (PRRT_kwDOPxxmWM6cKkGL): the durable stamp above only - // protects restarts. The LIVE stream stamp stays goal-attributable - // through the manual turn's own accounting (recordStreamAccounting - // preserves budget_limited stamps), so the manual turn's stream-end - // would arm a fresh candidate that wrap-up eligibility accepts — - // dispatching the autonomous wrap-up right after the user's - // intervention. Re-mark the live stamp user-origin so eligibility - // rejects it in-process too. - const liveStamp = this.lastGoalStreamStamps.get(workspaceId); - if (liveStamp?.goalId === current.goalId && liveStamp.originKind !== "user") { - this.lastGoalStreamStamps.set(workspaceId, { ...liveStamp, originKind: "user" }); + } else { + markLiveStampUserOrigin(); } }); } From dd45528d31c5cd11a80052babdaf272808c7aa0b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:28:17 +0000 Subject: [PATCH 32/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2027=20?= =?UTF-8?q?=E2=80=94=20suppress=20budget=20wrap-up=20when=20auto-pause=20i?= =?UTF-8?q?s=20rejected=20by=20a=20raced=20budget-limit=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit acknowledgeUser can return the goal as active while a queued child-attribution or budget edit moves the same goal to budget_limited during the safety hook's awaits. The stale status skipped the suppression branch and the auto-pause was rejected (budget-limited goals cannot pause), so the manual stream-end could re-arm the autonomous wrap-up despite the user's intervention. The pause-failure path now re-runs the suppression against the same goal identity; the service's locked recheck no-ops unless the goal actually became budget-limited. --- .../agentSession.goalAutoPause.test.ts | 54 +++++++++++++++++++ src/node/services/agentSession.ts | 32 ++++++++--- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 25447d1ad39..2e14c0fa222 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -188,6 +188,60 @@ describe("AgentSession goal safety hooks", () => { }); } + test("a same-goal budget-limit transition during acknowledgment still suppresses the wrap-up", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cNQvL): acknowledgeUser can return goal A as + // active while a queued child-attribution or budget edit then moves A to + // budget_limited. The stale status skips the suppression branch, and the + // auto-pause is rejected (budget-limited goals cannot pause) — without a + // recheck the manual stream-end re-arms the autonomous wrap-up despite + // the user's intervention. The pause-failure path must suppress against + // the same goal identity. + const workspaceId = "budget-limit-races-acknowledgment"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { + workspaceId, + objective: "Races into budget_limited", + budgetCents: 100, + }); + + const realAcknowledge = goalService.acknowledgeUser.bind(goalService); + const ackSpy = spyOn(goalService, "acknowledgeUser").mockImplementationOnce( + async (...args: Parameters) => { + const snapshot = await realAcknowledge(...args); + expect(snapshot).toMatchObject({ status: "active" }); + // Same-goal transition landing after the stale-active snapshot. + await goalService.recordStreamAccounting({ + workspaceId, + costUsd: 2, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + return snapshot; + } + ); + + // Invoke the hook directly: in the sendMessage flow the manual row would + // already reconcile the goal to paused before acknowledgment, hiding the + // stale-active window this race needs. + const sessionAccess = session as unknown as { + applyManualUserMessageGoalSafety: (input: { + policy: "pause" | "steer"; + enqueuedAtMs?: number; + }) => Promise; + }; + await sessionAccess.applyManualUserMessageGoalSafety({ policy: "pause" }); + ackSpy.mockRestore(); + + // The wrap-up is durably suppressed despite the stale-active snapshot. + expect(await goalService.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "budget_limited", + budgetLimitOriginKind: "user", + }); + session.dispose(); + }); + test("synthetic messages do not auto-pause active goals", async () => { const workspaceId = "synthetic-does-not-pause"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index afda39abcb7..0b2685a4d8a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1680,15 +1680,19 @@ export class AgentSession { // Also clears any candidate armed during the acknowledgment await — a // post-goal intervention must not leave a consumable continuation behind. goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); - if (goal?.status === "budget_limited") { - // Codex P2 (PRRT_kwDOPxxmWM6cJ6NM): the candidate delete above is - // in-memory only — persist the suppression so a restart cannot - // re-synthesize the autonomous wrap-up over the user's intervention. + // Codex P2 (PRRT_kwDOPxxmWM6cJ6NM): the candidate delete above is + // in-memory only — persist the suppression so a restart cannot + // re-synthesize the autonomous wrap-up over the user's intervention. + // + // Scoped to the acknowledged goal's identity: a replacement goal that + // persisted during the acknowledgment await must not be suppressed by a + // message that predates it (Codex P2 PRRT_kwDOPxxmWM6cLpID). The service + // re-verifies goalId + budget_limited status under its lock, so callers + // may invoke this on a stale snapshot and it no-ops unless suppression is + // actually owed. + const suppressWrapupForGoal = async (goalId: string): Promise => { try { - // Scoped to the acknowledged goal's identity: a replacement goal that - // persisted during the acknowledgment await must not be suppressed by - // a message that predates it (Codex P2 PRRT_kwDOPxxmWM6cLpID). - await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId, goal.goalId); + await goalService.suppressBudgetWrapupForManualUserMessage(this.workspaceId, goalId); } catch (error) { // A transient write failure must not break the user's manual send; // suppression fails closed (durable-first, so no in-memory state was @@ -1698,6 +1702,9 @@ export class AgentSession { error: getErrorMessage(error), }); } + }; + if (goal?.status === "budget_limited") { + await suppressWrapupForGoal(goal.goalId); } if (goal?.status !== "active") { return; @@ -1714,12 +1721,21 @@ export class AgentSession { workspaceId: this.workspaceId, error: result.error, }); + // Codex P2 (PRRT_kwDOPxxmWM6cNQvL): the acknowledged snapshot said + // "active", but a queued child-attribution or budget edit can move + // the SAME goal to budget_limited during the awaits above — the pause + // is then rejected (budget-limited goals cannot pause) and the + // suppression branch above was skipped on the stale status. Suppress + // against the same goal identity; the locked recheck no-ops unless + // the goal really became budget-limited. + await suppressWrapupForGoal(goal.goalId); } } catch (error) { log.warn("Failed to auto-pause goal for manual user message", { workspaceId: this.workspaceId, error: getErrorMessage(error), }); + await suppressWrapupForGoal(goal.goalId); } } From c72292c7e2d148791a6d509d1b29e64856ecfa81 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:42:08 +0000 Subject: [PATCH 33/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2028=20?= =?UTF-8?q?=E2=80=94=20validate=20persisted=20goal=20IDs=20before=20bounda?= =?UTF-8?q?ry/continuation=20scoping=20comparisons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat.jsonl metadata is unchecked JSON: a malformed non-string goalId on a pause boundary satisfied the '!== currentGoalId' mismatch test, so the boundary was skipped as another goal's row and the scan could reach the goal's own older continuation row — reactivating a durably paused goal after restart. toValidGoalId degrades invalid values to null (legacy unscoped semantics): the boundary still reconciles paused, just unscoped, and continuation rows cannot cross-mark corrupt IDs as other-goal history. --- .../services/workspaceGoalService.test.ts | 40 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 23 ++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 393364abbf8..db2bbc0ff82 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2958,6 +2958,46 @@ describe("WorkspaceGoalService", () => { }); }); + test("a malformed boundary goalId does not unpause the goal it belongs to", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cNxUY): chat.jsonl metadata is unchecked + // JSON — a corrupt non-string goalId on a pause boundary must not satisfy + // the mismatch test (it is not another goal's boundary). Skipping it + // would let the scan reach the goal's own older continuation row and + // reactivate a durably paused goal. Invalid IDs degrade to legacy + // unscoped semantics: conservative paused, not scoped. + const created = await setGoalOk(service, { workspaceId, objective: "Corrupt boundary" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + const corruptBoundary = createMuxMessage( + `goal-paused-corrupt-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary", goalId: 42 as unknown as string }, + } + ); + expect((await historyService.appendToHistory(workspaceId, corruptBoundary)).success).toBe(true); + // Durable pause written directly (no boundary append, no in-memory pause + // bookkeeping) — models the post-restart state where only the persisted + // artifacts remain. + await ( + service as unknown as { writeGoal: (id: string, goal: GoalRecordV1) => Promise } + ).writeGoal(workspaceId, { ...created, status: "paused", updatedAtMs: Date.now() }); + + // Reconciliation must not treat the corrupt boundary as another goal's + // row and reactivate off the continuation row beneath it. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + test("continuation rows for a replaced goal do not reconcile the newer goal to active", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cH3kV): goal A fired a continuation and was // paused, then replaced with goal B which the user pauses. When B's pause diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 87ca4478de1..de24572752d 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -226,6 +226,20 @@ function toValidEpochMs(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; } +/** + * Codex P2 (PRRT_kwDOPxxmWM6cNxUY): chat.jsonl metadata is unchecked JSON, so + * a goal-scoping ID persisted on a continuation or pause-boundary row can be + * any shape at runtime. A malformed non-string value must not satisfy a + * `!== currentGoalId` mismatch test (it is not evidence the row belongs to a + * DIFFERENT goal) — skipping a pause boundary on corrupt data would let the + * scan reach an older continuation row and reactivate a durably paused goal + * after restart. Invalid values degrade to `null`, i.e. legacy unscoped + * semantics. + */ +function toValidGoalId(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + interface ChatTailGoalModeResult { mode: "active" | "paused" | null; /** @@ -709,7 +723,9 @@ export class WorkspaceGoalService { // continuation row and silently reactivate B. Rows scoped to a // different goal are invisible here, mirroring the boundary skip; // legacy rows without a goalId keep the old any-goal semantics. - const rowGoalId = message.metadata.goalId; + // Validated before comparison: a malformed ID is not a different goal + // (see toValidGoalId). + const rowGoalId = toValidGoalId(message.metadata.goalId); if (currentGoalId != null && rowGoalId != null && rowGoalId !== currentGoalId) { crossedOtherGoalHistory = true; continue; @@ -720,7 +736,10 @@ export class WorkspaceGoalService { return { mode: "active" }; } if (message.metadata?.muxMetadata?.type === "goal-pause-boundary") { - const boundaryGoalId = message.metadata.muxMetadata.goalId; + // Validated before comparison: a malformed ID must degrade to the + // legacy unscoped branch below (conservative paused, unscoped), not + // count as another goal's boundary (see toValidGoalId). + const boundaryGoalId = toValidGoalId(message.metadata.muxMetadata.goalId); if (currentGoalId != null && boundaryGoalId != null && boundaryGoalId !== currentGoalId) { crossedOtherGoalHistory = true; // Codex P2 (PRRT_kwDOPxxmWM6cEl4F, PRRT_kwDOPxxmWM6cGSPK): a stale From 4c664f4dbdcfe3b7b578304340fd2409c0ab85dc Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 19:58:29 +0000 Subject: [PATCH 34/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2029=20?= =?UTF-8?q?=E2=80=94=20stop=20veto=20through=20publication=20awaits;=20ski?= =?UTF-8?q?p=20present-but-invalid=20continuation=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) The post-write stop recheck sampled the generation before pushSnapshot/pushLiveGoalPreviewOverlay, which still await inside the same lock tenure — a Stop landing during publication left the aborted turn's mutation (e.g. a model complete_goal) durable. All three persistence branches now recheck after their final awaited publication step and restore the prior record. (2) Legacy any-goal semantics now apply only when a continuation row's scoping ID is genuinely absent: a present-but-malformed ID is skipped outright instead of degrading to legacy-active evidence that could reactivate a durably paused goal. Boundaries keep the conservative degrade-to-unscoped-paused direction. --- .../services/workspaceGoalService.test.ts | 78 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 58 ++++++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index db2bbc0ff82..5e441e21f9f 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2998,6 +2998,84 @@ describe("WorkspaceGoalService", () => { }); }); + test("a malformed continuation goalId is not legacy activity evidence", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cOHpI): legacy any-goal semantics apply only + // when the scoping ID is genuinely absent. A present-but-malformed ID on + // a continuation row must be skipped, not accepted as legacy-active — at + // the tail it would otherwise reactivate a durably paused goal. + const created = await setGoalOk(service, { workspaceId, objective: "Corrupt continuation" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: 42 as unknown as string, + }); + // Durable pause written directly (no boundary, no in-memory bookkeeping) + // — models the post-restart state where only persisted artifacts remain. + await ( + service as unknown as { writeGoal: (id: string, goal: GoalRecordV1) => Promise } + ).writeGoal(workspaceId, { ...created, status: "paused", updatedAtMs: Date.now() }); + + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + + test("a stop landing during post-write publication restores the prior record", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cOHpB): the post-write recheck samples the + // stop generation, but pushSnapshot/pushLiveGoalPreviewOverlay still + // await inside the same lock tenure. A Stop landing there left the + // aborted turn's complete_goal durable — the veto must stay active + // through the final awaited publication step. + const created = await setGoalOk(service, { workspaceId, objective: "Abort mid-publication" }); + await extensionMetadata.setStreaming(workspaceId, true); + + const serviceAccess = service as unknown as { + pushSnapshot: (id: string, goal: GoalRecordV1 | null) => Promise; + }; + const realPush = serviceAccess.pushSnapshot.bind(service); + let releasePush!: () => void; + const pushGate = new Promise((resolve) => { + releasePush = resolve; + }); + let pushStarted = false; + const pushSpy = spyOn(serviceAccess, "pushSnapshot").mockImplementationOnce( + async (id: string, goal: GoalRecordV1 | null) => { + pushStarted = true; + await pushGate; + return realPush(id, goal); + } + ); + + const completePromise = service.setGoal({ + workspaceId, + status: "complete", + completionSummary: "Done before the user could stop it", + initiator: "model", + }); + await waitForCondition(() => pushStarted, { timeoutMs: 5_000 }); + // Stop lands during the publication await, after the post-write sample. + const stopPromise = service.recordUserStoppedStream(workspaceId); + releasePush(); + + const completed = await completePromise; + expect(completed.success).toBe(false); + if (!completed.success) { + expect(completed.error.type).toBe("invalid_transition"); + } + await stopPromise; + pushSpy.mockRestore(); + + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "active", + }); + expect(typeof (await service.getGoal(workspaceId))?.requireUserAcknowledgmentSinceMs).toBe( + "number" + ); + }); + test("continuation rows for a replaced goal do not reconcile the newer goal to active", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cH3kV): goal A fired a continuation and was // paused, then replaced with goal B which the user pauses. When B's pause diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index de24572752d..eb7fed421ae 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -233,8 +233,12 @@ function toValidEpochMs(value: unknown): number | null { * `!== currentGoalId` mismatch test (it is not evidence the row belongs to a * DIFFERENT goal) — skipping a pause boundary on corrupt data would let the * scan reach an older continuation row and reactivate a durably paused goal - * after restart. Invalid values degrade to `null`, i.e. legacy unscoped - * semantics. + * after restart. + * + * Callers treat a present-but-invalid ID by failure direction (Codex P2 + * PRRT_kwDOPxxmWM6cOHpI): a pause BOUNDARY degrades to legacy unscoped + * semantics (conservative paused, never scoped), while a CONTINUATION row is + * skipped outright — corrupt data must not manufacture activity evidence. */ function toValidGoalId(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; @@ -723,9 +727,17 @@ export class WorkspaceGoalService { // continuation row and silently reactivate B. Rows scoped to a // different goal are invisible here, mirroring the boundary skip; // legacy rows without a goalId keep the old any-goal semantics. - // Validated before comparison: a malformed ID is not a different goal - // (see toValidGoalId). - const rowGoalId = toValidGoalId(message.metadata.goalId); + // Codex P2 (PRRT_kwDOPxxmWM6cOHpI): legacy any-goal semantics apply + // only when the scoping ID is genuinely absent. A present-but- + // malformed ID (object, number, empty string) is not trustworthy + // ACTIVITY evidence — degrading it to legacy-active could reactivate + // a durably paused goal off corrupt data. Skip the row entirely: it + // neither proves activity nor marks other-goal history. + const rawRowGoalId: unknown = message.metadata.goalId; + const rowGoalId = toValidGoalId(rawRowGoalId); + if (rawRowGoalId != null && rowGoalId == null) { + continue; + } if (currentGoalId != null && rowGoalId != null && rowGoalId !== currentGoalId) { crossedOtherGoalHistory = true; continue; @@ -2869,6 +2881,15 @@ export class WorkspaceGoalService { } await this.pushSnapshot(input.workspaceId, withEdits); await this.pushLiveGoalPreviewOverlay(input.workspaceId, withEdits); + // Codex P1 (PRRT_kwDOPxxmWM6cOHpB): same publication-await window as + // the same-objective branch — keep the veto active through the final + // awaited publication step. + const stoppedDuringEditPublication = discardIfUserStopLanded(); + if (stoppedDuringEditPublication) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + return stoppedDuringEditPublication; + } this.emitBudgetChanged(current, withEdits, input); this.emitBudgetLimited(input.workspaceId, withEdits, previousStatus); this.emitStatusLifecycle(withEdits, previousStatus, input.initiator ?? "user"); @@ -2944,6 +2965,19 @@ export class WorkspaceGoalService { } await this.pushSnapshot(input.workspaceId, updated); await this.pushLiveGoalPreviewOverlay(input.workspaceId, updated); + // Codex P1 (PRRT_kwDOPxxmWM6cOHpB): the publication awaits above run + // inside the same lock tenure AFTER the post-write sample — a Stop + // landing during them would leave the aborted turn's mutation (e.g. + // a model complete_goal) durable with nothing left to discard it. + // Keep the veto active through the final awaited publication step; + // the restore also re-publishes the prior snapshot, superseding the + // transiently published mutation. + const stoppedDuringPublication = discardIfUserStopLanded(); + if (stoppedDuringPublication) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + return stoppedDuringPublication; + } this.emitBudgetChanged(current, updated, input); this.emitBudgetLimited(input.workspaceId, updated, previousStatus); this.emitStatusLifecycle(updated, previousStatus, input.initiator ?? "user"); @@ -3046,6 +3080,20 @@ export class WorkspaceGoalService { return stoppedDuringCreateWrite; } await this.pushSnapshot(input.workspaceId, next); + // Codex P1 (PRRT_kwDOPxxmWM6cOHpB): the snapshot publication also runs + // inside this tenure after the post-write sample — keep the veto active + // through it (same restore as the post-write branch above). + const stoppedDuringCreatePublication = discardIfUserStopLanded(); + if (stoppedDuringCreatePublication) { + if (current) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + } else { + await fs.rm(this.getFilePath(input.workspaceId), { force: true }); + await this.pushSnapshot(input.workspaceId, null); + } + return stoppedDuringCreatePublication; + } this.emitBudgetChanged(current, next, input); this.emitLifecycle(current ? "goal_replaced" : "goal_created", { sameObjective: current?.objective === objective, From 5b4c427529d12401b58805ce03e0aee29a80a6cb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 20:20:18 +0000 Subject: [PATCH 35/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2030=20?= =?UTF-8?q?=E2=80=94=20pause-staleness=20probe=20on=20continuation=20dispa?= =?UTF-8?q?tch,=20auto-promotion=20stop=20vetoes,=20retryable=20preview=20?= =?UTF-8?q?reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) The dispatched continuation's send now carries an admissionStale probe that flips when the captured candidate is deleted or the explicit-pause generation moves, so an explicit Pause completing during the send preflight refuses the continuation instead of landing its row after the pause boundary as fresh active evidence. (2) maybeAutoPromoteOnComplete and promoteNextUpcomingUnlocked re-sample the stop veto before their durable writes, and the completion callers restore the prior record when the stop landed and no promotion replaced goal.json (a promotion that already committed is left gated by the stop's queued section). (3) resetIneligibleCostPreview publishes the durable snapshot before clearing the cached preview so a failed publication is retried on the next delta. --- .../services/workspaceGoalService.test.ts | 144 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 97 +++++++++++- src/node/services/workspaceService.ts | 4 + 3 files changed, 237 insertions(+), 8 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 5e441e21f9f..8aacc78fdc4 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3076,6 +3076,150 @@ describe("WorkspaceGoalService", () => { ); }); + test("the continuation dispatch admission probe goes stale when an explicit pause commits", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cOgXR): an explicit Pause completing while + // the dispatched continuation's send runs its unlocked preflight must + // refuse the captured send — otherwise its synthetic row lands after the + // pause boundary as fresh active evidence and reactivates the goal. The + // dispatch passes an admissionStale probe that flips when the candidate + // is deleted or the explicit-pause generation moves. + const dispatcher = new IdleDispatcher(); + let capturedProbe: (() => boolean) | undefined; + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + capturedProbe = input.admissionStale; + // Model a send parked in preflight: never accept. + return Promise.resolve(false); + }, + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + await setGoalOk(service, { workspaceId, objective: "Pause races dispatch" }); + await drainPendingDispatches(); + await waitForCondition(() => capturedProbe != null, { timeoutMs: 5_000 }); + + // Mid-preflight, before any pause: not stale. + expect(capturedProbe!()).toBe(false); + // Explicit pause commits during the preflight: deletes the candidate and + // bumps the explicit-pause generation — the probe must flip stale. + await setGoalOk(service, { workspaceId, status: "paused" }); + expect(capturedProbe!()).toBe(true); + }); + + test("a stop landing during auto-promotion reads restores the completed goal", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): maybeAutoPromoteOnComplete awaits + // board/streaming/pricing reads after the caller's publication sample. A + // Stop landing there must neither leave the aborted completion durable + // nor promote the next upcoming goal from the aborted turn. + const created = await setGoalOk(service, { workspaceId, objective: "Abort mid-promotion" }); + const queued = await service.addUpcomingGoal({ workspaceId, objective: "Next in queue" }); + + const serviceAccess = service as unknown as { + readBoard: (id: string) => Promise; + }; + const realReadBoard = serviceAccess.readBoard.bind(service); + let releaseBoard!: () => void; + const boardGate = new Promise((resolve) => { + releaseBoard = resolve; + }); + let boardReadStarted = false; + const boardSpy = spyOn(serviceAccess, "readBoard").mockImplementationOnce( + async (id: string) => { + boardReadStarted = true; + await boardGate; + return realReadBoard(id); + } + ); + + const completePromise = service.setGoal({ + workspaceId, + status: "complete", + completionSummary: "Done before the user could stop it", + initiator: "model", + }); + await waitForCondition(() => boardReadStarted, { timeoutMs: 5_000 }); + // Stop lands during the auto-promotion's board read. + const stopPromise = service.recordUserStoppedStream(workspaceId); + releaseBoard(); + + const completed = await completePromise; + expect(completed.success).toBe(false); + if (!completed.success) { + expect(completed.error.type).toBe("invalid_transition"); + } + await stopPromise; + boardSpy.mockRestore(); + + // The prior goal is restored (not completed, not promoted) and gated. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "active", + }); + expect(typeof (await service.getGoal(workspaceId))?.requireUserAcknowledgmentSinceMs).toBe( + "number" + ); + // The upcoming goal was not consumed by the aborted promotion. + const board = await service.getGoalBoard(workspaceId); + const upcomingEntry = board.entries.find((e) => e.section === "upcoming"); + expect(upcomingEntry?.goal.goalId).toBe(queued.goalId); + }); + + test("a failed preview reset publication is retried on the next usage delta", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cOgXY): the reset deleted the cached live + // preview BEFORE publishing the durable snapshot. A failed publication + // then left no cached preview for later deltas to observe, so the reset + // never retried and the Goal UI kept the stale cost. Publish first; + // clear only after success. + const created = await setGoalOk(service, { + workspaceId, + objective: "Preview reset retry", + budgetCents: 500, + }); + await service.previewStreamAccounting({ + workspaceId, + costUsd: 0.5, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "other", + }); + const previews = (service as unknown as { liveGoalPreviewSnapshots: Map }) + .liveGoalPreviewSnapshots; + expect(previews.has(workspaceId)).toBe(true); + // Mid-stream pause makes the next delta ineligible. + await setGoalOk(service, { workspaceId, status: "paused" }); + + const serviceAccess = service as unknown as { + pushSnapshot: (id: string, goal: GoalRecordV1 | null) => Promise; + }; + const pushSpy = spyOn(serviceAccess, "pushSnapshot").mockImplementationOnce(() => + Promise.reject(new Error("injected: snapshot publish lost")) + ); + let threw = false; + try { + await service.previewStreamAccounting({ + workspaceId, + costUsd: 0.6, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "other", + }); + } catch { + threw = true; + } + pushSpy.mockRestore(); + expect(threw).toBe(true); + // The cache survives the failed publication so a later delta retries. + expect(previews.has(workspaceId)).toBe(true); + + // The next delta completes the reset. + await service.previewStreamAccounting({ + workspaceId, + costUsd: 0.7, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "other", + }); + expect(previews.has(workspaceId)).toBe(false); + }); + test("continuation rows for a replaced goal do not reconcile the newer goal to active", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cH3kV): goal A fired a continuation and was // paused, then replaced with goal B which the user pauses. When B's pause diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index eb7fed421ae..415f0efd984 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -184,6 +184,15 @@ export interface GoalContinuationRuntimeBridge { kind?: GoalSyntheticMessageKind; /** Stamped on the synthetic user row so chat-tail reconciliation can scope it to this goal. */ goalId?: string; + /** + * Codex P1 (PRRT_kwDOPxxmWM6cOgXR): re-evaluated through the send's + * admission gates up to the last gate before the pre-turn batch becomes + * irrevocable. An explicit Pause completing during the send preflight + * (pricing/settings/history awaits) flips this stale, refusing the + * captured continuation instead of landing its row after the pause + * boundary as fresh active evidence. + */ + admissionStale?: () => boolean; }): Promise; /** * Build default SendMessageOptions for a kickoff continuation that is armed @@ -1688,6 +1697,16 @@ export class WorkspaceGoalService { const message = buildGoalContinuationMessage(continuationGoal); return { dispatch: async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cOgXR): an explicit Pause completing + // while the send below runs its unlocked preflight would otherwise go + // unobserved — requireIdle still admits (Pause does not make the + // session busy) and the continuation row would land after the pause + // boundary as fresh active evidence. Every explicit pause path + // deletes the candidate and bumps the explicit-pause generation, so + // the probe flips stale and the send is refused before its pre-turn + // batch becomes irrevocable. Reconciliation pauses do neither, so the + // kickoff-window recovery dispatch still proceeds. + const pauseGenerationAtDispatch = this.explicitPauseGenerations.get(workspaceId) ?? 0; const accepted = await this.goalContinuationBridge?.executeGoalContinuation({ workspaceId, message, @@ -1695,6 +1714,9 @@ export class WorkspaceGoalService { startStreamInBackground: candidate.source === "kickoff", kind: GOAL_CONTINUATION_KIND, goalId: goal.goalId, + admissionStale: () => + this.pendingContinuationCandidates.get(workspaceId) !== candidate || + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtDispatch, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); @@ -2905,7 +2927,20 @@ export class WorkspaceGoalService { hasTurnCap: withEdits.turnCap != null, editInPlace: true, }); - await this.maybeAutoPromoteOnComplete(input.workspaceId, withEdits, previousStatus); + await this.maybeAutoPromoteOnComplete(input.workspaceId, withEdits, previousStatus, { + stopVeto: () => discardIfUserStopLanded() != null, + }); + // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): same conditional restore as the + // same-objective branch (see comment there). + const stoppedDuringEditPromotion = discardIfUserStopLanded(); + if (stoppedDuringEditPromotion) { + const durableNow = await this.readGoalFile(input.workspaceId); + if (durableNow?.goalId === withEdits.goalId) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + return stoppedDuringEditPromotion; + } + } return Ok(withEdits); } @@ -2981,7 +3016,25 @@ export class WorkspaceGoalService { this.emitBudgetChanged(current, updated, input); this.emitBudgetLimited(input.workspaceId, updated, previousStatus); this.emitStatusLifecycle(updated, previousStatus, input.initiator ?? "user"); - await this.maybeAutoPromoteOnComplete(input.workspaceId, updated, previousStatus); + await this.maybeAutoPromoteOnComplete(input.workspaceId, updated, previousStatus, { + stopVeto: () => discardIfUserStopLanded() != null, + }); + // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): auto-promotion awaits board/ + // streaming/pricing reads after the publication sample. The vetoes + // inside it prevent promotion writes once a stop lands, so if the + // durable record is still this turn's completion, restore the prior + // record; if a promotion already replaced goal.json (stop landed + // inside the promotion writes), leave it — the stop's queued + // section gates the promoted active goal instead. + const stoppedDuringPromotion = discardIfUserStopLanded(); + if (stoppedDuringPromotion) { + const durableNow = await this.readGoalFile(input.workspaceId); + if (durableNow?.goalId === updated.goalId) { + await this.writeGoal(input.workspaceId, current); + await this.pushSnapshot(input.workspaceId, current); + return stoppedDuringPromotion; + } + } } if (input.objective != null) { this.emitLifecycle("goal_replaced", { @@ -3600,9 +3653,16 @@ export class WorkspaceGoalService { workspaceId: string, current: GoalRecordV1 ): Promise { - const hadPreview = this.liveGoalPreviewSnapshots.delete(workspaceId); - if (hadPreview) { - return this.pushSnapshot(workspaceId, current); + if (this.liveGoalPreviewSnapshots.has(workspaceId)) { + // Codex P2 (PRRT_kwDOPxxmWM6cOgXY): publish BEFORE clearing the cache. + // Deleting first meant a failed publication left no cached preview for + // later deltas to observe, so the reset was never retried and the Goal + // UI kept the stale cost until an unrelated snapshot happened to + // succeed. If the push throws, the cache stays intact and the next + // usage delta retries the reset. + const snapshot = await this.pushSnapshot(workspaceId, current); + this.liveGoalPreviewSnapshots.delete(workspaceId); + return snapshot; } return toGoalSnapshot(current); } @@ -4765,7 +4825,8 @@ export class WorkspaceGoalService { private async maybeAutoPromoteOnComplete( workspaceId: string, completedGoal: GoalRecordV1, - previousStatus: GoalStatus + previousStatus: GoalStatus, + options?: { stopVeto?: () => boolean } ): Promise { if (completedGoal.status !== "complete" || previousStatus === "complete") { return; @@ -4799,12 +4860,20 @@ export class WorkspaceGoalService { ); return; } + // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): the board/streaming/pricing reads + // above await after the caller's last stop sample. Re-sample before the + // first durable write so an abort landing during those reads neither + // archives the aborted completion nor promotes the next goal; the caller + // re-checks after we return and restores the prior record. + if (options?.stopVeto?.() === true) { + return; + } // Move the completed goal into history before overwriting goal.json // with the promoted goal. The board's Completed section reads from // history, so this is what makes the just-completed goal visible // there. await this.appendGoalHistoryEntry(workspaceId, completedGoal, "completed"); - await this.promoteNextUpcomingUnlocked(workspaceId); + await this.promoteNextUpcomingUnlocked(workspaceId, options); } /** @@ -4818,7 +4887,10 @@ export class WorkspaceGoalService { * Caller must hold the workspace file lock. Returns the new active * record if a promotion happened, null otherwise. */ - private async promoteNextUpcomingUnlocked(workspaceId: string): Promise { + private async promoteNextUpcomingUnlocked( + workspaceId: string, + options?: { stopVeto?: () => boolean } + ): Promise { const board = await this.readBoard(workspaceId); if (board.upcoming.length === 0) return null; // same mid-stream guard as `promoteUpcomingGoal`. @@ -4870,6 +4942,15 @@ export class WorkspaceGoalService { ); return null; } + // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): last stop sample before the promotion + // writes — an abort landing during this helper's own board/streaming/ + // pricing reads must not promote a goal from the aborted turn. (A stop + // landing inside the writes below leaves the promoted goal active; the + // stop's queued locked section then installs its acknowledgment gate on + // it, halting autonomy.) + if (options?.stopVeto?.() === true) { + return null; + } await this.writeBoard(workspaceId, { ...board, upcoming: rest }); await this.writeGoal(workspaceId, activated); await this.pushSnapshot(workspaceId, activated); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9184dcf1230..06178c19435 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -13573,6 +13573,7 @@ export class WorkspaceService extends EventEmitter { kind?: GoalSyntheticMessageKind; goalId?: string; options: SendMessageOptions; + admissionStale?: () => boolean; }): Promise { assert(input.workspaceId.trim().length > 0, "executeGoalContinuation requires workspaceId"); assert(input.message.trim().length > 0, "executeGoalContinuation requires message"); @@ -13600,6 +13601,9 @@ export class WorkspaceService extends EventEmitter { goalKind, goalId: input.goalId, goalContinuation: true, + // Composed with the requireIdle preflight probe (see the requireIdle + // admission section in sendMessage). + admissionStale: input.admissionStale, } ); From 44a8f883d3cdc989c4a29e183a4e9622377ed40c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 20:38:54 +0000 Subject: [PATCH 36/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2031=20?= =?UTF-8?q?=E2=80=94=20admission=20probes=20cover=20wrap-up=20suppression?= =?UTF-8?q?=20and=20same-goal=20terminal=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The budget wrap-up dispatch carried no admissionStale probe, so a manual send suppressing the wrap-up during the send preflight (candidate deleted, durable + live user-origin) could not refuse the captured synthetic turn — tryMarkBudgetLimitInjected refusing after acceptance was too late. The active-continuation probe also missed same-goal transitions to complete or budget_limited, which neither delete the candidate nor bump the pause generation. writeGoal now bumps a per-workspace terminal-status generation whenever it commits a complete/budget_limited record (single write choke point covers direct setters, drained mutations, accounting flips, child attribution, and suppression); both dispatch probes check it, and the wrap-up probe additionally re-validates candidate identity and live-stamp wrap-up eligibility. --- .../services/workspaceGoalService.test.ts | 79 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 64 ++++++++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 8aacc78fdc4..aca0cb3b87c 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3107,6 +3107,85 @@ describe("WorkspaceGoalService", () => { expect(capturedProbe!()).toBe(true); }); + test("the wrap-up dispatch admission probe goes stale when a manual message suppresses it", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cPBWX): a manual send during the wrap-up + // send's preflight can suppress the wrap-up without making the session + // busy; tryMarkBudgetLimitInjected refusing after acceptance is too late. + // The wrap-up dispatch must carry an admission probe that flips on + // candidate deletion, live-stamp ineligibility, or the suppression's + // durable terminal-status write. + const created = await setGoalOk(service, { + workspaceId, + objective: "Wrap-up races manual suppression", + budgetCents: 100, + }); + const dispatcher = new IdleDispatcher(); + let capturedProbe: (() => boolean) | undefined; + let capturedKind: string | undefined; + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + capturedKind = input.kind; + capturedProbe = input.admissionStale; + // Model a send parked in preflight: never accept. + return Promise.resolve(false); + }, + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + await service.requestContinuationAfterStreamEnd({ + workspaceId, + sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + streamEndedAtMs: 20_000, + }); + await drainPendingDispatches(); + await waitForCondition(() => capturedProbe != null, { timeoutMs: 5_000 }); + expect(capturedKind).toBe(GOAL_BUDGET_LIMIT_KIND); + + // Mid-preflight, before the intervention: not stale. + expect(capturedProbe!()).toBe(false); + // Manual suppression commits during the preflight: durable user-origin + // write (terminal-status bump) + live stamp re-mark — probe flips stale. + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); + expect(capturedProbe!()).toBe(true); + }); + + test("the continuation admission probe goes stale when the goal completes mid-preflight", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cPBWd): a same-goal completion (or an + // accounting flip to budget_limited) during the send preflight neither + // deletes the candidate nor bumps the explicit-pause generation — the + // captured continuation would be admitted against a completed goal. The + // terminal-status generation must flip the probe. + const dispatcher = new IdleDispatcher(); + let capturedProbe: (() => boolean) | undefined; + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + capturedProbe = input.admissionStale; + return Promise.resolve(false); + }, + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + await setGoalOk(service, { workspaceId, objective: "Completion races dispatch" }); + await drainPendingDispatches(); + await waitForCondition(() => capturedProbe != null, { timeoutMs: 5_000 }); + + expect(capturedProbe!()).toBe(false); + await setGoalOk(service, { + workspaceId, + status: "complete", + completionSummary: "Completed during the preflight", + }); + expect(capturedProbe!()).toBe(true); + }); + test("a stop landing during auto-promotion reads restores the completed goal", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): maybeAutoPromoteOnComplete awaits // board/streaming/pricing reads after the caller's publication sample. A diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 415f0efd984..98084fbd01b 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -566,6 +566,22 @@ export class WorkspaceGoalService { */ private readonly explicitPauseGenerations = new Map(); + /** + * Codex P1 (PRRT_kwDOPxxmWM6cPBWd, PRRT_kwDOPxxmWM6cPBWX): monotonic + * per-workspace count of durable writes that commit a `complete` or + * `budget_limited` record, bumped inside `writeGoal` (the single goal.json + * write choke point, so every path — direct setters, drained mutations, + * accounting flips, child attribution, wrap-up suppression — is counted). + * Continuation/wrap-up dispatch admission probes capture it at dispatch + * entry: a same-goal terminal transition committing during the send + * preflight neither deletes the candidate nor bumps the pause generation, + * so without this the captured send would be admitted against a completed + * goal or in place of (or despite the suppression of) the budget wrap-up. + * Over-counting is safe: a refused send re-requests dispatch and + * eligibility re-derives the correct action from durable state. + */ + private readonly terminalStatusGenerations = new Map(); + private armPauseFinalizationHold(workspaceId: string, goalId: string): void { this.explicitPauseGenerations.set( workspaceId, @@ -1088,6 +1104,15 @@ export class WorkspaceGoalService { const filePath = this.getFilePath(workspaceId); await fs.mkdir(path.dirname(filePath), { recursive: true }); await writeFileAtomic(filePath, `${JSON.stringify(goal, null, 2)}\n`, "utf-8"); + // See terminalStatusGenerations: bumped at the write commit point so + // in-flight dispatch admission probes observe terminal transitions from + // every write path. + if (goal.status === "complete" || goal.status === "budget_limited") { + this.terminalStatusGenerations.set( + workspaceId, + (this.terminalStatusGenerations.get(workspaceId) ?? 0) + 1 + ); + } } private async renameCorruptGoal( @@ -1655,6 +1680,19 @@ export class WorkspaceGoalService { // dispatch to retry — we must not permanently flip // budgetLimitInjectedForGoalId or the goal gets stuck in budget_limited // with no wrap-up. Mirrors the active-continuation path below. + // + // Codex P1 (PRRT_kwDOPxxmWM6cPBWX): a manual send during this + // send's preflight can suppress the wrap-up (delete the candidate, + // stamp durable + live user origin) without ever making the session + // busy — e.g. an unpriced manual send rejected in its own + // preflight. tryMarkBudgetLimitInjected refusing after acceptance + // is too late (the wrap-up stream already started), so the + // admission probe re-validates the captured candidate, the live + // stamp's wrap-up eligibility, and the terminal-status generation + // (suppression's durable write bumps it before the live stamp + // updates) up to the last gate before the send is irrevocable. + const wrapupTerminalGenerationAtDispatch = + this.terminalStatusGenerations.get(workspaceId) ?? 0; const accepted = await this.goalContinuationBridge?.executeGoalContinuation({ workspaceId, message, @@ -1662,6 +1700,22 @@ export class WorkspaceGoalService { startStreamInBackground: false, kind: GOAL_BUDGET_LIMIT_KIND, goalId: goal.goalId, + admissionStale: () => { + if (this.pendingContinuationCandidates.get(workspaceId) !== candidate) { + return true; + } + const stamp = this.lastGoalStreamStamps.get(workspaceId); + if ( + stamp?.goalId !== goal.goalId || + !this.isBudgetWrapupEligibleOrigin(stamp.originKind) + ) { + return true; + } + return ( + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== + wrapupTerminalGenerationAtDispatch + ); + }, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); @@ -1707,6 +1761,13 @@ export class WorkspaceGoalService { // batch becomes irrevocable. Reconciliation pauses do neither, so the // kickoff-window recovery dispatch still proceeds. const pauseGenerationAtDispatch = this.explicitPauseGenerations.get(workspaceId) ?? 0; + // Codex P1 (PRRT_kwDOPxxmWM6cPBWd): a same-goal transition to + // complete (model/user completion) or budget_limited (accounting or + // child attribution) during the preflight neither deletes the + // candidate nor bumps the pause generation — the terminal-status + // generation covers those, refusing a normal continuation against a + // completed goal or one that now owes the budget wrap-up instead. + const terminalGenerationAtDispatch = this.terminalStatusGenerations.get(workspaceId) ?? 0; const accepted = await this.goalContinuationBridge?.executeGoalContinuation({ workspaceId, message, @@ -1716,7 +1777,8 @@ export class WorkspaceGoalService { goalId: goal.goalId, admissionStale: () => this.pendingContinuationCandidates.get(workspaceId) !== candidate || - (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtDispatch, + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtDispatch || + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtDispatch, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); From e740f8a80211416fe5e69a45f99a1dc83803fb93 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 20:54:32 +0000 Subject: [PATCH 37/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2032=20?= =?UTF-8?q?=E2=80=94=20candidate=20restore=20honors=20budget-limited=20goa?= =?UTF-8?q?ls=20that=20still=20owe=20their=20wrap-up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued manual message predating a budget_limited goal (e.g. an auto-promoted revival whose retained spend exceeds its budget) suspends the armed candidate; the active-only restore rule then rejected it, stranding the owed wrap-up until a restart happened to reconstruct it. The restore now also accepts a matching budget-limited goal whose durable record still owes the wrap-up (not user-suppressed, not already injected), source-agnostic because eligibility's budget_limited branch dispatches the one-shot wrap-up from any candidate source. Other statuses keep the active-only rule. --- .../services/workspaceGoalService.test.ts | 55 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 18 +++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index aca0cb3b87c..3033cfcb04e 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3107,6 +3107,61 @@ describe("WorkspaceGoalService", () => { expect(capturedProbe!()).toBe(true); }); + test("pre-goal queue races restore suspended budget wrap-up candidates", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cPbjX): a queued manual message that predates + // a budget_limited goal (e.g. an auto-promoted revival whose retained + // spend exceeds its budget) suspends the armed budget_wrapup candidate; + // the active-only restore rule would reject it and strand the owed + // wrap-up for the rest of the process. A matching budget-limited goal + // that still owes its wrap-up must accept the restore. + const created = await setGoalOk(service, { + workspaceId, + objective: "Wrap-up survives pre-goal queue race", + budgetCents: 100, + }); + const dispatcher = new IdleDispatcher(); + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + // Busy runtime: the armed candidate stays pending instead of firing. + getRuntimeState: () => ({ isRuntimeCompatible: true, isBusy: true }), + executeGoalContinuation: () => Promise.resolve(true), + getKickoffSendOptions: () => Promise.resolve({ model: "openai:gpt-4o", agentId: "exec" }), + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + await service.requestContinuationAfterStreamEnd({ + workspaceId, + sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + streamEndedAtMs: 20_000, + }); + const candidates = ( + service as unknown as { + pendingContinuationCandidates: Map; + } + ).pendingContinuationCandidates; + // Stream-end arming on a budget_limited goal produces the wrap-up's + // candidate (eligibility dispatches the wrap-up from any source). + expect(candidates.get(workspaceId)?.source).toBe("stream_end"); + + const suspended = service.takePendingContinuationCandidateForManualUserMessage(workspaceId); + expect(suspended).not.toBeNull(); + expect(candidates.has(workspaceId)).toBe(false); + await service.restorePendingContinuationCandidate(workspaceId, suspended!); + expect(candidates.get(workspaceId)?.source).toBe("stream_end"); + + // A suppressed wrap-up is no longer owed — the restore must refuse it. + const suspendedAgain = + service.takePendingContinuationCandidateForManualUserMessage(workspaceId); + expect(suspendedAgain).not.toBeNull(); + await service.suppressBudgetWrapupForManualUserMessage(workspaceId, created.goalId); + await service.restorePendingContinuationCandidate(workspaceId, suspendedAgain!); + expect(candidates.has(workspaceId)).toBe(false); + }); + test("the wrap-up dispatch admission probe goes stale when a manual message suppresses it", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cPBWX): a manual send during the wrap-up // send's preflight can suppress the wrap-up without making the session diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 98084fbd01b..4b0d21b843e 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -1480,7 +1480,23 @@ export class WorkspaceGoalService { return false; } const current = await this.readGoalFile(workspaceId); - if (current?.goalId !== candidate.goalId || current.status !== "active") { + if (current?.goalId !== candidate.goalId) { + return false; + } + // Codex P2 (PRRT_kwDOPxxmWM6cPbjX): a suspended candidate can belong to + // a budget_limited goal (e.g. an auto-promoted revival whose retained + // spend already exceeds its budget) — the active-only rule would strand + // the owed wrap-up until a restart reconstructs it. Accept the restore + // when the durable record still owes its wrap-up: not user-suppressed + // and not already injected. Source-agnostic on purpose: eligibility's + // budget_limited branch dispatches the one-shot wrap-up from any + // candidate source. Other statuses keep the active-only rule (a + // pause/completion during classification wins). + const wrapupStillOwed = + current.status === "budget_limited" && + current.budgetLimitOriginKind !== "user" && + current.budgetLimitInjectedForGoalId !== current.goalId; + if (current.status !== "active" && !wrapupStillOwed) { return false; } this.pendingContinuationCandidates.set(workspaceId, candidate); From e9afaf246e345eeb07879ec570dfd40175518d44 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 21:12:07 +0000 Subject: [PATCH 38/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2033=20?= =?UTF-8?q?=E2=80=94=20goal-identity=20generation=20in=20admission=20probe?= =?UTF-8?q?s;=20compaction=20follow-ups=20revalidate=20goal=20admission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) An active→active goal replacement bumps neither the pause nor the terminal generation, and the replaced goal's candidate can stay installed until the replacement's kickoff finalizer arms — a captured continuation for goal A could be admitted after goal B became durable, charging B's budget for A's work. writeGoal now bumps a per-workspace goal-identity generation whenever the written goalId changes (first write of the process also bumps, failing closed); both dispatch probes check it. (2) Compaction follow-ups preserve goal identity but lost their original requireIdle / admissionStale guards: goal-scoped follow-ups now enforce the idle rule unconditionally (a user message queued during compaction wins), revalidate durable goal admission via buildGoalRedispatchAdmission before redispatching (an explicit Pause/replacement/completion/suppression persisted during the compaction stream vetoes and clears the follow-up), and carry a fresh pause/terminal/identity staleness probe through the redispatched send's admission gates. --- .../agentSession.goalAutoPause.test.ts | 61 ++++++++++++- src/node/services/agentSession.ts | 34 +++++++- .../services/workspaceGoalService.test.ts | 82 ++++++++++++++++++ src/node/services/workspaceGoalService.ts | 86 ++++++++++++++++++- 4 files changed, 259 insertions(+), 4 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 2e14c0fa222..bab2b182e20 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -12,7 +12,7 @@ import { createMuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { GoalRecordV1, GoalStatus } from "@/common/types/goal"; -import { GOAL_CONTINUATION_IDLE_CONSUMER_NAME } from "@/constants/goals"; +import { GOAL_CONTINUATION_IDLE_CONSUMER_NAME, GOAL_CONTINUATION_KIND } from "@/constants/goals"; import { waitForCondition } from "./testDispatchHelpers"; import { IdleDispatcher } from "./idleDispatcher"; @@ -242,6 +242,65 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("a paused goal vetoes a redispatched compaction follow-up continuation", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): the compaction handoff preserves the + // continuation's goal identity but not its original requireIdle / + // admissionStale guards. An explicit Pause persisted while the compaction + // stream ran must veto the redispatch instead of letting the synthetic + // row land after the pause boundary as fresh active evidence. + const workspaceId = "compaction-followup-paused-veto"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { + workspaceId, + objective: "Paused during compaction", + }); + // Pause persists first (its boundary row lands), then the compaction + // summary carrying the goal-scoped follow-up commits. + await setGoalOk(goalService, { workspaceId, status: "paused" }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + timestamp: Date.now(), + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Continue working on the goal.", + agentId: "exec", + model: "openai:gpt-4o", + agentInitiated: true, + goalKind: GOAL_CONTINUATION_KIND, + goalId: created.goalId, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + + const sendSpy = spyOn(session, "sendMessage").mockImplementation(() => + Promise.resolve(Ok(undefined)) + ); + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + sendSpy.mockRestore(); + + // Vetoed: nothing dispatched, and the stale follow-up was cleared so it + // cannot re-fire on a later recovery pass. + expect(dispatched).toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + const tail = await historyService.getLastMessages(workspaceId, 1); + expect(tail.success).toBe(true); + if (tail.success) { + const meta = tail.data[0]?.metadata?.muxMetadata; + expect(meta && "pendingFollowUp" in meta ? meta.pendingFollowUp : undefined).toBeUndefined(); + } + session.dispose(); + }); + test("synthetic messages do not auto-pause active goals", async () => { const workspaceId = "synthetic-does-not-pause"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 0b2685a4d8a..b93847156de 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6889,8 +6889,12 @@ export class AgentSession { const hasQueuedMessages = this.hasPendingManualFollowUp(); const hasActiveNonCompletingTurn = this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING; + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): goal-loop follow-ups were originally + // requireIdle sends — enforce the idle rule for them unconditionally so a + // user message queued during the compaction stream wins the race instead + // of the synthetic continuation starting first. if ( - followUp.dispatchOptions?.requireIdle === true && + (followUp.dispatchOptions?.requireIdle === true || followUp.goalKind != null) && (hasQueuedMessages || hasActiveNonCompletingTurn) ) { log.info("Skipping pending follow-up because the workspace is no longer idle", { @@ -6927,6 +6931,31 @@ export class AgentSession { // DEFAULT_MODEL is a safe fallback that's always available. const effectiveModel = followUp.model ?? DEFAULT_MODEL; + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): the durable handoff preserves the + // goal identity but not the original send's admission guards. An explicit + // Pause (or a replacement/completion/suppression) persisted while the + // compaction stream ran must veto the redispatch — otherwise the + // synthetic row lands after the pause boundary and reads as fresh active + // evidence. Revalidate against durable goal state and carry a fresh + // staleness probe through the redispatched send's admission gates. + let goalAdmissionStale: (() => boolean) | undefined; + if (followUp.goalKind != null && followUp.goalId != null && this.workspaceGoalService) { + const admission = await this.workspaceGoalService.buildGoalRedispatchAdmission( + this.workspaceId, + followUp.goalId, + followUp.goalKind + ); + if (!admission.admissible) { + log.info("Skipping goal-scoped pending follow-up: goal no longer admits it", { + workspaceId: this.workspaceId, + goalKind: followUp.goalKind, + }); + await this.clearPendingFollowUpFromSummary(lastMessage); + return false; + } + goalAdmissionStale = admission.admissionStale; + } + log.debug("Dispatching pending follow-up from compaction summary", { workspaceId: this.workspaceId, hasText: Boolean(followUp.text), @@ -6997,6 +7026,9 @@ export class AgentSession { // reconciliation (Codex P2 PRRT_kwDOPxxmWM6cIv2E). goalId: followUp.goalId, goalContinuation: followUp.goalKind === GOAL_CONTINUATION_KIND, + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): re-derived admission guard for the + // redispatched goal turn (see buildGoalRedispatchAdmission above). + admissionStale: goalAdmissionStale, }); if (!sendResult.success) { const message = this.extractRetryFailureMessage(sendResult.error) ?? sendResult.error.type; diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 3033cfcb04e..8cba64a0f72 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3241,6 +3241,88 @@ describe("WorkspaceGoalService", () => { expect(capturedProbe!()).toBe(true); }); + test("the continuation admission probe goes stale when the goal is replaced", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cPuM6): an active→active replacement bumps + // neither the pause nor the terminal generation, and the replaced goal's + // candidate can stay installed until the replacement's kickoff finalizer + // arms — the captured continuation for goal A would be admitted after + // goal B is durable and its accounting would charge B for A's work. The + // identity generation must flip the probe. + const dispatcher = new IdleDispatcher(); + let capturedProbe: (() => boolean) | undefined; + let kickoffOptionsAvailable = true; + service.registerGoalContinuationConsumer(dispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => ({ isRuntimeCompatible: true }), + executeGoalContinuation: (input) => { + capturedProbe = input.admissionStale; + return Promise.resolve(false); + }, + getKickoffSendOptions: () => + Promise.resolve( + kickoffOptionsAvailable ? { model: "openai:gpt-4o", agentId: "exec" } : null + ), + }); + await setGoalOk(service, { workspaceId, objective: "Goal A" }); + await drainPendingDispatches(); + await waitForCondition(() => capturedProbe != null, { timeoutMs: 5_000 }); + expect(capturedProbe!()).toBe(false); + + // Models B's kickoff finalizer not having armed yet (no send options → + // arming skipped): the candidate reference cannot flip the probe, only + // the identity change can. + kickoffOptionsAvailable = false; + const candidates = ( + service as unknown as { pendingContinuationCandidates: Map } + ).pendingContinuationCandidates; + const candidateBefore = candidates.get(workspaceId); + await setGoalOk(service, { workspaceId, objective: "Goal B replaces A" }); + // Precondition for the clause under test: the candidate did not change. + expect(candidates.get(workspaceId)).toBe(candidateBefore); + expect(capturedProbe!()).toBe(true); + }); + + test("goal redispatch admission revalidates durable state and observes later transitions", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): a redispatched goal turn (compaction + // follow-up) lost its original admission guards. buildGoalRedispatchAdmission + // re-derives them: durable revalidation plus a staleness probe. + const created = await setGoalOk(service, { workspaceId, objective: "Redispatch admission" }); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + expect(admission.admissible).toBe(true); + if (!admission.admissible) { + throw new Error("expected admissible"); + } + expect(admission.admissionStale()).toBe(false); + + // An explicit pause after the build flips the probe; a rebuild refuses. + await setGoalOk(service, { workspaceId, status: "paused" }); + expect(admission.admissionStale()).toBe(true); + expect( + ( + await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ) + ).admissible + ).toBe(false); + + // Identity mismatch refuses outright. + expect( + ( + await service.buildGoalRedispatchAdmission( + workspaceId, + "other-goal", + GOAL_CONTINUATION_KIND + ) + ).admissible + ).toBe(false); + }); + test("a stop landing during auto-promotion reads restores the completed goal", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): maybeAutoPromoteOnComplete awaits // board/streaming/pricing reads after the caller's publication sample. A diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 4b0d21b843e..864f21e3de0 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -582,6 +582,22 @@ export class WorkspaceGoalService { */ private readonly terminalStatusGenerations = new Map(); + /** + * Codex P1 (PRRT_kwDOPxxmWM6cPuM6): monotonic per-workspace count of + * durable writes that change the goal identity (bumped inside `writeGoal` + * when the written goalId differs from the last one written by this + * process). An active→active replacement bumps neither the pause nor the + * terminal generation, and the replaced goal's candidate can stay + * installed until the replacement's kickoff finalizer arms — without this, + * a captured continuation for goal A could be admitted after goal B became + * durable and its stream accounting would charge B for A's work. The first + * write of a process also bumps (last-written unknown): a spuriously + * refused send just re-requests dispatch, whereas a missed replacement + * corrupts the successor's budget. + */ + private readonly goalIdentityGenerations = new Map(); + private readonly lastWrittenGoalIds = new Map(); + private armPauseFinalizationHold(workspaceId: string, goalId: string): void { this.explicitPauseGenerations.set( workspaceId, @@ -1113,6 +1129,15 @@ export class WorkspaceGoalService { (this.terminalStatusGenerations.get(workspaceId) ?? 0) + 1 ); } + // See goalIdentityGenerations: identity changes (replacement, revival, + // first write of the process) invalidate captured dispatch admissions. + if (this.lastWrittenGoalIds.get(workspaceId) !== goal.goalId) { + this.lastWrittenGoalIds.set(workspaceId, goal.goalId); + this.goalIdentityGenerations.set( + workspaceId, + (this.goalIdentityGenerations.get(workspaceId) ?? 0) + 1 + ); + } } private async renameCorruptGoal( @@ -1512,6 +1537,50 @@ export class WorkspaceGoalService { }); } + /** + * Codex P1 (PRRT_kwDOPxxmWM6cPuMw): admission revalidation for a goal-loop + * synthetic turn that is being REDISPATCHED outside its original guarded + * send — e.g. a compaction follow-up whose original `requireIdle` + + * `admissionStale` guards did not survive the durable handoff. The + * original closure cannot be persisted, so re-derive: verify the durable + * goal still admits the kind (continuation → active; budget wrap-up → + * budget_limited and still owed) and return a fresh staleness probe over + * the pause/terminal/identity generations for the send's admission gates. + */ + async buildGoalRedispatchAdmission( + workspaceId: string, + goalId: string, + kind: GoalSyntheticMessageKind + ): Promise<{ admissible: false } | { admissible: true; admissionStale: () => boolean }> { + assert(workspaceId.trim().length > 0, "buildGoalRedispatchAdmission requires workspaceId"); + assert(goalId.trim().length > 0, "buildGoalRedispatchAdmission requires goalId"); + const current = await this.readGoalFile(workspaceId); + if (current?.goalId !== goalId || current.requireUserAcknowledgmentSinceMs != null) { + return { admissible: false }; + } + if (kind === GOAL_BUDGET_LIMIT_KIND) { + const wrapupStillOwed = + current.status === "budget_limited" && + current.budgetLimitOriginKind !== "user" && + current.budgetLimitInjectedForGoalId !== goalId; + if (!wrapupStillOwed) { + return { admissible: false }; + } + } else if (current.status !== "active") { + return { admissible: false }; + } + const pauseGenerationAtBuild = this.explicitPauseGenerations.get(workspaceId) ?? 0; + const terminalGenerationAtBuild = this.terminalStatusGenerations.get(workspaceId) ?? 0; + const identityGenerationAtBuild = this.goalIdentityGenerations.get(workspaceId) ?? 0; + return { + admissible: true, + admissionStale: () => + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtBuild || + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild, + }; + } + /** * Treat an agent's text-only `goal_continuation` turn as implicit * completion. The continuation prompt asks the agent to call @@ -1709,6 +1778,10 @@ export class WorkspaceGoalService { // updates) up to the last gate before the send is irrevocable. const wrapupTerminalGenerationAtDispatch = this.terminalStatusGenerations.get(workspaceId) ?? 0; + // Codex P1 (PRRT_kwDOPxxmWM6cPuM6): replacement of the + // budget-limited goal during the preflight is an identity change. + const wrapupIdentityGenerationAtDispatch = + this.goalIdentityGenerations.get(workspaceId) ?? 0; const accepted = await this.goalContinuationBridge?.executeGoalContinuation({ workspaceId, message, @@ -1729,7 +1802,9 @@ export class WorkspaceGoalService { } return ( (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== - wrapupTerminalGenerationAtDispatch + wrapupTerminalGenerationAtDispatch || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== + wrapupIdentityGenerationAtDispatch ); }, }); @@ -1784,6 +1859,11 @@ export class WorkspaceGoalService { // generation covers those, refusing a normal continuation against a // completed goal or one that now owes the budget wrap-up instead. const terminalGenerationAtDispatch = this.terminalStatusGenerations.get(workspaceId) ?? 0; + // Codex P1 (PRRT_kwDOPxxmWM6cPuM6): an active→active replacement + // bumps neither generation above and may leave this candidate + // installed until the replacement's kickoff finalizer arms — the + // identity generation covers it. + const identityGenerationAtDispatch = this.goalIdentityGenerations.get(workspaceId) ?? 0; const accepted = await this.goalContinuationBridge?.executeGoalContinuation({ workspaceId, message, @@ -1794,7 +1874,9 @@ export class WorkspaceGoalService { admissionStale: () => this.pendingContinuationCandidates.get(workspaceId) !== candidate || (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtDispatch || - (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtDispatch, + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== + terminalGenerationAtDispatch || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtDispatch, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); From 27927435b9b697fdba61d006128be66af7c013ea Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 21:31:10 +0000 Subject: [PATCH 39/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2034=20?= =?UTF-8?q?=E2=80=94=20close=20redispatch=20admission=20races?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot admission generations before durable reads, invalidate probes when goals are cleared, and let compacted wrap-ups consume their matching reservation. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$482.83`_ --- .../services/workspaceGoalService.test.ts | 90 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 39 ++++++-- 2 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 8cba64a0f72..8e4b08e7905 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3323,6 +3323,96 @@ describe("WorkspaceGoalService", () => { ).toBe(false); }); + test("goal redispatch admission rejects a goal transition that commits during its state read", async () => { + const created = await setGoalOk(service, { + workspaceId, + objective: "Pause races redispatch read", + }); + const serviceAccess = service as unknown as { + readGoalFile: (id: string) => Promise; + }; + const realReadGoalFile = serviceAccess.readGoalFile.bind(service); + let releaseStaleRead!: () => void; + const staleReadGate = new Promise((resolve) => { + releaseStaleRead = resolve; + }); + let staleReadCaptured = false; + const readSpy = spyOn(serviceAccess, "readGoalFile").mockImplementationOnce( + async (id: string) => { + const staleGoal = await realReadGoalFile(id); + staleReadCaptured = true; + await staleReadGate; + return staleGoal; + } + ); + + const admissionPromise = service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + await waitForCondition(() => staleReadCaptured, { timeoutMs: 5_000 }); + await setGoalOk(service, { workspaceId, status: "paused" }); + releaseStaleRead(); + + expect((await admissionPromise).admissible).toBe(false); + readSpy.mockRestore(); + }); + + test("goal redispatch admission goes stale when the goal is cleared", async () => { + const created = await setGoalOk(service, { + workspaceId, + objective: "Clear invalidates redispatch", + }); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + expect(admission.admissible).toBe(true); + if (!admission.admissible) { + throw new Error("expected admissible"); + } + + await service.clearGoal(workspaceId); + + expect(admission.admissionStale()).toBe(true); + }); + + test("goal redispatch admission accepts a compacted wrap-up that owns its reservation", async () => { + const created = await setGoalOk(service, { + workspaceId, + objective: "Compacted wrap-up keeps reservation", + budgetCents: 100, + }); + const dispatcher = new IdleDispatcher(); + service.registerGoalContinuationConsumer(dispatcher, continuationBridge()); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + await service.requestContinuationAfterStreamEnd({ + workspaceId, + sendOptions: { model: "openai:gpt-4o", agentId: "exec" }, + streamEndedAtMs: created.createdAtMs + 2, + }); + await drainPendingDispatches(); + expect(await service.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitInjectedForGoalId: created.goalId, + }); + + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_BUDGET_LIMIT_KIND + ); + + expect(admission.admissible).toBe(true); + }); + test("a stop landing during auto-promotion reads restores the completed goal", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): maybeAutoPromoteOnComplete awaits // board/streaming/pricing reads after the caller's publication sample. A diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 864f21e3de0..64c0c30ca0f 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -1544,7 +1544,8 @@ export class WorkspaceGoalService { * `admissionStale` guards did not survive the durable handoff. The * original closure cannot be persisted, so re-derive: verify the durable * goal still admits the kind (continuation → active; budget wrap-up → - * budget_limited and still owed) and return a fresh staleness probe over + * budget_limited, unsuppressed, and either unreserved or reserved by this + * persisted follow-up) and return a fresh staleness probe over * the pause/terminal/identity generations for the send's admission gates. */ async buildGoalRedispatchAdmission( @@ -1554,24 +1555,38 @@ export class WorkspaceGoalService { ): Promise<{ admissible: false } | { admissible: true; admissionStale: () => boolean }> { assert(workspaceId.trim().length > 0, "buildGoalRedispatchAdmission requires workspaceId"); assert(goalId.trim().length > 0, "buildGoalRedispatchAdmission requires goalId"); + // Snapshot before the async read. A transition may commit after readFile + // captured the old bytes but before it resolves; pairing that stale record + // with post-transition baselines would make the returned probe look fresh. + const pauseGenerationAtBuild = this.explicitPauseGenerations.get(workspaceId) ?? 0; + const terminalGenerationAtBuild = this.terminalStatusGenerations.get(workspaceId) ?? 0; + const identityGenerationAtBuild = this.goalIdentityGenerations.get(workspaceId) ?? 0; const current = await this.readGoalFile(workspaceId); - if (current?.goalId !== goalId || current.requireUserAcknowledgmentSinceMs != null) { + if ( + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtBuild || + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild || + current?.goalId !== goalId || + current.requireUserAcknowledgmentSinceMs != null + ) { return { admissible: false }; } if (kind === GOAL_BUDGET_LIMIT_KIND) { - const wrapupStillOwed = + // This method is called for the persisted compaction follow-up. If the + // original wrap-up send was accepted as an on-send compaction request, + // it already reserved this goal ID before the real follow-up dispatches; + // the matching pending follow-up owns that reservation. + const wrapupAdmitted = current.status === "budget_limited" && current.budgetLimitOriginKind !== "user" && - current.budgetLimitInjectedForGoalId !== goalId; - if (!wrapupStillOwed) { + (current.budgetLimitInjectedForGoalId == null || + current.budgetLimitInjectedForGoalId === goalId); + if (!wrapupAdmitted) { return { admissible: false }; } } else if (current.status !== "active") { return { admissible: false }; } - const pauseGenerationAtBuild = this.explicitPauseGenerations.get(workspaceId) ?? 0; - const terminalGenerationAtBuild = this.terminalStatusGenerations.get(workspaceId) ?? 0; - const identityGenerationAtBuild = this.goalIdentityGenerations.get(workspaceId) ?? 0; return { admissible: true, admissionStale: () => @@ -4110,6 +4125,14 @@ export class WorkspaceGoalService { ); await fs.rm(this.getFilePath(workspaceId), { force: true }); + // Goal deletion is an identity transition too. In-flight admissions only + // observe generation counters after their initial durable read, so clear + // must invalidate them even when no upcoming goal is promoted. + this.lastWrittenGoalIds.delete(workspaceId); + this.goalIdentityGenerations.set( + workspaceId, + (this.goalIdentityGenerations.get(workspaceId) ?? 0) + 1 + ); await this.pushSnapshot(workspaceId, null); this.emitLifecycle("goal_cleared", { finalStatus: current.status, From f308112863f39d377717f3b31afd15ece1f87b41 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 21:45:48 +0000 Subject: [PATCH 40/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2035=20?= =?UTF-8?q?=E2=80=94=20validate=20persisted=20follow-up=20goal=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject malformed goal attribution at the unchecked compaction-summary boundary, clear the corrupt pending follow-up, and forward only validated values into goal recovery. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$502.42`_ --- .../agentSession.goalAutoPause.test.ts | 44 +++++++++++++++++++ src/node/services/agentSession.ts | 41 ++++++++++++----- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index bab2b182e20..9d81ad0f942 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -301,6 +301,50 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("malformed persisted follow-up goal IDs are discarded during recovery", async () => { + const workspaceId = "compaction-followup-malformed-goal-id"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + await setGoalOk(goalService, { workspaceId, objective: "Recover safely" }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Continue working on the goal.", + agentId: "exec", + model: "openai:gpt-4o", + goalKind: GOAL_CONTINUATION_KIND, + goalId: { malformed: true } as unknown as string, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + const sendSpy = spyOn(session, "sendMessage").mockImplementation(() => + Promise.resolve(Ok(undefined)) + ); + + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + + expect(dispatched).toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + const tail = await historyService.getLastMessages(workspaceId, 1); + expect(tail.success).toBe(true); + if (tail.success) { + const meta = tail.data[0]?.metadata?.muxMetadata; + expect(meta && "pendingFollowUp" in meta ? meta.pendingFollowUp : undefined).toBeUndefined(); + } + sendSpy.mockRestore(); + session.dispose(); + }); + test("synthetic messages do not auto-pause active goals", async () => { const workspaceId = "synthetic-does-not-pause"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index b93847156de..6982636c453 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -245,6 +245,10 @@ function coerceGoalSyntheticMessageKind(value: unknown): GoalSyntheticMessageKin return undefined; } +function coerceGoalId(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + const PDF_MEDIA_TYPE = "application/pdf"; const ACP_PROMPT_ID_METADATA_KEY = "acpPromptId"; const ACP_DELEGATED_TOOLS_METADATA_KEY = "acpDelegatedTools"; @@ -6887,6 +6891,23 @@ export class AgentSession { imageParts?: FilePart[]; }; + // Compaction summaries are unchecked chat.jsonl. Reject malformed persisted + // goal attribution instead of forwarding it into goal-service assertions or + // repeatedly crashing startup recovery on the same row. + const persistedGoalKind = coerceGoalSyntheticMessageKind(followUp.goalKind); + const persistedGoalId = coerceGoalId(followUp.goalId); + if ( + (followUp.goalKind !== undefined && persistedGoalKind == null) || + (followUp.goalId !== undefined && persistedGoalId == null) + ) { + log.warn("Discarding pending follow-up with malformed goal attribution", { + workspaceId: this.workspaceId, + summaryMessageId: lastMessage.id, + }); + await this.clearPendingFollowUpFromSummary(lastMessage); + return false; + } + const hasQueuedMessages = this.hasPendingManualFollowUp(); const hasActiveNonCompletingTurn = this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING; // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): goal-loop follow-ups were originally @@ -6894,7 +6915,7 @@ export class AgentSession { // user message queued during the compaction stream wins the race instead // of the synthetic continuation starting first. if ( - (followUp.dispatchOptions?.requireIdle === true || followUp.goalKind != null) && + (followUp.dispatchOptions?.requireIdle === true || persistedGoalKind != null) && (hasQueuedMessages || hasActiveNonCompletingTurn) ) { log.info("Skipping pending follow-up because the workspace is no longer idle", { @@ -6939,16 +6960,16 @@ export class AgentSession { // evidence. Revalidate against durable goal state and carry a fresh // staleness probe through the redispatched send's admission gates. let goalAdmissionStale: (() => boolean) | undefined; - if (followUp.goalKind != null && followUp.goalId != null && this.workspaceGoalService) { + if (persistedGoalKind != null && persistedGoalId != null && this.workspaceGoalService) { const admission = await this.workspaceGoalService.buildGoalRedispatchAdmission( this.workspaceId, - followUp.goalId, - followUp.goalKind + persistedGoalId, + persistedGoalKind ); if (!admission.admissible) { log.info("Skipping goal-scoped pending follow-up: goal no longer admits it", { workspaceId: this.workspaceId, - goalKind: followUp.goalKind, + goalKind: persistedGoalKind, }); await this.clearPendingFollowUpFromSummary(lastMessage); return false; @@ -7008,8 +7029,8 @@ export class AgentSession { this.setAutoRetryResumeState( options, followUp.agentInitiated, - followUp.goalKind, - followUp.goalId + persistedGoalKind, + persistedGoalId ); // Await sendMessage to ensure the follow-up is persisted before returning. @@ -7020,12 +7041,12 @@ export class AgentSession { const sendResult = await this.sendMessage(finalText, options, { synthetic: true, agentInitiated: followUp.agentInitiated, - goalKind: followUp.goalKind, + goalKind: persistedGoalKind, // Keep the re-dispatched continuation row goal-scoped so a replaced // goal's follow-up cannot reactivate its successor during chat-tail // reconciliation (Codex P2 PRRT_kwDOPxxmWM6cIv2E). - goalId: followUp.goalId, - goalContinuation: followUp.goalKind === GOAL_CONTINUATION_KIND, + goalId: persistedGoalId, + goalContinuation: persistedGoalKind === GOAL_CONTINUATION_KIND, // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): re-derived admission guard for the // redispatched goal turn (see buildGoalRedispatchAdmission above). admissionStale: goalAdmissionStale, From 9f4cad147d2e7a18380780f731acfd15e77b4db8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:02:57 +0000 Subject: [PATCH 41/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2036=20?= =?UTF-8?q?=E2=80=94=20live=20idle=20probe=20for=20redispatched=20follow-u?= =?UTF-8?q?ps;=20validate=20retry=20goal=20IDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry the queue/busy idle rule through the redispatched follow-up's send-admission gates, treat stale-admission refusals as skips, and discard startup-retry goal attribution when the persisted goal ID is present but malformed. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$502.42`_ --- .../agentSession.goalAutoPause.test.ts | 63 +++++++++++ .../agentSession.startupAutoRetry.test.ts | 40 +++++++ src/node/services/agentSession.ts | 107 +++++++++++++----- 3 files changed, 182 insertions(+), 28 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 9d81ad0f942..b1fe338573a 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -301,6 +301,69 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("a manual message queued during redispatch preflight vetoes the follow-up", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cQt3j): the idle sample taken at entry ages + // across the awaited goal read; a manual message queued in that window + // must win instead of waiting behind the synthetic follow-up's stream. + const workspaceId = "compaction-followup-queued-mid-preflight"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { workspaceId, objective: "Idle race" }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Continue working on the goal.", + agentId: "exec", + model: "openai:gpt-4o", + goalKind: GOAL_CONTINUATION_KIND, + goalId: created.goalId, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + + // The entry idle check passes (queue empty); the manual message lands + // during the awaited admission read, after that sample. + const realBuild = goalService.buildGoalRedispatchAdmission.bind(goalService); + const buildSpy = spyOn(goalService, "buildGoalRedispatchAdmission").mockImplementationOnce( + async (...args: Parameters) => { + const admission = await realBuild(...args); + session.queueMessage("user returned mid-preflight", SEND_OPTIONS, { synthetic: false }); + return admission; + } + ); + + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + buildSpy.mockRestore(); + + expect(dispatched).toBe(false); + const history = await historyService.getLastMessages(workspaceId, 10); + expect(history.success).toBe(true); + if (history.success) { + // The synthetic follow-up row was refused (and rolled back), and the + // summary dropped its pending follow-up so it cannot re-fire later. + expect( + history.data.some((message) => + message.parts.some( + (part) => part.type === "text" && part.text === "Continue working on the goal." + ) + ) + ).toBe(false); + const meta = history.data.find((message) => message.id === summary.id)?.metadata?.muxMetadata; + expect(meta && "pendingFollowUp" in meta ? meta.pendingFollowUp : undefined).toBeUndefined(); + } + session.dispose(); + }); + test("malformed persisted follow-up goal IDs are discarded during recovery", async () => { const workspaceId = "compaction-followup-malformed-goal-id"; const { session, goalService, historyService, cleanup } = diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 80d73889d2f..248933ab3d2 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -623,6 +623,46 @@ describe("AgentSession startup auto-retry recovery", () => { session.dispose(); }); + test("startup auto-retry discards goal attribution when the persisted goal ID is malformed", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cQt3o): chat.jsonl is unchecked JSON. A + // present-but-invalid goalId must not resume the turn as goal-driven with + // untrustworthy identity — a later compaction would persist a missing-ID + // follow-up that bypasses buildGoalRedispatchAdmission entirely. + const workspaceId = "startup-retry-malformed-goal-id"; + const { session, historyService, cleanup } = await createSessionBundle(workspaceId); + cleanups.push(cleanup); + + const appendResult = await historyService.appendToHistory( + workspaceId, + createMuxMessage("user-1", "user", "Interrupted goal turn", { + timestamp: Date.now(), + kind: GOAL_CONTINUATION_KIND, + goalId: "" as unknown as string, + retrySendOptions: { + model: "anthropic:claude-sonnet-4-5", + agentId: "exec", + goalKind: GOAL_CONTINUATION_KIND, + }, + }) + ); + expect(appendResult.success).toBe(true); + + session.ensureStartupAutoRetryCheck(); + await (session as unknown as { startupAutoRetryCheckPromise: Promise | null }) + .startupAutoRetryCheckPromise; + + const retryOptions = ( + session as unknown as { + lastAutoRetryResumeRequest?: AutoRetryResumeRequest & { goalId?: string }; + } + ).lastAutoRetryResumeRequest; + expect(retryOptions).toBeDefined(); + expect(retryOptions?.goalKind).toBeUndefined(); + expect(retryOptions?.goalId).toBeUndefined(); + + session.dispose(); + }); + test("startup auto-retry prefers child workspace agent settings over stale retry metadata", async () => { const workspaceId = "startup-retry-child-stale-agent"; const workspaceMetadata: WorkspaceMetadata = { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 6982636c453..c46ac19591b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1830,16 +1830,23 @@ export class AgentSession { const workspaceMetadata = await this.getWorkspaceMetadataForRetry(); const persistedRetrySendOptions = lastUserMessage?.metadata?.retrySendOptions; - const persistedGoalKind = - coerceGoalSyntheticMessageKind(persistedRetrySendOptions?.goalKind) ?? - coerceGoalSyntheticMessageKind(lastUserMessage?.metadata?.kind); // The user row's own metadata.goalId is the durable copy (stamped next to // `kind`); recover it so resumed streams keep goal-scoped compaction - // follow-ups (Codex P2 PRRT_kwDOPxxmWM6cIv2E). + // follow-ups (Codex P2 PRRT_kwDOPxxmWM6cIv2E). Chat metadata is unchecked + // JSON (Codex P2 PRRT_kwDOPxxmWM6cQt3o): a PRESENT-but-invalid goalId must + // not resume the turn as goal-driven with untrustworthy identity — a later + // compaction would persist a missing-ID follow-up that bypasses + // buildGoalRedispatchAdmission. Absent IDs keep legacy unscoped semantics; + // present-invalid values discard the row's goal attribution entirely. + const rawPersistedGoalId: unknown = lastUserMessage?.metadata?.goalId; + const goalAttributionCorrupt = + rawPersistedGoalId !== undefined && coerceGoalId(rawPersistedGoalId) == null; + const persistedGoalKind = goalAttributionCorrupt + ? undefined + : (coerceGoalSyntheticMessageKind(persistedRetrySendOptions?.goalKind) ?? + coerceGoalSyntheticMessageKind(lastUserMessage?.metadata?.kind)); const persistedGoalId = - persistedGoalKind != null && typeof lastUserMessage?.metadata?.goalId === "string" - ? lastUserMessage.metadata.goalId - : undefined; + persistedGoalKind != null ? coerceGoalId(rawPersistedGoalId) : undefined; const workspaceAgentIdCandidates = resolvePersistedAgentIdCandidates(workspaceMetadata); const workspaceAgentId = workspaceAgentIdCandidates[0] ?? WORKSPACE_DEFAULTS.agentId; @@ -6908,36 +6915,22 @@ export class AgentSession { return false; } - const hasQueuedMessages = this.hasPendingManualFollowUp(); - const hasActiveNonCompletingTurn = this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING; // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): goal-loop follow-ups were originally // requireIdle sends — enforce the idle rule for them unconditionally so a // user message queued during the compaction stream wins the race instead // of the synthetic continuation starting first. - if ( - (followUp.dispatchOptions?.requireIdle === true || persistedGoalKind != null) && - (hasQueuedMessages || hasActiveNonCompletingTurn) - ) { + const enforceIdleRule = + followUp.dispatchOptions?.requireIdle === true || persistedGoalKind != null; + const hasQueuedMessages = this.hasPendingManualFollowUp(); + const hasActiveNonCompletingTurn = this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING; + if (enforceIdleRule && (hasQueuedMessages || hasActiveNonCompletingTurn)) { log.info("Skipping pending follow-up because the workspace is no longer idle", { workspaceId: this.workspaceId, summaryMessageId: lastMessage.id, hasQueuedMessages, turnPhase: this.turnPhase, }); - if ( - lastMessage.metadata?.compacted === "heartbeat" && - hasQueuedMessages && - !hasActiveNonCompletingTurn - ) { - const rollbackResult = - await this.compactionHandler.rollbackHeartbeatContextResetBoundary(lastMessage); - if (!rollbackResult.success) { - throw new Error(`Failed to rollback heartbeat reset boundary: ${rollbackResult.error}`); - } - this.onPostCompactionStateChange?.(); - } else { - await this.clearPendingFollowUpFromSummary(lastMessage); - } + await this.skipIdleRuleFollowUp(lastMessage, hasQueuedMessages, hasActiveNonCompletingTurn); return false; } @@ -6977,6 +6970,22 @@ export class AgentSession { goalAdmissionStale = admission.admissionStale; } + // Codex P1 (PRRT_kwDOPxxmWM6cQt3j): the queue/busy sample above ages + // across the awaited goal read and the send's own preflight. Re-evaluate + // the idle rule through the send-admission gates — all of them run before + // this send claims the turn phase, so the probe cannot self-trip — and a + // manual message queued during those awaits wins instead of waiting + // behind the synthetic follow-up's stream. + const idleRuleStale = enforceIdleRule + ? () => + this.hasPendingManualFollowUp() || + (this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING) + : undefined; + const followUpAdmissionStale = + idleRuleStale != null || goalAdmissionStale != null + ? () => idleRuleStale?.() === true || goalAdmissionStale?.() === true + : undefined; + log.debug("Dispatching pending follow-up from compaction summary", { workspaceId: this.workspaceId, hasText: Boolean(followUp.text), @@ -7049,9 +7058,24 @@ export class AgentSession { goalContinuation: persistedGoalKind === GOAL_CONTINUATION_KIND, // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): re-derived admission guard for the // redispatched goal turn (see buildGoalRedispatchAdmission above). - admissionStale: goalAdmissionStale, + admissionStale: followUpAdmissionStale, }); if (!sendResult.success) { + // A stale-admission refusal is the idle rule (or a goal transition) + // working as intended, not a recovery failure: route it through the + // same skip path as the pre-send check instead of throwing. + if (followUpAdmissionStale?.() === true) { + log.info("Pending follow-up refused at send admission; skipping it", { + workspaceId: this.workspaceId, + summaryMessageId: lastMessage.id, + }); + await this.skipIdleRuleFollowUp( + lastMessage, + this.hasPendingManualFollowUp(), + this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING + ); + return false; + } const message = this.extractRetryFailureMessage(sendResult.error) ?? sendResult.error.type; throw new Error(`Failed to dispatch pending follow-up: ${message}`); } @@ -7059,6 +7083,33 @@ export class AgentSession { return true; } + /** + * Shared skip path for a pending follow-up vetoed by the idle rule (or a + * stale goal admission): heartbeat reset boundaries are rolled back so the + * queued user turn sees pre-reset context; every other summary just drops + * its pending follow-up so it cannot re-fire on a later recovery pass. + */ + private async skipIdleRuleFollowUp( + summaryMessage: MuxMessage, + hasQueuedMessages: boolean, + hasActiveNonCompletingTurn: boolean + ): Promise { + if ( + summaryMessage.metadata?.compacted === "heartbeat" && + hasQueuedMessages && + !hasActiveNonCompletingTurn + ) { + const rollbackResult = + await this.compactionHandler.rollbackHeartbeatContextResetBoundary(summaryMessage); + if (!rollbackResult.success) { + throw new Error(`Failed to rollback heartbeat reset boundary: ${rollbackResult.error}`); + } + this.onPostCompactionStateChange?.(); + } else { + await this.clearPendingFollowUpFromSummary(summaryMessage); + } + } + private async clearPendingFollowUpFromSummary(summaryMessage: MuxMessage): Promise { assert( summaryMessage.role === "assistant", From c205e21ba19f12c7de316ad495a5269a416ab207 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 22:32:12 +0000 Subject: [PATCH 42/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2037=20?= =?UTF-8?q?=E2=80=94=20preflight-visible=20idle=20probe;=20UUID=20goal-ID?= =?UTF-8?q?=20validation;=20recovered=20wrap-up=20reservation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose WorkspaceService send preflights to redispatched follow-up idle probes, validate goal-scoping IDs against the durable UUID contract, and install the missing wrap-up reservation when a crash-recovered budget follow-up dispatches. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$502.42`_ --- src/common/orpc/schemas/goal.ts | 9 +- src/common/types/goal.ts | 19 ++ .../agentSession.goalAutoPause.test.ts | 175 +++++++++++++++++- src/node/services/agentSession.ts | 46 ++++- .../services/workspaceGoalService.test.ts | 37 ++++ src/node/services/workspaceGoalService.ts | 50 +++-- src/node/services/workspaceService.ts | 5 + 7 files changed, 319 insertions(+), 22 deletions(-) diff --git a/src/common/orpc/schemas/goal.ts b/src/common/orpc/schemas/goal.ts index cf0e74d9fd1..d4e9af57a89 100644 --- a/src/common/orpc/schemas/goal.ts +++ b/src/common/orpc/schemas/goal.ts @@ -26,9 +26,16 @@ export const GoalBudgetLimitOriginKindSchema = z .enum(["goal_continuation", "goal_budget_limit", "user", "other"]) .nullable(); +/** + * Durable goal identifiers are always UUIDs (`crypto.randomUUID()` at + * creation). Share this schema wherever an unchecked value claims to be a + * goal ID so validation stays as strict as the persisted record contract. + */ +export const GoalIdSchema = z.string().uuid(); + export const GoalRecordV1Schema = z.object({ version: z.literal(1), - goalId: z.string().uuid(), + goalId: GoalIdSchema, objective: z.string().min(1), status: GoalStatusSchema, budgetCents: z.number().int().nonnegative().nullable(), diff --git a/src/common/types/goal.ts b/src/common/types/goal.ts index fb425d982b4..bc9b289606f 100644 --- a/src/common/types/goal.ts +++ b/src/common/types/goal.ts @@ -11,6 +11,25 @@ import type { GoalSnapshotSchema, GoalStatusSchema, } from "@/common/orpc/schemas/goal"; +import { GoalIdSchema } from "@/common/orpc/schemas/goal"; + +/** + * Defensive validation for goal-scoping IDs read from unchecked persisted + * metadata (chat.jsonl rows, compaction summaries). Durable goal IDs are + * always UUIDs (see GoalIdSchema), so any non-UUID value is corrupt data — + * not another goal's identity (Codex P2 PRRT_kwDOPxxmWM6cNxUY, + * PRRT_kwDOPxxmWM6cRJEC). + * + * Callers treat a present-but-invalid ID by failure direction (Codex P2 + * PRRT_kwDOPxxmWM6cOHpI): a pause BOUNDARY degrades to legacy unscoped + * semantics (conservative paused, never scoped), a CONTINUATION row is + * skipped outright (corrupt data must not manufacture activity evidence), + * and recovery paths discard the row's goal attribution entirely. + */ +export function toValidGoalId(value: unknown): string | null { + const parsed = GoalIdSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} export type GoalStatus = z.infer; export type GoalRecordV1 = z.infer; diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index b1fe338573a..154561232e5 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -12,7 +12,11 @@ import { createMuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { GoalRecordV1, GoalStatus } from "@/common/types/goal"; -import { GOAL_CONTINUATION_IDLE_CONSUMER_NAME, GOAL_CONTINUATION_KIND } from "@/constants/goals"; +import { + GOAL_BUDGET_LIMIT_KIND, + GOAL_CONTINUATION_IDLE_CONSUMER_NAME, + GOAL_CONTINUATION_KIND, +} from "@/constants/goals"; import { waitForCondition } from "./testDispatchHelpers"; import { IdleDispatcher } from "./idleDispatcher"; @@ -364,6 +368,175 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("a service-level send preflight defers redispatched follow-ups", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cRJD-): a manual WorkspaceService send can be + // counted in preflight without queueing or holding the turn phase. The + // entry idle check must consult the injected probe. + const workspaceId = "compaction-followup-service-preflight"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { workspaceId, objective: "Preflight race" }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Continue working on the goal.", + agentId: "exec", + model: "openai:gpt-4o", + goalKind: GOAL_CONTINUATION_KIND, + goalId: created.goalId, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + (session as unknown as { hasExternalSendPreflight?: () => boolean }).hasExternalSendPreflight = + () => true; + const sendSpy = spyOn(session, "sendMessage").mockImplementation(() => + Promise.resolve(Ok(undefined)) + ); + + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + + expect(dispatched).toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + const tail = await historyService.getLastMessages(workspaceId, 1); + expect(tail.success).toBe(true); + if (tail.success) { + const meta = tail.data[0]?.metadata?.muxMetadata; + expect(meta && "pendingFollowUp" in meta ? meta.pendingFollowUp : undefined).toBeUndefined(); + } + sendSpy.mockRestore(); + session.dispose(); + }); + + test("a service preflight starting mid-redispatch flips the live admission probe", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cRJD-): the preflight can also begin AFTER + // the entry sample, during the awaited goal read — the live probe carried + // through the send-admission gates must observe it. + const workspaceId = "compaction-followup-preflight-mid-read"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { workspaceId, objective: "Late preflight" }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Continue working on the goal.", + agentId: "exec", + model: "openai:gpt-4o", + goalKind: GOAL_CONTINUATION_KIND, + goalId: created.goalId, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + + let preflightActive = false; + (session as unknown as { hasExternalSendPreflight?: () => boolean }).hasExternalSendPreflight = + () => preflightActive; + const realBuild = goalService.buildGoalRedispatchAdmission.bind(goalService); + const buildSpy = spyOn(goalService, "buildGoalRedispatchAdmission").mockImplementationOnce( + async (...args: Parameters) => { + const admission = await realBuild(...args); + preflightActive = true; + return admission; + } + ); + + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + buildSpy.mockRestore(); + + expect(dispatched).toBe(false); + const history = await historyService.getLastMessages(workspaceId, 10); + expect(history.success).toBe(true); + if (history.success) { + expect( + history.data.some((message) => + message.parts.some( + (part) => part.type === "text" && part.text === "Continue working on the goal." + ) + ) + ).toBe(false); + } + session.dispose(); + }); + + test("a recovered budget wrap-up follow-up installs its missing reservation", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cRJEE): a crash between wrap-up send + // acceptance and tryMarkBudgetLimitInjected leaves the goal unmarked. + // The redispatched follow-up must install the reservation or the + // recovered stream's end arms a second wrap-up. + const workspaceId = "compaction-followup-wrapup-reservation"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { + workspaceId, + objective: "Recover the owed wrap-up", + budgetCents: 100, + }); + await goalService.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitInjectedForGoalId: null, + }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Wrap up the budget-limited goal.", + agentId: "exec", + model: "openai:gpt-4o", + goalKind: GOAL_BUDGET_LIMIT_KIND, + goalId: created.goalId, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + const sendSpy = spyOn(session, "sendMessage").mockImplementation(() => + Promise.resolve(Ok(undefined)) + ); + + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + + expect(dispatched).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + budgetLimitInjectedForGoalId: created.goalId, + }); + sendSpy.mockRestore(); + session.dispose(); + }); + test("malformed persisted follow-up goal IDs are discarded during recovery", async () => { const workspaceId = "compaction-followup-malformed-goal-id"; const { session, goalService, historyService, cleanup } = diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c46ac19591b..600cb7b7793 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -96,6 +96,7 @@ import { type ReviewNoteDataForDisplay, type StartupRetrySendOptions, } from "@/common/types/message"; +import { toValidGoalId } from "@/common/types/goal"; import { selectKeepRecentTailStartIndex } from "@/common/utils/messages/keepRecentTail"; import { extractReadFilePaths, mergeReadFilePaths } from "@/common/utils/messages/extractReadFiles"; import { isNonNegativeInteger } from "@/common/utils/numbers"; @@ -245,8 +246,10 @@ function coerceGoalSyntheticMessageKind(value: unknown): GoalSyntheticMessageKin return undefined; } +// Durable goal IDs are UUIDs — see toValidGoalId for the corruption contract +// (Codex P2 PRRT_kwDOPxxmWM6cRJEC extends it to every recovery-path reader). function coerceGoalId(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; + return toValidGoalId(value) ?? undefined; } const PDF_MEDIA_TYPE = "application/pdf"; @@ -534,6 +537,14 @@ interface AgentSessionOptions { onIdleCompactionOutcome?: (success: boolean) => void; /** Called when post-compaction context state may have changed (plan/file edits) */ onPostCompactionStateChange?: () => void; + /** + * Codex P1 (PRRT_kwDOPxxmWM6cRJD-): true while a service-level send is in + * its preflight (counted in WorkspaceService.preflightSendCounts but not + * yet queued or holding the turn phase). Session queue/phase state cannot + * see that window, so redispatched idle-rule follow-ups consult this probe + * to yield to a manual send that is still awaiting pricing/settings. + */ + hasExternalSendPreflight?: () => boolean; } enum TurnPhase { @@ -573,6 +584,7 @@ export class AgentSession { private readonly keepBackgroundProcesses: boolean; private readonly sanitizeCliWorkspaceRegistration?: AgentSessionOptions["sanitizeCliWorkspaceRegistration"]; private readonly onPostCompactionStateChange?: () => void; + private readonly hasExternalSendPreflight?: () => boolean; private readonly emitter = new EventEmitter(); private readonly aiListeners: Array<{ event: string; handler: (...args: unknown[]) => void }> = []; @@ -822,6 +834,7 @@ export class AgentSession { onCompactionComplete, onIdleCompactionOutcome, onPostCompactionStateChange, + hasExternalSendPreflight, } = options; assert(typeof workspaceId === "string", "workspaceId must be a string"); @@ -840,6 +853,7 @@ export class AgentSession { this.keepBackgroundProcesses = keepBackgroundProcesses ?? false; this.sanitizeCliWorkspaceRegistration = sanitizeCliWorkspaceRegistration; this.onPostCompactionStateChange = onPostCompactionStateChange; + this.hasExternalSendPreflight = hasExternalSendPreflight; this.compactionHandler = new CompactionHandler({ workspaceId: this.workspaceId, @@ -6923,7 +6937,14 @@ export class AgentSession { followUp.dispatchOptions?.requireIdle === true || persistedGoalKind != null; const hasQueuedMessages = this.hasPendingManualFollowUp(); const hasActiveNonCompletingTurn = this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING; - if (enforceIdleRule && (hasQueuedMessages || hasActiveNonCompletingTurn)) { + // Codex P1 (PRRT_kwDOPxxmWM6cRJD-): a manual service-level send can sit + // in its preflight (awaiting pricing/settings) without queueing or + // holding the turn phase — it must win over the synthetic follow-up too. + const hasExternalPreflightSend = this.hasExternalSendPreflight?.() === true; + if ( + enforceIdleRule && + (hasQueuedMessages || hasActiveNonCompletingTurn || hasExternalPreflightSend) + ) { log.info("Skipping pending follow-up because the workspace is no longer idle", { workspaceId: this.workspaceId, summaryMessageId: lastMessage.id, @@ -6979,6 +7000,7 @@ export class AgentSession { const idleRuleStale = enforceIdleRule ? () => this.hasPendingManualFollowUp() || + this.hasExternalSendPreflight?.() === true || (this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING) : undefined; const followUpAdmissionStale = @@ -7080,6 +7102,26 @@ export class AgentSession { throw new Error(`Failed to dispatch pending follow-up: ${message}`); } + // Codex P2 (PRRT_kwDOPxxmWM6cRJEE): if the original wrap-up dispatcher + // crashed between send acceptance and its tryMarkBudgetLimitInjected + // commit, this redispatched follow-up owns the wrap-up now — install the + // missing reservation so the recovered stream's end cannot arm a second + // one. Best-effort: a failed marker write must not fail the already + // dispatched wrap-up turn. + if (persistedGoalKind === GOAL_BUDGET_LIMIT_KIND && persistedGoalId != null) { + try { + await this.workspaceGoalService?.reserveBudgetWrapupForRedispatch( + this.workspaceId, + persistedGoalId + ); + } catch (error) { + log.warn("Failed to reserve budget wrap-up after redispatch", { + workspaceId: this.workspaceId, + error, + }); + } + } + return true; } diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 8e4b08e7905..204b93ac47d 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -2998,6 +2998,43 @@ describe("WorkspaceGoalService", () => { }); }); + test("a non-UUID boundary goalId degrades to a conservative unscoped pause", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cRJEC): durable goal IDs are UUIDs, so a + // corrupt non-UUID STRING on a pause boundary is not another goal's + // boundary either. Treating it as scoped-different would skip it, reach + // the goal's own older continuation row, and reactivate a durably paused + // goal after restart. + const created = await setGoalOk(service, { workspaceId, objective: "Non-UUID boundary" }); + await appendUserHistoryMessage(historyService, workspaceId, "Continue working on the goal.", { + timestamp: Date.now(), + synthetic: true, + kind: "goal_continuation", + goalId: created.goalId, + }); + const corruptBoundary = createMuxMessage( + `goal-paused-nonuuid-${crypto.randomUUID()}`, + "user", + "Goal paused by the user. Do not continue the goal until a later goal continuation message.", + { + timestamp: Date.now(), + synthetic: true, + muxMetadata: { type: "goal-pause-boundary", goalId: "broken" }, + } + ); + expect((await historyService.appendToHistory(workspaceId, corruptBoundary)).success).toBe(true); + // Durable pause written directly (no boundary append, no in-memory pause + // bookkeeping) — models the post-restart state where only the persisted + // artifacts remain. + await ( + service as unknown as { writeGoal: (id: string, goal: GoalRecordV1) => Promise } + ).writeGoal(workspaceId, { ...created, status: "paused", updatedAtMs: Date.now() }); + + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "paused", + }); + }); + test("a malformed continuation goalId is not legacy activity evidence", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cOHpI): legacy any-goal semantics apply only // when the scoping ID is genuinely absent. A present-but-malformed ID on diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 64c0c30ca0f..b7bf240a969 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -5,6 +5,7 @@ import assert from "@/common/utils/assert"; import { toGoalSnapshot, toPendingGoalSnapshot, + toValidGoalId, type GoalHistoryEndReason, type GoalHistoryEntry, type GoalRecordV1, @@ -235,24 +236,6 @@ function toValidEpochMs(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; } -/** - * Codex P2 (PRRT_kwDOPxxmWM6cNxUY): chat.jsonl metadata is unchecked JSON, so - * a goal-scoping ID persisted on a continuation or pause-boundary row can be - * any shape at runtime. A malformed non-string value must not satisfy a - * `!== currentGoalId` mismatch test (it is not evidence the row belongs to a - * DIFFERENT goal) — skipping a pause boundary on corrupt data would let the - * scan reach an older continuation row and reactivate a durably paused goal - * after restart. - * - * Callers treat a present-but-invalid ID by failure direction (Codex P2 - * PRRT_kwDOPxxmWM6cOHpI): a pause BOUNDARY degrades to legacy unscoped - * semantics (conservative paused, never scoped), while a CONTINUATION row is - * skipped outright — corrupt data must not manufacture activity evidence. - */ -function toValidGoalId(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - interface ChatTailGoalModeResult { mode: "active" | "paused" | null; /** @@ -1596,6 +1579,37 @@ export class WorkspaceGoalService { }; } + /** + * Codex P2 (PRRT_kwDOPxxmWM6cRJEE): a budget wrap-up send accepted before a + * crash can resume through a persisted compaction follow-up whose original + * dispatcher never committed tryMarkBudgetLimitInjected. The redispatched + * follow-up owns the wrap-up now: install the missing reservation (an + * existing matching reservation is already correct and left untouched) so + * the recovered stream's end cannot arm and fire a second wrap-up. + */ + async reserveBudgetWrapupForRedispatch(workspaceId: string, goalId: string): Promise { + assert(workspaceId.trim().length > 0, "reserveBudgetWrapupForRedispatch requires workspaceId"); + assert(goalId.trim().length > 0, "reserveBudgetWrapupForRedispatch requires goalId"); + await this.fileLocks.withLock(workspaceId, async () => { + const current = await this.readGoalFile(workspaceId); + if ( + current?.goalId !== goalId || + current.status !== "budget_limited" || + current.budgetLimitOriginKind === "user" || + current.budgetLimitInjectedForGoalId != null + ) { + return; + } + const next = GoalRecordV1Schema.parse({ + ...current, + budgetLimitInjectedForGoalId: goalId, + updatedAtMs: Date.now(), + }); + await this.writeGoal(workspaceId, next); + await this.pushSnapshot(workspaceId, next); + }); + } + /** * Treat an agent's text-only `goal_continuation` turn as implicit * completion. The continuation prompt asks the agent to call diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 06178c19435..dcc544245d0 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -4218,6 +4218,11 @@ export class WorkspaceService extends EventEmitter { onPostCompactionStateChange: () => { this.schedulePostCompactionMetadataRefresh(workspaceId); }, + // Codex P1 (PRRT_kwDOPxxmWM6cRJD-): expose service-level send + // preflights (manual sends counted below but not yet queued or busy) to + // the session's follow-up idle probes so redispatched synthetic turns + // yield to them. + hasExternalSendPreflight: () => (this.preflightSendCounts.get(workspaceId) ?? 0) > 0, }); } From af844281acc9d7a250a90e0b4f78f393669b9d0c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 23:08:45 +0000 Subject: [PATCH 43/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2038=20?= =?UTF-8?q?=E2=80=94=20session-invisible=20preflight=20counter;=20heartbea?= =?UTF-8?q?t=20rollback=20on=20preflight=20contention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...gentSession.continueMessageAgentId.test.ts | 27 ++++++++ src/node/services/agentSession.ts | 21 +++++-- src/node/services/workspaceService.test.ts | 62 +++++++++++++++++++ src/node/services/workspaceService.ts | 53 ++++++++++++++-- 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index e672a7d61ec..0c45c445d09 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -281,6 +281,33 @@ describe("AgentSession continue-message agentId fallback", () => { expect(historyResult.data.map((message) => message.id)).toEqual(["before-reset"]); }); + test("dispatchPendingFollowUp rolls back heartbeat boundaries when a service send is in preflight", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cRi_N): a manual service-level send still in + // preflight is user contention too — the heartbeat reset boundary must be + // rolled back (as for queued input), not left in history with the + // follow-up silently cleared. + const earlierMessage = createMuxMessage("before-reset", "assistant", "Earlier context"); + const { session, historyService, internals } = await createSession([ + earlierMessage, + heartbeatBoundaryMessage(), + ]); + internals.sendMessage = mock(() => Promise.resolve({ success: true as const })); + (session as unknown as { hasExternalSendPreflight?: () => boolean }).hasExternalSendPreflight = + () => true; + + const dispatched = await internals.dispatchPendingFollowUp(); + + expect(dispatched).toBe(false); + expect(internals.sendMessage).not.toHaveBeenCalled(); + + const historyResult = await historyService.getLastMessages("ws", 10); + expect(historyResult.success).toBe(true); + if (!historyResult.success) { + throw new Error(`Expected history read to succeed: ${historyResult.error}`); + } + expect(historyResult.data.map((message) => message.id)).toEqual(["before-reset"]); + }); + test("dispatchPendingFollowUp skips idle-only follow-ups when a new turn is already active", async () => { const { historyService, internals } = await createSession([ compactionSummaryMessage("summary-active-turn", idleFollowUp()), diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 600cb7b7793..fca9296d4db 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -6951,7 +6951,11 @@ export class AgentSession { hasQueuedMessages, turnPhase: this.turnPhase, }); - await this.skipIdleRuleFollowUp(lastMessage, hasQueuedMessages, hasActiveNonCompletingTurn); + await this.skipIdleRuleFollowUp( + lastMessage, + hasQueuedMessages || hasExternalPreflightSend, + hasActiveNonCompletingTurn + ); return false; } @@ -7093,7 +7097,7 @@ export class AgentSession { }); await this.skipIdleRuleFollowUp( lastMessage, - this.hasPendingManualFollowUp(), + this.hasPendingManualFollowUp() || this.hasExternalSendPreflight?.() === true, this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING ); return false; @@ -7128,17 +7132,22 @@ export class AgentSession { /** * Shared skip path for a pending follow-up vetoed by the idle rule (or a * stale goal admission): heartbeat reset boundaries are rolled back so the - * queued user turn sees pre-reset context; every other summary just drops - * its pending follow-up so it cannot re-fire on a later recovery pass. + * user turn sees pre-reset context; every other summary just drops its + * pending follow-up so it cannot re-fire on a later recovery pass. + * + * `hasUserContention` covers queued manual input AND a service-level send + * still in preflight (Codex P2 PRRT_kwDOPxxmWM6cRi_N): when user input is + * the reason the heartbeat continuation was vetoed, the reset boundary must + * be rolled back even though the message has not reached the queue yet. */ private async skipIdleRuleFollowUp( summaryMessage: MuxMessage, - hasQueuedMessages: boolean, + hasUserContention: boolean, hasActiveNonCompletingTurn: boolean ): Promise { if ( summaryMessage.metadata?.compacted === "heartbeat" && - hasQueuedMessages && + hasUserContention && !hasActiveNonCompletingTurn ) { const rollbackResult = diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 8815db90e77..86a315f5c22 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -6324,6 +6324,68 @@ describe("WorkspaceService sendMessage status clearing", () => { ); }); + test("the follow-up idle probe excludes the originating send after its session handoff", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cRi_J): preflightSendCounts stays positive + // until the outer service call returns, so a probe reading it would let a + // continuation's on-send compaction completion veto the continuation's + // OWN saved follow-up. The probe must see unrelated preflights (round-37 + // semantics) but release the originating send at its session handoff. + fakeSession.isBusy.mockReturnValue(false); + const realSession = ( + workspaceService as unknown as { createSession: (workspaceId: string) => AgentSession } + ).createSession("test-workspace"); + // The shared fixture aiService omits stopStream; disposal needs it. + ( + realSession as unknown as { aiService: { stopStream?: () => Promise } } + ).aiService.stopStream = () => Promise.resolve(Ok(undefined)); + const probe = (realSession as unknown as { hasExternalSendPreflight?: () => boolean }) + .hasExternalSendPreflight; + expect(probe).toBeDefined(); + try { + expect(probe!()).toBe(false); + + // A send stalled in its pricing preflight is visible to the probe. + let releasePricing!: () => void; + const pricingGate = new Promise((resolve) => { + releasePricing = resolve; + }); + let pricingStarted = false; + workspaceService.setWorkspaceGoalService({ + assertPricedModelForBudgetedGoal: mock(async () => { + pricingStarted = true; + await pricingGate; + return Ok(undefined); + }), + getPendingGoalSnapshot: mock(() => null), + } as unknown as WorkspaceGoalService); + // Ref object: closure assignments to a `let` are invisible to TS + // control-flow narrowing at the later assertion site. + const probeDuringSessionSend: { value: boolean | null } = { value: null }; + fakeSession.sendMessage.mockImplementationOnce(() => { + probeDuringSessionSend.value = probe!(); + return Promise.resolve(Ok(undefined)); + }); + + const sendPromise = workspaceService.sendMessage("test-workspace", "manual message", { + model: "openai:gpt-4o-mini", + agentId: "exec", + }); + await waitForCondition(() => pricingStarted); + expect(probe!()).toBe(true); + + // Once the send reaches the session handoff its reservation releases: + // a follow-up redispatched from within that turn must not see it. + releasePricing(); + const result = await sendPromise; + expect(result.success).toBe(true); + expect(probeDuringSessionSend.value).toBe(false); + // Fully settled: no residual reservation leaks. + expect(probe!()).toBe(false); + } finally { + realSession.dispose(); + } + }); + test("does not clear persisted agent status directly for non-synthetic sends", async () => { const updateAgentStatus = spyOn( workspaceService as unknown as { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index dcc544245d0..718a7f519fc 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2094,6 +2094,38 @@ export class WorkspaceService extends EventEmitter { // after that user row and enter the send's request as a trailing foreign // assistant row (see acquireIdleTurnExclusion). private readonly preflightSendCounts = new Map(); + /** + * Codex P1 (PRRT_kwDOPxxmWM6cRi_J): sends the SESSION cannot observe yet — + * counted from service entry until the queue/session handoff, then released. + * Unlike preflightSendCounts (held for the whole service call for archive + * and refine interlocks), this feeds the session's follow-up idle probes: + * a follow-up redispatched from within the originating send's own turn + * (e.g. its on-send compaction completing) must not veto itself, and once + * handed off the session's own queue/turn-phase state governs visibility. + */ + private readonly sessionInvisiblePreflightCounts = new Map(); + + /** See sessionInvisiblePreflightCounts. Release is idempotent. */ + private armSessionInvisiblePreflight(workspaceId: string): { release: () => void } & Disposable { + this.sessionInvisiblePreflightCounts.set( + workspaceId, + (this.sessionInvisiblePreflightCounts.get(workspaceId) ?? 0) + 1 + ); + let released = false; + const release = () => { + if (released) { + return; + } + released = true; + const remaining = (this.sessionInvisiblePreflightCounts.get(workspaceId) ?? 1) - 1; + if (remaining <= 0) { + this.sessionInvisiblePreflightCounts.delete(workspaceId); + } else { + this.sessionInvisiblePreflightCounts.set(workspaceId, remaining); + } + }; + return { release, [Symbol.dispose]: release }; + } // In-flight renderer executeBash requests per workspace. Incremented in the same // synchronous block as executeBash's archivingWorkspaces check (mirroring // preflightSendCounts) so archive admission and bash execution always observe each other: @@ -4219,10 +4251,14 @@ export class WorkspaceService extends EventEmitter { this.schedulePostCompactionMetadataRefresh(workspaceId); }, // Codex P1 (PRRT_kwDOPxxmWM6cRJD-): expose service-level send - // preflights (manual sends counted below but not yet queued or busy) to - // the session's follow-up idle probes so redispatched synthetic turns - // yield to them. - hasExternalSendPreflight: () => (this.preflightSendCounts.get(workspaceId) ?? 0) > 0, + // preflights (manual sends counted but not yet queued or busy) to the + // session's follow-up idle probes so redispatched synthetic turns yield + // to them. Codex P1 (PRRT_kwDOPxxmWM6cRi_J): reads the session-invisible + // counter, not preflightSendCounts — the originating send's reservation + // is released at its queue/session handoff so a follow-up dispatched + // from within that turn does not veto itself. + hasExternalSendPreflight: () => + (this.sessionInvisiblePreflightCounts.get(workspaceId) ?? 0) > 0, }); } @@ -10715,6 +10751,7 @@ export class WorkspaceService extends EventEmitter { } }, }; + using sessionInvisiblePreflight = this.armSessionInvisiblePreflight(workspaceId); // Guard: avoid creating sessions for workspaces that don't exist anymore. const workspaceConfig = this.config.findWorkspace(workspaceId); @@ -10823,6 +10860,7 @@ export class WorkspaceService extends EventEmitter { ); if (!pricingGate.success) { if (internal?.synthetic !== true) { + sessionInvisiblePreflight.release(); return session.sendMessage(message, normalizedOptions, { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, @@ -10963,6 +11001,7 @@ export class WorkspaceService extends EventEmitter { // we must not cancel foreground waits. Use the queue's effective dispatch mode // (not incoming options) because MessageQueue makes tool-end sticky. const continuationSendState = getContinuationSendState(); + sessionInvisiblePreflight.release(); const effectiveQueueDispatchMode = session.queueMessage( message, continuationSendState.options, @@ -11068,6 +11107,10 @@ export class WorkspaceService extends EventEmitter { claimedAutoTitle = true; } + // Handoff: from here the send is the session's own admission problem — + // release the probe reservation so a follow-up redispatched from within + // this very turn (on-send compaction completion) does not veto itself. + sessionInvisiblePreflight.release(); const result = await session.sendMessage(message, continuationSendState.options, { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, @@ -11236,6 +11279,7 @@ export class WorkspaceService extends EventEmitter { } }, }; + using sessionInvisiblePreflight = this.armSessionInvisiblePreflight(workspaceId); // Guard: avoid creating sessions for workspaces that don't exist anymore. if (!this.config.findWorkspace(workspaceId)) { @@ -11312,6 +11356,7 @@ export class WorkspaceService extends EventEmitter { }); } + sessionInvisiblePreflight.release(); const result = await session.resumeStream(normalizedOptions, { agentInitiated: internal?.agentInitiated, }); From b37d4b6ed1c3b8ab5b896a5465d03bfeb9ea374d Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 23:23:18 +0000 Subject: [PATCH 44/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2039=20?= =?UTF-8?q?=E2=80=94=20hold=20preflight=20reservation=20through=20rejected?= =?UTF-8?q?-send=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/workspaceService.test.ts | 45 ++++++++++++++++++++++ src/node/services/workspaceService.ts | 10 ++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 86a315f5c22..3ca92381e6a 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -6386,6 +6386,51 @@ describe("WorkspaceService sendMessage status clearing", () => { } }); + test("holds the preflight reservation through the rejected-send fallback", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cSCjs): a manual send rejected by the pricing + // gate delegates into AgentSession to persist the user row and apply goal + // safety, but it never streams and cannot produce its own compaction + // follow-up. Releasing the reservation before that fallback let a + // completing goal-scoped follow-up be admitted ahead of the user's + // intervention; the reservation must survive until the fallback settles. + fakeSession.isBusy.mockReturnValue(false); + const realSession = ( + workspaceService as unknown as { createSession: (workspaceId: string) => AgentSession } + ).createSession("test-workspace"); + // The shared fixture aiService omits stopStream; disposal needs it. + ( + realSession as unknown as { aiService: { stopStream?: () => Promise } } + ).aiService.stopStream = () => Promise.resolve(Ok(undefined)); + const probe = (realSession as unknown as { hasExternalSendPreflight?: () => boolean }) + .hasExternalSendPreflight; + expect(probe).toBeDefined(); + try { + const pricingError: SendMessageError = { type: "unknown", raw: "unpriced model" }; + workspaceService.setWorkspaceGoalService({ + assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Err(pricingError))), + getPendingGoalSnapshot: mock(() => null), + } as unknown as WorkspaceGoalService); + const probeDuringFallback: { value: boolean | null } = { value: null }; + fakeSession.sendMessage.mockImplementationOnce(() => { + probeDuringFallback.value = probe!(); + return Promise.resolve(Err(pricingError)); + }); + + const result = await workspaceService.sendMessage("test-workspace", "please stop", { + model: "custom:unpriced-model", + agentId: "exec", + }); + expect(result.success).toBe(false); + // The fallback persists the rejected row and applies goal safety while + // other dispatchers may probe idleness — it must still see this send. + expect(probeDuringFallback.value).toBe(true); + // Fully settled: no residual reservation leaks. + expect(probe!()).toBe(false); + } finally { + realSession.dispose(); + } + }); + test("does not clear persisted agent status directly for non-synthetic sends", async () => { const updateAgentStatus = spyOn( workspaceService as unknown as { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 718a7f519fc..9a0adccb69e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -10860,8 +10860,14 @@ export class WorkspaceService extends EventEmitter { ); if (!pricingGate.success) { if (internal?.synthetic !== true) { - sessionInvisiblePreflight.release(); - return session.sendMessage(message, normalizedOptions, { + // Codex P1 (PRRT_kwDOPxxmWM6cSCjs): unlike the accepted handoffs + // below, this rejected send never streams and cannot produce its own + // compaction follow-up, so the handoff release does not apply. Hold + // the reservation (released by `using` disposal after the await + // settles) so a completing goal-scoped follow-up cannot be admitted + // ahead of the user's intervention while the fallback persists the + // rejected row and applies goal safety. + return await session.sendMessage(message, normalizedOptions, { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, goalKind: internal?.goalKind, From 29228e041930d6f9e835896abdbe319f80e7a27b Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 25 Aug 2026 23:54:24 +0000 Subject: [PATCH 45/50] =?UTF-8?q?=F0=9F=A4=96=20security:=20anchor=20queue?= =?UTF-8?q?-race=20pause=20bypass=20on=20explicit=20user=20activation=20co?= =?UTF-8?q?nsent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-goal timestamp bypass (enqueuedAtMs <= createdAtMs) let a model publish a goal AFTER a user's queued stop/correction and shield its autonomous continuations from the any-manual-turn-pauses boundary. - GoalRecordV1.lastUserActivationAtMs: stamped only by explicit user activations (direct create, Resume, board promote); model set_goal, auto-promotion, and accounting re-arms never stamp, so their goals fail closed (queued manual messages always pause them). - Dispatch-time hook and durable-tail kickoff exemption now require the consent stamp to postdate the message's authoring. - Durable tail additionally keeps never-driven goals active when the manual row was PROCESSED (completed assistant row follows it): the initiating prompt whose turn produced the goal is not an unprocessed intervention, preserving restart/eviction crash-safety for model-created goals born from an ordinary prompt. Codex security P2 (PRRT_kwDOPxxmWM6cSGrq) --- src/common/orpc/schemas/goal.ts | 10 ++ .../agentSession.goalAutoPause.test.ts | 52 ++++++++ src/node/services/agentSession.ts | 25 +++- .../services/workspaceGoalService.test.ts | 51 ++++++++ src/node/services/workspaceGoalService.ts | 118 +++++++++++++++--- 5 files changed, 232 insertions(+), 24 deletions(-) diff --git a/src/common/orpc/schemas/goal.ts b/src/common/orpc/schemas/goal.ts index d4e9af57a89..d8137899084 100644 --- a/src/common/orpc/schemas/goal.ts +++ b/src/common/orpc/schemas/goal.ts @@ -54,6 +54,16 @@ export const GoalRecordV1Schema = z.object({ budgetLimitOriginKind: GoalBudgetLimitOriginKindSchema.optional(), requireUserAcknowledgmentSinceMs: z.number().int().nonnegative().nullable(), lastContinuationFiredAtMs: z.number().int().nonnegative().nullable().optional(), + // Timestamp of the last EXPLICIT user action that made this goal active + // (direct create, Resume, board promote — never model set_goal, + // auto-promotion, or accounting re-arms). This is the consent anchor for + // the queue-race pause bypasses: a manual message authored before this + // moment was visibly pending when the user activated the goal, which is a + // genuine opt-in. Optional so legacy records load without migration; goals + // without it (including every model-created goal) FAIL CLOSED — a queued + // manual message always pauses them (Codex security P2 + // PRRT_kwDOPxxmWM6cSGrq). + lastUserActivationAtMs: z.number().int().nonnegative().nullable().optional(), completionSummary: z.string().optional(), createdAtMs: z.number().int().nonnegative(), updatedAtMs: z.number().int().nonnegative(), diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 154561232e5..40f522e38e8 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -619,6 +619,58 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("queued messages predating a model-created goal still pause it", async () => { + // Codex security P2 (PRRT_kwDOPxxmWM6cSGrq): a model can publish a goal + // AFTER the user queued a stop/correction, so timestamp order alone must + // not shield the fresh goal's autonomy from the any-manual-turn-pauses + // boundary. Only an explicit user activation is consent; model-created + // goals carry no consent stamp and fail closed into the pause. + const workspaceId = "queued-predates-model-goal"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const enqueuedAtMs = Date.now(); + const created = await setGoalOk(goalService, { + workspaceId, + objective: "Model-published goal", + initiator: "model", + }); + expect(created.createdAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + + const result = await session.sendMessage("Stop what you are doing", SEND_OPTIONS, { + enqueuedAtMs, + }); + + expect(result.success).toBe(true); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "paused" }); + session.dispose(); + }); + + test("a user Resume is consent for messages authored before it", async () => { + // The consent anchor is the explicit user activation, not goal creation: + // clicking Resume with a message already pending is a genuine opt-in, so + // the queued message must not instantly re-pause the resumed goal. + const workspaceId = "resume-consent-queue-race"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + await setGoalOk(goalService, { workspaceId, objective: "Resumable goal" }); + await setGoalOk(goalService, { workspaceId, status: "paused", initiator: "user" }); + const enqueuedAtMs = Date.now(); + const resumed = await setGoalOk(goalService, { + workspaceId, + status: "active", + initiator: "user", + }); + expect(resumed.lastUserActivationAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + + const result = await session.sendMessage("Queued before the resume", SEND_OPTIONS, { + enqueuedAtMs, + }); + + expect(result.success).toBe(true); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "active" }); + session.dispose(); + }); + test("queued manual messages enqueued after goal creation still pause it", async () => { const workspaceId = "queued-postdates-goal"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index fca9296d4db..2390ea112a3 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1680,12 +1680,25 @@ export class AgentSession { // Queue race: a message the user typed while the goal-creating turn was // still streaming predates the goal itself — the model's queued set_goal // applies at that turn's stream end, and only then does the queued message - // dispatch. The user cannot have been intervening against a goal they had - // not seen, so let the fresh goal keep its kickoff continuation instead of - // silently pausing it half a second after creation (user report: goals - // "paused by heartbeats" were actually killed here, then heartbeat turns - // kept the workspace moving while the goal sat paused). - if (input.enqueuedAtMs != null && goal != null && goal.createdAtMs >= input.enqueuedAtMs) { + // dispatch (user report: goals "paused by heartbeats" were actually killed + // here, then heartbeat turns kept the workspace moving while the goal sat + // paused). + // + // Codex security P2 (PRRT_kwDOPxxmWM6cSGrq): timestamp order alone is NOT + // consent — a model can publish a goal AFTER the user queued a + // stop/correction, and a "predates the goal" bypass would shield the new + // goal's autonomy from the any-manual-turn-pauses boundary even if the + // model ignores the corrective turn. The bypass therefore requires an + // EXPLICIT user activation (direct create, Resume, board promote — never + // model set_goal or auto-promotion; see stampUserActivation) that + // postdates the message's authoring: the user acted with the message + // already pending, a genuine opt-in. Model-created goals carry no consent + // stamp and fail closed into the visible, resumable pause below. + if ( + input.enqueuedAtMs != null && + goal?.lastUserActivationAtMs != null && + goal.lastUserActivationAtMs >= input.enqueuedAtMs + ) { if (suspendedCandidate != null) { // The restore re-verifies goal identity + active status under the // goal file lock (Codex P2 PRRT_kwDOPxxmWM6cErQ7): a pause landing diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 204b93ac47d..4bccc1fc484 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -52,6 +52,19 @@ async function appendUserHistoryMessage( expect(result.success).toBe(true); } +async function appendAssistantHistoryMessage( + historyService: HistoryService, + workspaceId: string, + text: string, + metadata: Parameters[3] = { timestamp: Date.now() } +): Promise { + const result = await historyService.appendToHistory( + workspaceId, + createMuxMessage(`goal-test-assistant-${crypto.randomUUID()}`, "assistant", text, metadata) + ); + expect(result.success).toBe(true); +} + async function getLastUserHistoryMessage(historyService: HistoryService, workspaceId: string) { const history = await historyService.getLastMessages(workspaceId, 20); expect(history.success).toBe(true); @@ -463,6 +476,44 @@ describe("WorkspaceGoalService", () => { expect(reconciled).toMatchObject({ status: "active" }); }); + test("getGoal pauses a never-driven model-created goal on an unprocessed pre-goal row", async () => { + // Codex security P2 (PRRT_kwDOPxxmWM6cSGrq): only explicit user activation + // is consent. A model-published goal whose chat tail ends at a queue-raced + // manual row (no completed assistant row after it) fails closed to paused — + // timestamp order alone must not let the model outrun a queued correction. + const created = await setGoalOk(service, { + workspaceId, + objective: "Model queue race", + initiator: "model", + }); + await appendUserHistoryMessage(historyService, workspaceId, "Typed mid-stream", { + timestamp: created.createdAtMs + 500, + enqueuedAtMs: created.createdAtMs - 500, + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "paused" }); + }); + + test("getGoal keeps a never-driven model-created goal active when the pre-goal prompt was processed", async () => { + // The initiating prompt's turn settled (completed assistant row follows + // it) — that turn PRODUCED the goal, so the prompt is not an unprocessed + // intervention. Candidate loss (restart/eviction) must not pause the + // fresh goal before it ever runs. + await appendUserHistoryMessage(historyService, workspaceId, "Set yourself a goal"); + await appendAssistantHistoryMessage(historyService, workspaceId, "Goal created"); + await setGoalOk(service, { + workspaceId, + objective: "Processed prompt", + initiator: "model", + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "active" }); + }); + test("getGoal pauses a never-driven goal when a manual row was authored after the goal", async () => { // Crash-recovery self-healing: if the dispatch-time auto-pause was lost // (process exit between the user row persist and the pause write), the diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index b7bf240a969..86e0c746a4b 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -257,12 +257,24 @@ interface ChatTailGoalModeResult { /** * When `pausedBy === "manual_user"`: the moment the user authored the pausing * row — its persisted enqueue time (queued sends) or the row timestamp. - * Reconciliation compares this against `goal.createdAtMs` to tell pre-goal - * rows (must not pause a never-driven goal) from genuine post-goal - * interventions (must pause even if the dispatch-time auto-pause was lost to - * a crash). + * Reconciliation compares this against the goal's explicit user-activation + * consent stamp (`lastUserActivationAtMs`) to tell rows the user visibly + * left pending while activating the goal (must not pause a never-driven + * goal) from genuine interventions (must pause even if the dispatch-time + * auto-pause was lost to a crash). Codex security P2 PRRT_kwDOPxxmWM6cSGrq: + * `createdAtMs` is deliberately NOT the anchor — a model publishing a goal + * after a queued correction would grandfather its autonomy past it. */ manualRowAuthoredAtMs?: number; + /** + * When `pausedBy === "manual_user"`: true when a COMPLETED assistant row + * immediately follows the manual row, i.e. the turn that consumed the row + * settled. In the kickoff window this identifies the goal-creating turn's + * own initiating prompt (that turn PRODUCED the goal, so the row is not an + * unprocessed intervention); an unprocessed or queue-dispatched intervention + * is the tail's final row and stays false. + */ + manualRowProcessed?: boolean; /** * `explicitPauseGenerations` value captured BEFORE the history read that * produced this evidence. Tail reads run outside the goal file lock, so an @@ -810,10 +822,18 @@ export class WorkspaceGoalService { const authoredAtMs = toValidEpochMs(message.metadata?.enqueuedAtMs) ?? toValidEpochMs(message.metadata?.timestamp); + // A COMPLETED assistant row immediately after the manual row proves the + // turn that consumed it settled (see manualRowProcessed field doc). A + // partial assistant row (crash mid-response) stays unprocessed so the + // fail-closed pause + crash-recovery acknowledgment gates apply. + const followerRow = historyResult.data[index + 1]; + const manualRowProcessed = + followerRow?.role === "assistant" && followerRow.metadata?.partial !== true; return { mode: "paused", pausedBy: "manual_user", ...(authoredAtMs != null ? { manualRowAuthoredAtMs: authoredAtMs } : {}), + ...(manualRowProcessed ? { manualRowProcessed: true } : {}), }; } @@ -858,17 +878,32 @@ export class WorkspaceGoalService { // hooks) would silently pause the goal before it ever ran (user report: // scheduled heartbeats "pausing" fresh goals). // - // Scoped to rows AUTHORED before the goal existed (persisted enqueue time - // for queued sends, row timestamp otherwise): a row the user authored - // after the goal became visible is a genuine intervention and must still - // pause even when the dispatch-time auto-pause was lost to a crash - // (Codex P2 — persisted state stays self-healing). Explicit pause paths - // are unaffected: they append a goal-pause-boundary row, which reconciles + // Scoped to two provably-safe cases (Codex security P2 + // PRRT_kwDOPxxmWM6cSGrq — "authored before the goal existed" alone is + // NOT consent, because a model can publish a goal AFTER the user queued + // a stop/correction and would grandfather its autonomy past it): + // 1. The manual row was PROCESSED — a completed assistant row follows + // it, so for a never-driven goal it is the initiating prompt whose + // turn produced the goal, not an unprocessed intervention. (An + // intervention's dispatch-time hook pauses the goal durably before + // its turn streams, so a later processed row cannot resurrect + // autonomy; the residual gap is a pause-write failure that is + // already logged and wrap-up-suppressed.) + // 2. The row was authored before the goal's explicit user-activation + // consent stamp — the user activated the goal with the message + // visibly pending, a genuine opt-in. Model-created goals carry no + // stamp and fail closed. + // Rows authored after the consent stamp are genuine interventions and + // must still pause even when the dispatch-time auto-pause was lost to a + // crash (persisted state stays self-healing). Explicit pause paths are + // unaffected: they append a goal-pause-boundary row, which reconciles // via the pause_boundary branch. if ( goal.lastContinuationFiredAtMs == null && - chatTailMode.manualRowAuthoredAtMs != null && - chatTailMode.manualRowAuthoredAtMs <= goal.createdAtMs + (chatTailMode.manualRowProcessed === true || + (chatTailMode.manualRowAuthoredAtMs != null && + goal.lastUserActivationAtMs != null && + chatTailMode.manualRowAuthoredAtMs <= goal.lastUserActivationAtMs)) ) { return goal; } @@ -2362,6 +2397,32 @@ export class WorkspaceGoalService { return null; } + /** + * Explicit-user-activation consent stamp — the anchor for the queue-race + * pause bypasses (Codex security P2 PRRT_kwDOPxxmWM6cSGrq). Only explicit + * user actions that transition a goal into `active` stamp it (direct + * create, Resume, board promote); model set_goal, auto-promotion, and + * accounting re-arms leave it unset so their goals FAIL CLOSED — a queued + * manual message always pauses them. The oRPC schema deliberately omits + * `initiator`, so renderer calls default to "user" here while the model can + * only enter through tools that pass `initiator: "model"`. + */ + private stampUserActivation( + next: GoalRecordV1, + previousStatus: GoalRecordV1["status"] | null, + initiator: GoalLifecycleInitiator | undefined, + activatedAtMs: number + ): GoalRecordV1 { + if ( + next.status !== "active" || + previousStatus === "active" || + (initiator ?? "user") !== "user" + ) { + return next; + } + return GoalRecordV1Schema.parse({ ...next, lastUserActivationAtMs: activatedAtMs }); + } + private applyMutableFields(goal: GoalRecordV1, input: SetGoalInput): GoalRecordV1 { const completionSummary = input.completionSummary?.trim() ?? null; if (input.status != null) { @@ -2885,11 +2946,13 @@ export class WorkspaceGoalService { // construction stamp predates the kickoff-model validation await, // the streaming re-check, and the async activity-snapshot read // inside publication — a message queued during any of those awaits - // postdated that stamp while the goal was not yet visible anywhere, - // so the pre-goal guard (enqueuedAtMs <= createdAtMs) misread it as - // an intervention against a goal the user could not have seen. - // Existing-goal branches keep their original durable createdAtMs — - // those goals were published long ago. + // postdated that stamp while the goal was not yet visible anywhere. + // For USER-drained creations, `createdAtMs` feeds the explicit + // activation consent stamp (see stampUserActivation in the creation + // branch), so a stale construction-time stamp would misread such a + // message as an intervention against a goal the user could not have + // seen. Existing-goal branches keep their original durable + // createdAtMs — those goals were published long ago. // // The identity guard (Codex P1 PRRT_kwDOPxxmWM6b-orH) skips the // re-stamp when a user abort (or competing setter) removed or @@ -3063,7 +3126,12 @@ export class WorkspaceGoalService { // Apply other inline edits (status / budget / turnCap) on top of the // renamed record so a single payload can rename and update budget // atomically. - const withEdits = this.applyMutableFields(renamed, input); + const withEdits = this.stampUserActivation( + this.applyMutableFields(renamed, input), + current.status, + input.initiator, + Date.now() + ); if ( (withEdits.status === "active" || withEdits.status === "budget_limited") && !(await this.canRunBudgetedGoalOnKickoffModel(input.workspaceId, withEdits)) @@ -3171,6 +3239,7 @@ export class WorkspaceGoalService { updatedAtMs: Date.now(), }); } + updated = this.stampUserActivation(updated, previousStatus, input.initiator, Date.now()); await this.writeGoal(input.workspaceId, updated); // Codex P1 (PRRT_kwDOPxxmWM6cLpIP): the write itself yields. A model // complete_goal takes this direct branch during the live stream; a @@ -3302,6 +3371,12 @@ export class WorkspaceGoalService { updatedAtMs: publishedAtMs, }); } + // Consent anchor for the queue-race pause bypasses: `createdAtMs` is the + // moment the user's create action became visible (direct creates) or the + // drained user mutation's publication stamp — a message authored before + // it was pending when the user acted. Model-initiated creations never + // stamp (fail closed). + next = this.stampUserActivation(next, null, input.initiator, next.createdAtMs); await this.writeGoal(input.workspaceId, next); // Codex P1 (PRRT_kwDOPxxmWM6cMGn8): the creation/replacement write // itself yields — this is the stream-end drain's main path for a @@ -4950,6 +5025,9 @@ export class WorkspaceGoalService { updatedAtMs: now, completionSummary: undefined, requireUserAcknowledgmentSinceMs: null, + // The user is the only caller of board promotion — an explicit + // activation consent (see stampUserActivation). + lastUserActivationAtMs: now, }); const activated = this.applyBudgetDrivenStatus(baseActivated); @@ -5122,6 +5200,10 @@ export class WorkspaceGoalService { updatedAtMs: now, completionSummary: undefined, requireUserAcknowledgmentSinceMs: null, + // Auto-promotion is not user consent: clear any stale activation stamp + // a previously user-activated (then demoted) goal may still carry so the + // queue-race pause bypasses fail closed for this activation. + lastUserActivationAtMs: null, }); const activated = this.applyBudgetDrivenStatus(baseActivated); // same pricing gate as `promoteUpcomingGoal`. If the next From e0626ebb4d0d88978c29b9579259173856f078d0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 00:01:50 +0000 Subject: [PATCH 46/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2040=20?= =?UTF-8?q?=E2=80=94=20pause=20generation=20at=20write=20commit;=20reserva?= =?UTF-8?q?tion=20lifetime=20to=20session=20busy=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - explicitPauseGenerations bumps inside writeGoal (commit point) so admission probes read stale during the publication window before the finalization hold arms (PRRT_kwDOPxxmWM6cSREI) - resumeStream holds the session-invisible reservation until session admission settles (PRRT_kwDOPxxmWM6cSREO) - direct sends release the reservation via onTurnAdmissionCommitted at the synchronous PREPARING claim instead of the service handoff (PRRT_kwDOPxxmWM6cSRkH) --- src/node/services/agentSession.ts | 22 +++++- .../services/workspaceGoalService.test.ts | 58 ++++++++++++++ src/node/services/workspaceGoalService.ts | 16 ++++ src/node/services/workspaceService.test.ts | 75 ++++++++++++++++--- src/node/services/workspaceService.ts | 24 ++++-- 5 files changed, 177 insertions(+), 18 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2390ea112a3..613773a242b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2857,11 +2857,24 @@ export class AgentSession { cancelSignal?: AbortSignal; /** * For queue-dispatched sends: when the user last added to the queued - * entry. Goal safety uses it to skip auto-pausing a goal created AFTER - * the message was typed (queued while the goal-creating turn streamed) — - * the user cannot have been intervening against a goal they had not seen. + * entry. Goal safety compares it against the goal's explicit + * user-activation consent stamp — a message the user visibly left + * pending while activating the goal must not auto-pause it (Codex + * security P2 PRRT_kwDOPxxmWM6cSGrq: creation time alone is not + * consent). */ enqueuedAtMs?: number; + /** + * Codex P2 (PRRT_kwDOPxxmWM6cSRkH): fired synchronously the moment this + * turn claims PREPARING (isBusy() becomes true). WorkspaceService keeps + * its session-invisible preflight reservation armed until this fires so + * follow-up recovery cannot observe the idle gap between the service + * handoff and the busy claim (cancelBeforeAcceptance and the other + * admission awaits yield) and admit a synthetic turn ahead of the + * accepted manual send. Refusal paths never fire it — the service's + * scoped disposal releases the reservation when the call returns. + */ + onTurnAdmissionCommitted?: () => void; /** * Synthetic assistant rows persisted immediately before this turn's user * row (family-message payloads). Persisting them inside turn admission — @@ -3903,6 +3916,9 @@ export class AgentSession { this.activePreparedTurnAbortController = preparedTurnAbortController; this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata(optionsForStream.muxMetadata); this.setTurnPhase(TurnPhase.PREPARING); + // From this synchronous point isBusy() reports the turn — release the + // service-side preflight reservation (see onTurnAdmissionCommitted doc). + internal?.onTurnAdmissionCommitted?.(); const startPreparedStream = async (): Promise> => { try { diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 4bccc1fc484..76532566816 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -476,6 +476,64 @@ describe("WorkspaceGoalService", () => { expect(reconciled).toMatchObject({ status: "active" }); }); + test("a paused write invalidates captured redispatch admissions before publication completes", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cSREI): the explicit-pause generation must + // bump at the durable write commit, not after snapshot/preview + // publication — a captured continuation's admission probe re-checked + // during the publication awaits (before setGoal returns and arms the + // finalization hold) must already read stale, or an autonomous turn could + // be admitted against the committed Pause. + const created = await setGoalOk(service, { workspaceId, objective: "Pause admission" }); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + expect(admission.admissible).toBe(true); + if (!admission.admissible) { + throw new Error("expected admissible probe"); + } + expect(admission.admissionStale()).toBe(false); + + // Block publication so the paused record is durable while setGoal is + // still awaiting inside its locked persistence. + let releasePublication!: () => void; + const publicationGate = new Promise((resolve) => { + releasePublication = resolve; + }); + const pushSnapshotSpy = spyOn( + service as unknown as { pushSnapshot: (workspaceId: string, goal: unknown) => Promise }, + "pushSnapshot" + ).mockImplementation(async () => { + await publicationGate; + }); + try { + const pausePromise = service.setGoal({ + workspaceId, + status: "paused", + initiator: "user", + }); + const goalPath = path.join(config.getSessionDir(workspaceId), "goal.json"); + await waitForCondition(async () => { + try { + const raw = JSON.parse(await fs.readFile(goalPath, "utf-8")) as { status?: string }; + return raw.status === "paused"; + } catch { + return false; + } + }); + // The durable pause has committed but publication (and the finalization + // hold arming) has not — the captured probe must already be stale. + expect(admission.admissionStale()).toBe(true); + releasePublication(); + const paused = await pausePromise; + expect(paused.success).toBe(true); + } finally { + releasePublication(); + pushSnapshotSpy.mockRestore(); + } + }); + test("getGoal pauses a never-driven model-created goal on an unprocessed pre-goal row", async () => { // Codex security P2 (PRRT_kwDOPxxmWM6cSGrq): only explicit user activation // is consent. A model-published goal whose chat tail ends at a queue-raced diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 86e0c746a4b..2f39ccbbfbe 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -1147,6 +1147,22 @@ export class WorkspaceGoalService { (this.terminalStatusGenerations.get(workspaceId) ?? 0) + 1 ); } + // See explicitPauseGenerations: also bumped at the write commit point + // (Codex P1 PRRT_kwDOPxxmWM6cSREI). persistGoalMutationLocked awaits + // snapshot/preview publication AFTER the durable paused write and before + // setGoalImmediately arms the finalization hold — a captured + // continuation's admissionStale probe would otherwise still read the + // pre-pause generation during that publication window and admit an + // autonomous turn against the committed Pause. Restore/reconciliation + // writes of paused records bump too; consumers compare generations for + // inequality, so extra bumps only cause conservative staleness refusals + // that retry. + if (goal.status === "paused") { + this.explicitPauseGenerations.set( + workspaceId, + (this.explicitPauseGenerations.get(workspaceId) ?? 0) + 1 + ); + } // See goalIdentityGenerations: identity changes (replacement, revival, // first write of the process) invalidate captured dispatch admissions. if (this.lastWrittenGoalIds.get(workspaceId) !== goal.goalId) { diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 3ca92381e6a..25a4d1f965f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -6358,13 +6358,27 @@ describe("WorkspaceService sendMessage status clearing", () => { }), getPendingGoalSnapshot: mock(() => null), } as unknown as WorkspaceGoalService); - // Ref object: closure assignments to a `let` are invisible to TS - // control-flow narrowing at the later assertion site. - const probeDuringSessionSend: { value: boolean | null } = { value: null }; - fakeSession.sendMessage.mockImplementationOnce(() => { - probeDuringSessionSend.value = probe!(); - return Promise.resolve(Ok(undefined)); - }); + // Ref objects: closure assignments to a `let` are invisible to TS + // control-flow narrowing at the later assertion sites. + const probeBeforeAdmission: { value: boolean | null } = { value: null }; + const probeAfterAdmission: { value: boolean | null } = { value: null }; + fakeSession.sendMessage.mockImplementationOnce( + ( + _message: unknown, + _options: unknown, + internal?: { onTurnAdmissionCommitted?: () => void } + ) => { + // Codex P2 (PRRT_kwDOPxxmWM6cSRkH): the reservation must survive the + // session's admission awaits (the idle gap before the busy claim)... + probeBeforeAdmission.value = probe!(); + internal?.onTurnAdmissionCommitted?.(); + // ...and release the moment the turn synchronously claims PREPARING, + // so a follow-up redispatched from within this very turn (on-send + // compaction completion) does not veto itself. + probeAfterAdmission.value = probe!(); + return Promise.resolve(Ok(undefined)); + } + ); const sendPromise = workspaceService.sendMessage("test-workspace", "manual message", { model: "openai:gpt-4o-mini", @@ -6373,12 +6387,53 @@ describe("WorkspaceService sendMessage status clearing", () => { await waitForCondition(() => pricingStarted); expect(probe!()).toBe(true); - // Once the send reaches the session handoff its reservation releases: - // a follow-up redispatched from within that turn must not see it. releasePricing(); const result = await sendPromise; expect(result.success).toBe(true); - expect(probeDuringSessionSend.value).toBe(false); + expect(probeBeforeAdmission.value).toBe(true); + expect(probeAfterAdmission.value).toBe(false); + // Fully settled: no residual reservation leaks. + expect(probe!()).toBe(false); + } finally { + realSession.dispose(); + } + }); + + test("holds the preflight reservation through resumeStream session admission", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cSREO): AgentSession.resumeStream runs its own + // async admission (a second pricing gate) during which the session still + // reports idle. Releasing the reservation before that await let follow-up + // recovery admit a recovered synthetic turn that then ran concurrently + // with the resumed stream — the reservation must survive until the session + // call settles. + fakeSession.isBusy.mockReturnValue(false); + const realSession = ( + workspaceService as unknown as { createSession: (workspaceId: string) => AgentSession } + ).createSession("test-workspace"); + // The shared fixture aiService omits stopStream; disposal needs it. + ( + realSession as unknown as { aiService: { stopStream?: () => Promise } } + ).aiService.stopStream = () => Promise.resolve(Ok(undefined)); + const probe = (realSession as unknown as { hasExternalSendPreflight?: () => boolean }) + .hasExternalSendPreflight; + expect(probe).toBeDefined(); + try { + workspaceService.setWorkspaceGoalService({ + assertPricedModelForBudgetedGoal: mock(() => Promise.resolve(Ok(undefined))), + getPendingGoalSnapshot: mock(() => null), + } as unknown as WorkspaceGoalService); + const probeDuringResume: { value: boolean | null } = { value: null }; + fakeSession.resumeStream.mockImplementationOnce(() => { + probeDuringResume.value = probe!(); + return Promise.resolve(Ok({ started: true })); + }); + + const result = await workspaceService.resumeStream("test-workspace", { + model: "openai:gpt-4o-mini", + agentId: "exec", + }); + expect(result.success).toBe(true); + expect(probeDuringResume.value).toBe(true); // Fully settled: no residual reservation leaks. expect(probe!()).toBe(false); } finally { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 9a0adccb69e..93d59d189c3 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -11113,11 +11113,17 @@ export class WorkspaceService extends EventEmitter { claimedAutoTitle = true; } - // Handoff: from here the send is the session's own admission problem — - // release the probe reservation so a follow-up redispatched from within - // this very turn (on-send compaction completion) does not veto itself. - sessionInvisiblePreflight.release(); + // Handoff: the session releases the probe reservation the moment the + // turn synchronously claims PREPARING (onTurnAdmissionCommitted), so a + // follow-up redispatched from within this very turn (on-send compaction + // completion) does not veto itself — while the admission awaits between + // here and the busy claim stay covered. Codex P2 (PRRT_kwDOPxxmWM6cSRkH): + // releasing at the handoff itself left AgentSession's + // cancelBeforeAcceptance yield observable as idle, letting follow-up + // recovery admit an exec turn ahead of the accepted manual send. Refusal + // paths never fire the callback; the scoped disposal releases on return. const result = await session.sendMessage(message, continuationSendState.options, { + onTurnAdmissionCommitted: () => sessionInvisiblePreflight.release(), synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, goalKind: internal?.goalKind, @@ -11362,10 +11368,18 @@ export class WorkspaceService extends EventEmitter { }); } - sessionInvisiblePreflight.release(); + // Codex P1 (PRRT_kwDOPxxmWM6cSREO): resumeStream runs its own async + // admission (a second pricing gate) during which the session still + // reports idle — releasing the reservation before that await let + // follow-up recovery admit a recovered synthetic turn that then ran + // concurrently with the resumed stream. Hold the reservation until the + // session call settles: resumeStream returns once the stream has + // started (or refused), so no follow-up redispatched from within the + // resumed turn itself can observe the reservation and self-veto. const result = await session.resumeStream(normalizedOptions, { agentInitiated: internal?.agentInitiated, }); + sessionInvisiblePreflight.release(); if (!result.success) { log.error("resumeStream handler: session returned error", { workspaceId, From a9622bd72a446b972216802de7d927791cd45d35 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 00:15:35 +0000 Subject: [PATCH 47/50] =?UTF-8?q?=F0=9F=A4=96=20security:=20sample=20the?= =?UTF-8?q?=20user-stop=20generation=20in=20redispatch=20admission=20probe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordUserStoppedStream leaves an active goal's status and identity unchanged, so pause/terminal/identity generations stay fresh across a Stop — a recovered goal-scoped follow-up admitted before the Stop could start an exec turn after it. Codex security P2 (PRRT_kwDOPxxmWM6cSx0M) --- .../services/workspaceGoalService.test.ts | 24 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 11 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 76532566816..7cec1a9aad2 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -534,6 +534,30 @@ describe("WorkspaceGoalService", () => { } }); + test("a user Stop invalidates captured redispatch admissions", async () => { + // Codex security P2 (PRRT_kwDOPxxmWM6cSx0M): recordUserStoppedStream + // leaves an active goal's status and identity unchanged (it only bumps the + // stop generation; the acknowledgment gate lands later), so the pause/ + // terminal/identity generation probes stay fresh across a Stop — a + // recovered goal-scoped follow-up whose admission was captured before the + // Stop could otherwise start an exec turn after it. + const created = await setGoalOk(service, { workspaceId, objective: "Stop admission" }); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + expect(admission.admissible).toBe(true); + if (!admission.admissible) { + throw new Error("expected admissible probe"); + } + expect(admission.admissionStale()).toBe(false); + + await service.recordUserStoppedStream(workspaceId); + + expect(admission.admissionStale()).toBe(true); + }); + test("getGoal pauses a never-driven model-created goal on an unprocessed pre-goal row", async () => { // Codex security P2 (PRRT_kwDOPxxmWM6cSGrq): only explicit user activation // is consent. A model-published goal whose chat tail ends at a queue-raced diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 2f39ccbbfbe..ed241726b10 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -1595,11 +1595,19 @@ export class WorkspaceGoalService { const pauseGenerationAtBuild = this.explicitPauseGenerations.get(workspaceId) ?? 0; const terminalGenerationAtBuild = this.terminalStatusGenerations.get(workspaceId) ?? 0; const identityGenerationAtBuild = this.goalIdentityGenerations.get(workspaceId) ?? 0; + // Codex security P2 (PRRT_kwDOPxxmWM6cSx0M): a user Stop leaves an active + // goal's status and identity untouched (recordUserStoppedStream only bumps + // the stop generation synchronously; the acknowledgment gate persists + // later), so the generation probes above stay fresh across it — a + // recovered goal-scoped follow-up admitted before the Stop could start an + // exec turn after it. Sample the stop generation with the others. + const userStopGenerationAtBuild = this.userStopGenerationsByWorkspace.get(workspaceId) ?? 0; const current = await this.readGoalFile(workspaceId); if ( (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtBuild || (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild || + this.userStopLandedSince(workspaceId, userStopGenerationAtBuild) || current?.goalId !== goalId || current.requireUserAcknowledgmentSinceMs != null ) { @@ -1626,7 +1634,8 @@ export class WorkspaceGoalService { admissionStale: () => (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtBuild || (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || - (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild, + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild || + this.userStopLandedSince(workspaceId, userStopGenerationAtBuild), }; } From 1894d807baf6f307b472ceaa50c1c9c60c856aba Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 00:36:59 +0000 Subject: [PATCH 48/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2042=20?= =?UTF-8?q?=E2=80=94=20stop-ack=20latch;=20legacy=20follow-up=20discard;?= =?UTF-8?q?=20strict=20consent=20ordering;=20non-synthetic=20followers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pendingStopAcknowledgmentCounts: a Stop in flight (generation bumped, acknowledgment not yet durable) refuses redispatch admissions and marks captured probes stale (PRRT_kwDOPxxmWM6cS7qG) - legacy compaction follow-ups carrying goalKind without goalId are conservatively discarded — no unscoped synthetic redispatch against a replaced goal (PRRT_kwDOPxxmWM6cS8Bq) - activation consent requires strict ordering: same-millisecond equality fails closed to pause (PRRT_kwDOPxxmWM6cS8Bu) - processed-row rule rejects synthetic assistant followers (e.g. goal-cleared summaries) as proof of settlement (PRRT_kwDOPxxmWM6cS8Bx) --- .../agentSession.goalAutoPause.test.ts | 93 +++++++++++++++++-- src/node/services/agentSession.ts | 22 ++++- .../services/workspaceGoalService.test.ts | 60 ++++++++++++ src/node/services/workspaceGoalService.ts | 58 +++++++++++- 4 files changed, 222 insertions(+), 11 deletions(-) diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 40f522e38e8..7ba6a05ecfa 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -606,9 +606,11 @@ describe("AgentSession goal safety hooks", () => { const workspaceId = "queued-predates-goal"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); cleanups.push(cleanup); - const enqueuedAtMs = Date.now(); + // Strictly earlier than the activation stamp: equality fails closed + // (Codex P2 PRRT_kwDOPxxmWM6cS8Bu). + const enqueuedAtMs = Date.now() - 10; const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); - expect(created.createdAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + expect(created.createdAtMs).toBeGreaterThan(enqueuedAtMs); const result = await session.sendMessage("Queued before the goal existed", SEND_OPTIONS, { enqueuedAtMs, @@ -619,6 +621,78 @@ describe("AgentSession goal safety hooks", () => { session.dispose(); }); + test("same-millisecond activation and message authoring fail closed to pause", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cS8Bu): millisecond timestamps cannot order an + // activation against a message authored in the same millisecond, so + // equality cannot prove the message was already pending when the user + // activated — fail closed into the pause. + const workspaceId = "consent-equal-millisecond"; + const { session, goalService, cleanup } = await createSessionHarness(workspaceId); + cleanups.push(cleanup); + const created = await setGoalOk(goalService, { workspaceId, objective: "Equal ms" }); + if (created.lastUserActivationAtMs == null) { + throw new Error("expected a user-created goal to carry an activation stamp"); + } + + const result = await session.sendMessage("Same instant", SEND_OPTIONS, { + enqueuedAtMs: created.lastUserActivationAtMs, + }); + + expect(result.success).toBe(true); + expect(await goalService.getGoal(workspaceId)).toMatchObject({ status: "paused" }); + session.dispose(); + }); + + test("a legacy goal follow-up without goal identity is discarded", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cS8Bq): pre-upgrade compaction summaries + // persisted goalKind without any goalId field, so the durable admission + // revalidation cannot scope them — goal A could be replaced while the + // summary sat at the tail and its captured objective would redispatch as + // an unscoped synthetic turn charged to the current goal. Fail closed. + const workspaceId = "compaction-followup-legacy-unscoped"; + const { session, goalService, historyService, cleanup } = + await createSessionHarness(workspaceId); + cleanups.push(cleanup); + await setGoalOk(goalService, { workspaceId, objective: "Current goal" }); + const summary = createMuxMessage( + `summary-${crypto.randomUUID()}`, + "assistant", + "Compacted conversation.", + { + timestamp: Date.now(), + muxMetadata: { + type: "compaction-summary", + pendingFollowUp: { + text: "Continue working on the goal.", + agentId: "exec", + model: "openai:gpt-4o", + agentInitiated: true, + goalKind: GOAL_CONTINUATION_KIND, + }, + }, + } + ); + expect((await historyService.appendToHistory(workspaceId, summary)).success).toBe(true); + + const sendSpy = spyOn(session, "sendMessage").mockImplementation(() => + Promise.resolve(Ok(undefined)) + ); + const dispatched = await ( + session as unknown as { dispatchPendingFollowUp: (id?: string) => Promise } + ).dispatchPendingFollowUp(); + sendSpy.mockRestore(); + + expect(dispatched).toBe(false); + expect(sendSpy).not.toHaveBeenCalled(); + const tail = await historyService.getLastMessages(workspaceId, 1); + expect(tail.success).toBe(true); + if (tail.success) { + const meta = tail.data[0]?.metadata?.muxMetadata; + expect(meta && "pendingFollowUp" in meta ? meta.pendingFollowUp : undefined).toBeUndefined(); + } + session.dispose(); + }); + test("queued messages predating a model-created goal still pause it", async () => { // Codex security P2 (PRRT_kwDOPxxmWM6cSGrq): a model can publish a goal // AFTER the user queued a stop/correction, so timestamp order alone must @@ -655,12 +729,15 @@ describe("AgentSession goal safety hooks", () => { await setGoalOk(goalService, { workspaceId, objective: "Resumable goal" }); await setGoalOk(goalService, { workspaceId, status: "paused", initiator: "user" }); const enqueuedAtMs = Date.now(); + // Strictly-later activation: equality fails closed (Codex P2 + // PRRT_kwDOPxxmWM6cS8Bu), so step past the sampled millisecond. + await new Promise((resolve) => setTimeout(resolve, 2)); const resumed = await setGoalOk(goalService, { workspaceId, status: "active", initiator: "user", }); - expect(resumed.lastUserActivationAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + expect(resumed.lastUserActivationAtMs).toBeGreaterThan(enqueuedAtMs); const result = await session.sendMessage("Queued before the resume", SEND_OPTIONS, { enqueuedAtMs, @@ -776,7 +853,9 @@ describe("AgentSession goal safety hooks", () => { const workspaceId = "pre-stop-send-keeps-gate"; const { session, goalService, cleanup } = await createSessionHarness(workspaceId); cleanups.push(cleanup); - const enqueuedAtMs = Date.now(); + // Strictly earlier than the activation stamp: equality fails closed + // (Codex P2 PRRT_kwDOPxxmWM6cS8Bu). + const enqueuedAtMs = Date.now() - 10; const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); await goalService.recordUserStoppedStream(workspaceId, created.createdAtMs + 5_000); @@ -801,9 +880,11 @@ describe("AgentSession goal safety hooks", () => { const { session, goalService, cleanup } = await createSessionHarness(workspaceId); cleanups.push(cleanup); const candidates = registerBusyKickoffConsumer(goalService); - const enqueuedAtMs = Date.now(); + // Strictly earlier than the activation stamp: equality fails closed + // (Codex P2 PRRT_kwDOPxxmWM6cS8Bu). + const enqueuedAtMs = Date.now() - 10; const created = await setGoalOk(goalService, { workspaceId, objective: "Fresh goal" }); - expect(created.createdAtMs).toBeGreaterThanOrEqual(enqueuedAtMs); + expect(created.createdAtMs).toBeGreaterThan(enqueuedAtMs); expect(candidates.has(workspaceId)).toBe(true); const result = await session.sendMessage("Queued before the goal existed", SEND_OPTIONS, { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 613773a242b..c937e55362c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1694,10 +1694,13 @@ export class AgentSession { // postdates the message's authoring: the user acted with the message // already pending, a genuine opt-in. Model-created goals carry no consent // stamp and fail closed into the visible, resumable pause below. + // Strict ordering (Codex P2 PRRT_kwDOPxxmWM6cS8Bu): millisecond timestamps + // cannot order same-millisecond events, so equality cannot prove the + // message was already pending at activation — it fails closed to pause. if ( input.enqueuedAtMs != null && goal?.lastUserActivationAtMs != null && - goal.lastUserActivationAtMs >= input.enqueuedAtMs + goal.lastUserActivationAtMs > input.enqueuedAtMs ) { if (suspendedCandidate != null) { // The restore re-verifies goal identity + active status under the @@ -6958,6 +6961,23 @@ export class AgentSession { return false; } + // Codex P1 (PRRT_kwDOPxxmWM6cS8Bq): pre-upgrade summaries persisted + // goalKind without any goalId field, so the durable admission + // revalidation below cannot scope them — goal A could be replaced while + // the summary sat at the tail, and redispatching its captured objective + // as an unscoped synthetic turn would do autonomous work charged to the + // current goal. Fail closed and discard; an active goal re-arms fresh, + // properly scoped continuations through the normal idle/stream-end paths. + if (persistedGoalKind != null && persistedGoalId == null) { + log.info("Discarding legacy goal follow-up without goal identity", { + workspaceId: this.workspaceId, + summaryMessageId: lastMessage.id, + goalKind: persistedGoalKind, + }); + await this.clearPendingFollowUpFromSummary(lastMessage); + return false; + } + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): goal-loop follow-ups were originally // requireIdle sends — enforce the idle rule for them unconditionally so a // user message queued during the compaction stream wins the race instead diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 7cec1a9aad2..9fdc1afd0d4 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -534,6 +534,66 @@ describe("WorkspaceGoalService", () => { } }); + test("a Stop in flight refuses redispatch admissions before its acknowledgment commits", async () => { + // Codex security P2 (PRRT_kwDOPxxmWM6cS7qG): recordUserStoppedStream bumps + // the stop generation BEFORE awaiting the goal lock, so an admission built + // in that window captures the post-Stop generation as its fresh baseline + // while readGoalFile still returns the pre-Stop active record (no + // acknowledgment gate yet). The later active→active acknowledgment write + // moves no generation — the in-flight Stop itself must refuse admission. + const created = await setGoalOk(service, { workspaceId, objective: "Stop latch" }); + const svc = service as unknown as { + writeGoal: (workspaceId: string, goal: GoalRecordV1) => Promise; + }; + const realWriteGoal = svc.writeGoal.bind(service); + let releaseAck!: () => void; + const ackGate = new Promise((resolve) => { + releaseAck = resolve; + }); + const writeSpy = spyOn(svc, "writeGoal").mockImplementationOnce( + async (wsId: string, goal: GoalRecordV1) => { + await ackGate; + return realWriteGoal(wsId, goal); + } + ); + try { + const stopPromise = service.recordUserStoppedStream(workspaceId); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + expect(admission.admissible).toBe(false); + releaseAck(); + await stopPromise; + } finally { + releaseAck(); + writeSpy.mockRestore(); + } + }); + + test("a synthetic assistant follower does not mark a manual row processed", async () => { + // Codex security P2 (PRRT_kwDOPxxmWM6cS8Bx): synthetic assistant artifacts + // (e.g. the goal-cleared summary appended by clearGoal auto-promotion) are + // not the manual turn's settled response. Treating one as proof that the + // intervention was processed would keep an auto-promoted goal active with + // its autonomous kickoff recoverable over an unprocessed intervention. + await appendUserHistoryMessage(historyService, workspaceId, "Stop this"); + await appendAssistantHistoryMessage(historyService, workspaceId, "Goal cleared: summary", { + timestamp: Date.now(), + synthetic: true, + }); + await setGoalOk(service, { + workspaceId, + objective: "Auto-promoted goal", + initiator: "model", + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "paused" }); + }); + test("a user Stop invalidates captured redispatch admissions", async () => { // Codex security P2 (PRRT_kwDOPxxmWM6cSx0M): recordUserStoppedStream // leaves an active goal's status and identity unchanged (it only bumps the diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index ed241726b10..ab879e91cb5 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -502,6 +502,17 @@ export class WorkspaceGoalService { * misread as a fresh stop and falsely discard an unrelated in-flight setter. */ private readonly userStopGenerationsByWorkspace = new Map(); + /** + * Stops whose acknowledgment write has not committed yet. Codex security P2 + * (PRRT_kwDOPxxmWM6cS7qG): `recordUserStoppedStream` bumps the stop + * generation BEFORE awaiting the goal lock, so a redispatch admission built + * inside that window captures the post-Stop generation as its fresh + * baseline while reading the pre-Stop active record — and the later + * active→active acknowledgment write moves no generation. While a Stop is + * in flight, admissions must refuse outright; once the acknowledgment + * commits, the durable `requireUserAcknowledgmentSinceMs` gate takes over. + */ + private readonly pendingStopAcknowledgmentCounts = new Map(); private recordedStreamStartedAtMsByWorkspace = new Map(); private lastGoalStreamStamps = new Map(); /** @@ -826,9 +837,15 @@ export class WorkspaceGoalService { // turn that consumed it settled (see manualRowProcessed field doc). A // partial assistant row (crash mid-response) stays unprocessed so the // fail-closed pause + crash-recovery acknowledgment gates apply. + // Codex security P2 (PRRT_kwDOPxxmWM6cS8Bx): synthetic assistant + // artifacts (goal-cleared summaries, family-message payloads) are not + // the manual turn's settled response — only a real, completed assistant + // response proves the turn consumed the row. const followerRow = historyResult.data[index + 1]; const manualRowProcessed = - followerRow?.role === "assistant" && followerRow.metadata?.partial !== true; + followerRow?.role === "assistant" && + followerRow.metadata?.partial !== true && + followerRow.metadata?.synthetic !== true; return { mode: "paused", pausedBy: "manual_user", @@ -897,13 +914,15 @@ export class WorkspaceGoalService { // must still pause even when the dispatch-time auto-pause was lost to a // crash (persisted state stays self-healing). Explicit pause paths are // unaffected: they append a goal-pause-boundary row, which reconciles - // via the pause_boundary branch. + // via the pause_boundary branch. Strict ordering (Codex P2 + // PRRT_kwDOPxxmWM6cS8Bu): same-millisecond equality cannot prove the + // row was pending at activation — it fails closed to pause. if ( goal.lastContinuationFiredAtMs == null && (chatTailMode.manualRowProcessed === true || (chatTailMode.manualRowAuthoredAtMs != null && goal.lastUserActivationAtMs != null && - chatTailMode.manualRowAuthoredAtMs <= goal.lastUserActivationAtMs)) + chatTailMode.manualRowAuthoredAtMs < goal.lastUserActivationAtMs)) ) { return goal; } @@ -1608,6 +1627,12 @@ export class WorkspaceGoalService { (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild || this.userStopLandedSince(workspaceId, userStopGenerationAtBuild) || + // Codex security P2 (PRRT_kwDOPxxmWM6cS7qG): a Stop whose acknowledgment + // write has not committed bumped the generation BEFORE this baseline was + // captured — the baseline is fresh and the record still reads pre-Stop. + // Refuse while the Stop is in flight; after commit the durable + // acknowledgment gate below covers new builds. + (this.pendingStopAcknowledgmentCounts.get(workspaceId) ?? 0) > 0 || current?.goalId !== goalId || current.requireUserAcknowledgmentSinceMs != null ) { @@ -1635,7 +1660,8 @@ export class WorkspaceGoalService { (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtBuild || (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild || - this.userStopLandedSince(workspaceId, userStopGenerationAtBuild), + this.userStopLandedSince(workspaceId, userStopGenerationAtBuild) || + (this.pendingStopAcknowledgmentCounts.get(workspaceId) ?? 0) > 0, }; } @@ -1797,6 +1823,30 @@ export class WorkspaceGoalService { // user did not intend (the stop was meant to discard the goal change). const hadPendingGoalMutation = this.pendingGoalMutations.delete(workspaceId); + // Armed in the same synchronous block as the generation bump above so no + // admission can observe the bumped generation without the latch (see + // pendingStopAcknowledgmentCounts). + this.pendingStopAcknowledgmentCounts.set( + workspaceId, + (this.pendingStopAcknowledgmentCounts.get(workspaceId) ?? 0) + 1 + ); + try { + await this.recordUserStoppedStreamLocked(workspaceId, stoppedAtMs, hadPendingGoalMutation); + } finally { + const remaining = (this.pendingStopAcknowledgmentCounts.get(workspaceId) ?? 1) - 1; + if (remaining <= 0) { + this.pendingStopAcknowledgmentCounts.delete(workspaceId); + } else { + this.pendingStopAcknowledgmentCounts.set(workspaceId, remaining); + } + } + } + + private async recordUserStoppedStreamLocked( + workspaceId: string, + stoppedAtMs: number, + hadPendingGoalMutation: boolean + ): Promise { await this.fileLocks.withLock(workspaceId, async () => { const current = await this.readGoalFile(workspaceId); if (current?.status !== "active" && current?.status !== "budget_limited") { From 9e59e31241a1a0ba334b954d25cbe4daae65d5f9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 00:43:42 +0000 Subject: [PATCH 49/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2042b=20?= =?UTF-8?q?=E2=80=94=20objective-revision=20generation;=20promotion=20stop?= =?UTF-8?q?=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - goalIdentityGenerations now bumps on same-ID objective revisions (editInPlace renames), so captured continuations embedding the old objective go stale when the user redirects the goal (PRRT_kwDOPxxmWM6cS8B1) - promoteNextUpcomingUnlocked re-samples the stop veto after its board/goal/snapshot writes and rolls the promotion transaction back (restore board + prior record + re-publish) when a Stop landed inside them (PRRT_kwDOPxxmWM6cS8B4) --- .../services/workspaceGoalService.test.ts | 73 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 49 +++++++++++-- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index 9fdc1afd0d4..c7c68d679e9 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3701,6 +3701,79 @@ describe("WorkspaceGoalService", () => { expect(upcomingEntry?.goal.goalId).toBe(queued.goalId); }); + test("a stop landing during the promotion writes rolls the promotion back", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cS8B4): the pre-write veto above cannot see a + // Stop landing INSIDE the promotion's board/goal/snapshot writes. The + // promotion would complete durably, and the caller deliberately skips + // restoring once goal.json no longer holds its completion — leaving the + // aborted completion archived and the board advanced despite the Stop. + const created = await setGoalOk(service, { workspaceId, objective: "Abort mid-write" }); + const queued = await service.addUpcomingGoal({ workspaceId, objective: "Next in queue" }); + + const serviceAccess = service as unknown as { + writeBoard: (id: string, board: unknown) => Promise; + }; + const realWriteBoard = serviceAccess.writeBoard.bind(service); + const boardSpy = spyOn(serviceAccess, "writeBoard").mockImplementationOnce( + async (id: string, board: unknown) => { + // Stop lands during the first promotion write: the generation bump is + // synchronous even though the locked acknowledgment waits behind the + // in-flight setter's lock tenure. + void service.recordUserStoppedStream(id); + return realWriteBoard(id, board); + } + ); + + const completed = await service.setGoal({ + workspaceId, + status: "complete", + completionSummary: "Done before the user could stop it", + initiator: "model", + }); + boardSpy.mockRestore(); + expect(completed.success).toBe(false); + if (!completed.success) { + expect(completed.error.type).toBe("invalid_transition"); + } + + // The promotion transaction rolled back and the caller restored the + // pre-completion record. + expect(await service.getGoal(workspaceId)).toMatchObject({ + goalId: created.goalId, + status: "active", + }); + // The upcoming goal was not consumed by the aborted promotion. + const board = await service.getGoalBoard(workspaceId); + const upcomingEntry = board.entries.find((e) => e.section === "upcoming"); + expect(upcomingEntry?.goal.goalId).toBe(queued.goalId); + }); + + test("a same-ID objective edit invalidates captured redispatch admissions", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cS8B1): an editInPlace rename keeps the + // goalId, so the identity generation previously stayed put — a captured + // continuation embedding the OLD objective could be admitted after the + // user redirected the goal. + const created = await setGoalOk(service, { workspaceId, objective: "Original objective" }); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_CONTINUATION_KIND + ); + expect(admission.admissible).toBe(true); + if (!admission.admissible) { + throw new Error("expected admissible probe"); + } + expect(admission.admissionStale()).toBe(false); + + await setGoalOk(service, { + workspaceId, + objective: "Redirected objective", + editInPlace: true, + }); + + expect(admission.admissionStale()).toBe(true); + }); + test("a failed preview reset publication is retried on the next usage delta", async () => { // Codex P2 (PRRT_kwDOPxxmWM6cOgXY): the reset deleted the cached live // preview BEFORE publishing the durable snapshot. A failed publication diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index ab879e91cb5..a69493ba026 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -602,7 +602,10 @@ export class WorkspaceGoalService { * corrupts the successor's budget. */ private readonly goalIdentityGenerations = new Map(); - private readonly lastWrittenGoalIds = new Map(); + private readonly lastWrittenGoalIdentities = new Map< + string, + { goalId: string; objective: string } + >(); private armPauseFinalizationHold(workspaceId: string, goalId: string): void { this.explicitPauseGenerations.set( @@ -1184,8 +1187,18 @@ export class WorkspaceGoalService { } // See goalIdentityGenerations: identity changes (replacement, revival, // first write of the process) invalidate captured dispatch admissions. - if (this.lastWrittenGoalIds.get(workspaceId) !== goal.goalId) { - this.lastWrittenGoalIds.set(workspaceId, goal.goalId); + // Codex P1 (PRRT_kwDOPxxmWM6cS8B1): same-ID objective revisions + // (editInPlace renames) count too — a captured continuation's payload + // embeds the old objective, so admitting it after the user redirects the + // goal would run tools toward work the user just replaced. Limit-only + // edits stay fresh: they do not change what work runs, and newly + // exhausted limits flip status (bumping the terminal generation above). + const lastIdentity = this.lastWrittenGoalIdentities.get(workspaceId); + if (lastIdentity?.goalId !== goal.goalId || lastIdentity.objective !== goal.objective) { + this.lastWrittenGoalIdentities.set(workspaceId, { + goalId: goal.goalId, + objective: goal.objective, + }); this.goalIdentityGenerations.set( workspaceId, (this.goalIdentityGenerations.get(workspaceId) ?? 0) + 1 @@ -4292,7 +4305,7 @@ export class WorkspaceGoalService { // Goal deletion is an identity transition too. In-flight admissions only // observe generation counters after their initial durable read, so clear // must invalidate them even when no upcoming goal is promoted. - this.lastWrittenGoalIds.delete(workspaceId); + this.lastWrittenGoalIdentities.delete(workspaceId); this.goalIdentityGenerations.set( workspaceId, (this.goalIdentityGenerations.get(workspaceId) ?? 0) + 1 @@ -5298,16 +5311,36 @@ export class WorkspaceGoalService { } // Codex P1 (PRRT_kwDOPxxmWM6cOgXV): last stop sample before the promotion // writes — an abort landing during this helper's own board/streaming/ - // pricing reads must not promote a goal from the aborted turn. (A stop - // landing inside the writes below leaves the promoted goal active; the - // stop's queued locked section then installs its acknowledgment gate on - // it, halting autonomy.) + // pricing reads must not promote a goal from the aborted turn. if (options?.stopVeto?.() === true) { return null; } + // Snapshot for the post-write rollback below: the caller's completion (or + // prior record) currently occupies goal.json. + const priorGoal = options?.stopVeto != null ? await this.readGoalFile(workspaceId) : null; await this.writeBoard(workspaceId, { ...board, upcoming: rest }); await this.writeGoal(workspaceId, activated); await this.pushSnapshot(workspaceId, activated); + // Codex P1 (PRRT_kwDOPxxmWM6cS8B4): the sample above precedes the three + // awaited writes. A Stop landing inside them would otherwise leave the + // promotion durable — the caller deliberately skips restoring once + // goal.json no longer holds its completion, so the aborted turn's + // completion would survive and the board would advance despite the Stop. + // Re-sample and roll the transaction back: restore the pre-promotion + // board and goal record and re-publish. The completed goal's history + // entry stays (append-only and cosmetic, matching stops that land during + // other archive appends); the caller's own recheck then restores its + // pre-completion record normally. + if (options?.stopVeto?.() === true) { + await this.writeBoard(workspaceId, board); + if (priorGoal != null) { + await this.writeGoal(workspaceId, priorGoal); + } else { + await fs.rm(this.getFilePath(workspaceId), { force: true }); + } + await this.pushSnapshot(workspaceId, priorGoal); + return null; + } this.emitLifecycle("goal_created", { viaFork: false, sourceStatus: head.status, From f992e3434cfed78231040e790974edc16ee2f1cb Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 26 Aug 2026 01:03:07 +0000 Subject: [PATCH 50/50] =?UTF-8?q?=F0=9F=A4=96=20review:=20round=2043=20?= =?UTF-8?q?=E2=80=94=20terminal-exit=20generation=20bumps;=20consent=20arm?= =?UTF-8?q?=20independent=20of=20kickoff=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - terminalStatusGenerations now also bumps when a record leaves a terminal status (budget raise re-arms budget_limited→active), so captured budget wrap-up admissions go stale instead of charging a stopping turn after reactivation (PRRT_kwDOPxxmWM6cTN_o) - the durable-tail consent arm applies independently of the never-driven kickoff guard, preserving an explicit Resume for goals that already fired continuations (PRRT_kwDOPxxmWM6cTN_r) --- .../services/workspaceGoalService.test.ts | 68 +++++++++++++++++++ src/node/services/workspaceGoalService.ts | 39 +++++++++-- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index c7c68d679e9..b6a2ed2b250 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -3748,6 +3748,74 @@ describe("WorkspaceGoalService", () => { expect(upcomingEntry?.goal.goalId).toBe(queued.goalId); }); + test("raising the budget out of budget_limited invalidates captured wrap-up admissions", async () => { + // Codex P1 (PRRT_kwDOPxxmWM6cTN_o): re-arming budget_limited→active by + // raising the exhausted limit changes neither identity, objective, nor + // pause state — a budget wrap-up admission captured before the raise + // would stay fresh and charge a stale stopping turn after reactivation. + const created = await setGoalOk(service, { + workspaceId, + objective: "Re-armed budget", + budgetCents: 100, + }); + await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.25, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + expect(await service.getGoal(workspaceId)).toMatchObject({ status: "budget_limited" }); + const admission = await service.buildGoalRedispatchAdmission( + workspaceId, + created.goalId, + GOAL_BUDGET_LIMIT_KIND + ); + expect(admission.admissible).toBe(true); + if (!admission.admissible) { + throw new Error("expected admissible probe"); + } + expect(admission.admissionStale()).toBe(false); + + // User raises the limit: applyBudgetDrivenStatus re-arms the goal active. + await setGoalOk(service, { workspaceId, budgetCents: 1_000 }); + expect(await service.getGoal(workspaceId)).toMatchObject({ status: "active" }); + + expect(admission.admissionStale()).toBe(true); + }); + + test("getGoal preserves Resume consent for previously driven goals", async () => { + // Codex P2 (PRRT_kwDOPxxmWM6cTN_r): the consent arm must apply + // independently of the never-driven kickoff guard. A user who explicitly + // resumes with a queued message pending has opted in — the row's + // dispatch-time tail sync runs before manual-message goal safety, and + // writing the resumed goal back to paused would discard the Resume with + // no repair path (candidate restoration requires an active goal). + await setGoalOk(service, { workspaceId, objective: "Driven then resumed" }); + await driveOneContinuation(); + // Keep the resume from arming a kickoff candidate so this exercises the + // durable path (candidates are lost on restart/eviction anyway). + (service as unknown as { suppressKickoffContinuation: boolean }).suppressKickoffContinuation = + true; + try { + await setGoalOk(service, { workspaceId, status: "paused" }); + const authoredAtMs = Date.now(); + await new Promise((resolve) => setTimeout(resolve, 2)); + await setGoalOk(service, { workspaceId, status: "active" }); + // The queued row dispatches after the Resume; its authoring predates it. + await appendUserHistoryMessage(historyService, workspaceId, "Queued before resume", { + timestamp: Date.now(), + enqueuedAtMs: authoredAtMs, + }); + + const reconciled = await service.getGoal(workspaceId); + + expect(reconciled).toMatchObject({ status: "active" }); + } finally { + (service as unknown as { suppressKickoffContinuation: boolean }).suppressKickoffContinuation = + false; + } + }); + test("a same-ID objective edit invalidates captured redispatch admissions", async () => { // Codex P1 (PRRT_kwDOPxxmWM6cS8B1): an editInPlace rename keeps the // goalId, so the identity generation previously stayed put — a captured diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index a69493ba026..5b220db50f2 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -606,6 +606,7 @@ export class WorkspaceGoalService { string, { goalId: string; objective: string } >(); + private readonly lastWrittenGoalStatuses = new Map(); private armPauseFinalizationHold(workspaceId: string, goalId: string): void { this.explicitPauseGenerations.set( @@ -920,12 +921,24 @@ export class WorkspaceGoalService { // via the pause_boundary branch. Strict ordering (Codex P2 // PRRT_kwDOPxxmWM6cS8Bu): same-millisecond equality cannot prove the // row was pending at activation — it fails closed to pause. + // + // The consent arm applies independently of the never-driven guard + // (Codex P2 PRRT_kwDOPxxmWM6cTN_r): a user who explicitly resumed with + // the queued message already pending has opted in, and the row's + // dispatch-time tail sync runs BEFORE manual-message goal safety — + // writing the resumed goal back to paused here would discard the Resume + // with no repair path (candidate restoration requires an active goal). + // The processed-row arm stays scoped to the kickoff window: for a + // driven goal, a settled manual turn with no later continuation row + // means the dispatch-time auto-pause was lost, and reconciling to + // paused is the self-healing path. + const consentCoversManualRow = + chatTailMode.manualRowAuthoredAtMs != null && + goal.lastUserActivationAtMs != null && + chatTailMode.manualRowAuthoredAtMs < goal.lastUserActivationAtMs; if ( - goal.lastContinuationFiredAtMs == null && - (chatTailMode.manualRowProcessed === true || - (chatTailMode.manualRowAuthoredAtMs != null && - goal.lastUserActivationAtMs != null && - chatTailMode.manualRowAuthoredAtMs < goal.lastUserActivationAtMs)) + consentCoversManualRow || + (goal.lastContinuationFiredAtMs == null && chatTailMode.manualRowProcessed === true) ) { return goal; } @@ -1162,8 +1175,19 @@ export class WorkspaceGoalService { await writeFileAtomic(filePath, `${JSON.stringify(goal, null, 2)}\n`, "utf-8"); // See terminalStatusGenerations: bumped at the write commit point so // in-flight dispatch admission probes observe terminal transitions from - // every write path. - if (goal.status === "complete" || goal.status === "budget_limited") { + // every write path. Codex P1 (PRRT_kwDOPxxmWM6cTN_o): transitions OUT of + // a terminal status bump too — raising/removing an exhausted limit + // re-arms budget_limited→active without changing identity, objective, or + // pause state, and a captured budget wrap-up admission would otherwise + // stay fresh and charge a stale stopping turn after reactivation. + // Non-terminal→non-terminal writes (per-stream accounting on active + // goals) still never bump. + const isTerminalStatus = goal.status === "complete" || goal.status === "budget_limited"; + const previousWrittenStatus = this.lastWrittenGoalStatuses.get(workspaceId); + const wasTerminalStatus = + previousWrittenStatus === "complete" || previousWrittenStatus === "budget_limited"; + this.lastWrittenGoalStatuses.set(workspaceId, goal.status); + if (isTerminalStatus || wasTerminalStatus) { this.terminalStatusGenerations.set( workspaceId, (this.terminalStatusGenerations.get(workspaceId) ?? 0) + 1 @@ -4306,6 +4330,7 @@ export class WorkspaceGoalService { // observe generation counters after their initial durable read, so clear // must invalidate them even when no upcoming goal is promoted. this.lastWrittenGoalIdentities.delete(workspaceId); + this.lastWrittenGoalStatuses.delete(workspaceId); this.goalIdentityGenerations.set( workspaceId, (this.goalIdentityGenerations.get(workspaceId) ?? 0) + 1