diff --git a/src/cli/run.ts b/src/cli/run.ts index 2047b81e30..6e88549ea8 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/orpc/schemas/goal.ts b/src/common/orpc/schemas/goal.ts index cf0e74d9fd..d813789908 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(), @@ -47,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/common/types/goal.ts b/src/common/types/goal.ts index fb425d982b..bc9b289606 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/common/types/message.ts b/src/common/types/message.ts index 7e1faa4817..505698d5b6 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; /** @@ -598,6 +612,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 @@ -829,6 +851,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. * @@ -867,6 +896,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.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 53ceb0d016..e41f349caa 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.budgetGate.test.ts b/src/node/services/agentSession.budgetGate.test.ts index 6c8ebfa4e5..5481647bb8 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.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index e672a7d61e..0c45c445d0 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.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 27a8427fa2..ab65098f99 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 d3b7e37676..7ba6a05ecf 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"; @@ -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 } 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"; @@ -188,6 +192,395 @@ 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("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("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("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 } = + 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); @@ -204,6 +597,306 @@ 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); + // 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).toBeGreaterThan(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("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 + // 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(); + // 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).toBeGreaterThan(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); + 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(); + }); + + // 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 + // 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")) + ); + + 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(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("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); + // 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); + + 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 + // 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); + // 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).toBeGreaterThan(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(); + }); + 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.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 80d73889d2..248933ab3d 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 49bfc60093..c937e55362 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"; @@ -216,6 +217,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 { @@ -243,6 +246,12 @@ 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 toValidGoalId(value) ?? undefined; +} + const PDF_MEDIA_TYPE = "application/pdf"; const ACP_PROMPT_ID_METADATA_KEY = "acpPromptId"; const ACP_DELEGATED_TOOLS_METADATA_KEY = "acpDelegatedTools"; @@ -528,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 { @@ -567,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 }> = []; @@ -777,6 +795,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; }; @@ -814,6 +834,7 @@ export class AgentSession { onCompactionComplete, onIdleCompactionOutcome, onPostCompactionStateChange, + hasExternalSendPreflight, } = options; assert(typeof workspaceId === "string", "workspaceId must be a string"); @@ -832,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, @@ -1166,7 +1188,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 +1200,7 @@ export class AgentSession { options, ...(agentInitiated === true ? { agentInitiated: true } : {}), ...(goalKind != null ? { goalKind } : {}), + ...(goalId != null ? { goalId } : {}), }; } @@ -1204,6 +1228,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) { @@ -1612,6 +1637,7 @@ export class AgentSession { private async applyManualUserMessageGoalSafety(input: { policy: GoalInterventionPolicy; + enqueuedAtMs?: number; }): Promise { const goalService = this.workspaceGoalService; if (!goalService) { @@ -1629,8 +1655,91 @@ 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. + // + // 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. + // + // 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 + // applies at that turn's stream end, and only then does the queued message + // 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. + // 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 + ) { + if (suspendedCandidate != null) { + // 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; + } + + // Also clears any candidate armed during the acknowledgment await — a + // post-goal intervention must not leave a consumable continuation behind. goalService.clearPendingContinuationForManualUserMessage(this.workspaceId); - const goal = await goalService.acknowledgeUser(this.workspaceId); + // 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 { + 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 + // 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 === "budget_limited") { + await suppressWrapupForGoal(goal.goalId); + } if (goal?.status !== "active") { return; } @@ -1646,12 +1755,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); } } @@ -1742,9 +1860,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). 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 ? coerceGoalId(rawPersistedGoalId) : undefined; const workspaceAgentIdCandidates = resolvePersistedAgentIdCandidates(workspaceMetadata); const workspaceAgentId = workspaceAgentIdCandidates[0] ?? WORKSPACE_DEFAULTS.agentId; @@ -1854,6 +1986,9 @@ export class AgentSession { if (persistedGoalKind != null) { compactionRequest.goalKind = persistedGoalKind; } + if (persistedGoalId != null) { + compactionRequest.goalId = persistedGoalId; + } return compactionRequest; } @@ -1894,6 +2029,9 @@ export class AgentSession { if (persistedGoalKind != null) { retryRequest.goalKind = persistedGoalKind; } + if (persistedGoalId != null) { + retryRequest.goalId = persistedGoalId; + } if (typeof persistedAllowAgentSetGoal === "boolean") { retryRequest.allowAgentSetGoal = persistedAllowAgentSetGoal; } @@ -2049,8 +2187,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 @@ -2712,12 +2850,34 @@ 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; 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 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 — @@ -2887,7 +3047,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 @@ -2898,7 +3059,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); @@ -3271,6 +3435,12 @@ 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 } : {}), // Auto-resume and other system-generated messages are synthetic + UI-visible ...(internal?.synthetic && { synthetic: true, uiVisible: true }), }, @@ -3365,6 +3535,7 @@ export class AgentSession { fileParts: followUpFileParts, agentInitiated, goalKind, + goalId: internal?.goalId, muxMetadata: typedMuxMetadata, workspaceTurnMetadata: inheritedWorkspaceTurnMetadata, }); @@ -3625,7 +3796,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. @@ -3696,7 +3870,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) { @@ -3745,6 +3919,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 { @@ -3785,6 +3962,7 @@ export class AgentSession { agentInitiated, preparedTurnAbortController.signal, goalKind, + internal?.goalId, turnThinkingOverride ); if (streamResult.success && preparedTurnAbortController.signal.aborted) { @@ -3854,7 +4032,7 @@ export class AgentSession { async resumeStream( options: SendMessageOptions, - internal?: { agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind } + internal?: { agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; goalId?: string } ): Promise> { this.assertNotDisposed("resumeStream"); @@ -3895,7 +4073,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 @@ -3913,6 +4096,7 @@ export class AgentSession { internal?.agentInitiated, undefined, internal?.goalKind, + internal?.goalId, turnThinkingOverride ); if (!result.success) { @@ -4068,7 +4252,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; @@ -4093,7 +4278,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); @@ -4173,6 +4366,7 @@ export class AgentSession { fileParts?: FilePart[]; agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; + goalId?: string; muxMetadata?: MuxMessageMetadata; workspaceTurnMetadata?: Extract; }): CompactionFollowUpRequest { @@ -4191,6 +4385,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; } @@ -4430,6 +4628,7 @@ export class AgentSession { options: streamContext.options, agentInitiated: streamContext.agentInitiated, goalKind: streamContext.goalKind, + goalId: streamContext.goalId, modelForStream: streamContext.modelString, muxMetadata: streamContext.workspaceTurnMetadata, }); @@ -4547,6 +4746,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. @@ -4573,6 +4773,7 @@ export class AgentSession { agentInitiated, openaiTruncationModeOverride, ...(goalKind != null ? { goalKind } : {}), + ...(goalId != null ? { goalId } : {}), providersConfig, }; this.activeStreamUserMessageId = undefined; @@ -5018,6 +5219,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, @@ -5037,7 +5239,12 @@ export class AgentSession { return false; } - this.setAutoRetryResumeState(retryOptionsForResume, retryAgentInitiated, retryGoalKind); + this.setAutoRetryResumeState( + retryOptionsForResume, + retryAgentInitiated, + retryGoalKind, + retryGoalId + ); this.preparingWorkspaceTurnMetadata = getWorkspaceTurnMuxMetadata( retryOptionsForResume.muxMetadata ); @@ -5051,7 +5258,8 @@ export class AgentSession { undefined, retryAgentInitiated, undefined, - retryGoalKind + retryGoalKind, + retryGoalId ); } finally { if (this.turnPhase === TurnPhase.PREPARING) { @@ -5157,7 +5365,8 @@ export class AgentSession { true, context.agentInitiated, undefined, - context.goalKind + context.goalKind, + context.goalId ); } finally { if (this.turnPhase === TurnPhase.PREPARING) { @@ -5188,6 +5397,8 @@ export class AgentSession { providerMetadata?: Record; metadataModel?: string; isCompaction?: boolean; + goalKind?: GoalSyntheticMessageKind; + agentInitiated?: boolean; }): Promise { if (!this.workspaceGoalService) { return; @@ -5198,12 +5409,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) { @@ -5361,6 +5577,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(); } @@ -5462,6 +5683,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. @@ -6069,6 +6292,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. */ @@ -6485,7 +6710,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(); @@ -6504,7 +6729,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 @@ -6719,11 +6944,55 @@ 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; + } + + // 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 + // of the synthetic continuation starting first. + const enforceIdleRule = + followUp.dispatchOptions?.requireIdle === true || persistedGoalKind != null; const hasQueuedMessages = this.hasPendingManualFollowUp(); const hasActiveNonCompletingTurn = this.isBusy() && this.turnPhase !== TurnPhase.COMPLETING; + // 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 ( - followUp.dispatchOptions?.requireIdle === true && - (hasQueuedMessages || hasActiveNonCompletingTurn) + enforceIdleRule && + (hasQueuedMessages || hasActiveNonCompletingTurn || hasExternalPreflightSend) ) { log.info("Skipping pending follow-up because the workspace is no longer idle", { workspaceId: this.workspaceId, @@ -6731,20 +7000,11 @@ export class AgentSession { 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 || hasExternalPreflightSend, + hasActiveNonCompletingTurn + ); return false; } @@ -6759,6 +7019,48 @@ 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 (persistedGoalKind != null && persistedGoalId != null && this.workspaceGoalService) { + const admission = await this.workspaceGoalService.buildGoalRedispatchAdmission( + this.workspaceId, + persistedGoalId, + persistedGoalKind + ); + if (!admission.admissible) { + log.info("Skipping goal-scoped pending follow-up: goal no longer admits it", { + workspaceId: this.workspaceId, + goalKind: persistedGoalKind, + }); + await this.clearPendingFollowUpFromSummary(lastMessage); + return false; + } + 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.hasExternalSendPreflight?.() === true || + (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), @@ -6808,7 +7110,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, + persistedGoalKind, + persistedGoalId + ); // Await sendMessage to ensure the follow-up is persisted before returning. // This guarantees ordering: the follow-up message is written to history @@ -6818,17 +7125,91 @@ export class AgentSession { const sendResult = await this.sendMessage(finalText, options, { synthetic: true, agentInitiated: followUp.agentInitiated, - goalKind: followUp.goalKind, - goalContinuation: followUp.goalKind === GOAL_CONTINUATION_KIND, + 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: persistedGoalId, + goalContinuation: persistedGoalKind === GOAL_CONTINUATION_KIND, + // Codex P1 (PRRT_kwDOPxxmWM6cPuMw): re-derived admission guard for the + // redispatched goal turn (see buildGoalRedispatchAdmission above). + 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.hasExternalSendPreflight?.() === true, + 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}`); } + // 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; } + /** + * 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 + * 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, + hasUserContention: boolean, + hasActiveNonCompletingTurn: boolean + ): Promise { + if ( + summaryMessage.metadata?.compacted === "heartbeat" && + hasUserContention && + !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", diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index b51635f4ff..22d950229b 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -10,6 +10,46 @@ 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("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"); + + 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 d536f33297..dbf71169ca 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -96,6 +96,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. */ @@ -161,6 +169,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; @@ -503,6 +518,11 @@ export class MessageQueue { addCount: 0, syntheticCount: 0, agentInitiatedCount: 0, + // 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); } @@ -570,6 +590,12 @@ export class MessageQueue { } entry.addCount += 1; + // 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; } @@ -784,6 +810,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) { @@ -840,7 +868,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 622354268a..b6a2ed2b25 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); @@ -348,22 +361,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 () => { @@ -388,8 +410,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 +447,250 @@ describe("WorkspaceGoalService", () => { expect(reconciled).toMatchObject({ status: "paused" }); }); - test("chat-tail reconciliation ignores synthetic maintenance user rows", async () => { - await setGoalOk(service, { workspaceId, objective: "Ignore maintenance rows" }); - await appendUserHistoryMessage(historyService, workspaceId, "Continue goal", { + 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 (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" }); + + 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("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("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, - uiVisible: true, - kind: GOAL_CONTINUATION_KIND, }); + 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 + // 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 + // 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 + // 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("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 + // and the synthetic-row skip below is what keeps it active. + await driveOneContinuation(); await appendUserHistoryMessage(historyService, workspaceId, "Synthetic heartbeat", { timestamp: Date.now(), synthetic: true, @@ -2182,97 +2468,2635 @@ describe("WorkspaceGoalService", () => { }); }); - test("queued mid-stream goal replacement preserves expectedGoalId at drain time", async () => { - const created = await setGoalOk(service, { workspaceId, objective: "Original" }); + 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 creation stamps creation at publication time", async () => { + // 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, { + ...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); + // 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, - objective: "Queued replacement", - expectedGoalId: created.goalId, + objective: "Publication stamp", + budgetCents: 500, }); + 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); + }); - await extensionMetadata.setStreaming(workspaceId, false); - await setGoalOk(service, { workspaceId, objective: "Concurrent replacement" }); + 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 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 && 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; + }); - const drained = await service.applyPendingAfterStreamEnd(workspaceId); + 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)).toMatchObject({ - objective: "Concurrent replacement", - }); + expect(await service.getGoal(workspaceId)).toBeNull(); }); - test("increments accounting for non-compaction stream completions", async () => { - const created = await setGoalOk(service, { workspaceId, objective: "Account for stream" }); - - const updated = await service.recordStreamAccounting({ + 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, - costUsd: 1.235, + 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" }); - expect(updated).toMatchObject({ costCents: 124, turnsUsed: 1 }); - expect(await service.getGoal(workspaceId)).toMatchObject({ costCents: 124, turnsUsed: 1 }); + // 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("accumulates sub-cent stream costs across goal turns", async () => { + 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: "Accumulate tiny costs", - budgetCents: 1, + objective: "User-exhausted budget", + budgetCents: 100, }); - - const first = await service.recordStreamAccounting({ + await service.recordStreamAccounting({ workspaceId, - costUsd: 0.004, + costUsd: 2, streamStartedAtMs: created.createdAtMs + 1, - streamOriginKind: "goal_continuation", + streamOriginKind: "user", }); - const second = await service.recordStreamAccounting({ + 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.004, + costUsd: 0.01, streamStartedAtMs: created.createdAtMs + 2, - streamOriginKind: "goal_continuation", - }); - const third = await service.recordStreamAccounting({ - workspaceId, - costUsd: 0.002, - streamStartedAtMs: created.createdAtMs + 3, - streamOriginKind: "goal_continuation", + streamOriginKind: "other", }); - expect(first).toMatchObject({ costCents: 0, costMicroCents: 400_000, status: "active" }); - expect(second).toMatchObject({ costCents: 1, costMicroCents: 800_000, status: "active" }); - expect(third).toMatchObject({ - costCents: 1, - costMicroCents: 1_000_000, - status: "budget_limited", - }); + const stamps = ( + service as unknown as { + lastGoalStreamStamps: Map; + } + ).lastGoalStreamStamps; + expect(stamps.get(workspaceId)?.originKind).toBe("user"); }); - test("paused goals ignore later stream accounting", async () => { + 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: "User clarification mid-goal", - turnCap: 3, + objective: "Direct publication stamp", + budgetCents: 500, }); - await setGoalOk(service, { - workspaceId, - objective: created.objective, - status: "paused", + + expect(midValidationMs).toBeGreaterThan(0); + expect(created.createdAtMs).toBeGreaterThanOrEqual(midValidationMs); + 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 updated = await service.recordStreamAccounting({ + const created = await setGoalOk(service, { workspaceId, - costUsd: 0.42, - streamStartedAtMs: created.createdAtMs + 1, - streamOriginKind: "user", + objective: "Atomic publication stamp", + budgetCents: 500, }); - expect(updated).toMatchObject({ costCents: 0, turnsUsed: 0, status: "paused" }); + // 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 + // 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("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("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 }; + }; + // 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; + }); + 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" }); + // 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); + 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 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("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("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 + // 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", + }); + + // 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()}`, + "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("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("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 + // 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("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("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 + // 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("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("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 + // 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 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("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 + // 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 + // 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 + // 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("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, + }); + + // 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, created.goalId); + expect(await service.getGoal(workspaceId)).toMatchObject({ + status: "budget_limited", + 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, + 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("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, created.goalId); + } 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, created.goalId); + expect(serviceAccess.lastGoalStreamStamps.get(workspaceId)?.originKind).toBe("user"); + 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 + // 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("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("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. + // 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 + // 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("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) + // 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("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 + // 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(); + + // 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, + }); + 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 + // 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 + // 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("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 setGoalOk(service, { workspaceId, objective: "First goal" }); + + 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. + // + // 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; + }); + 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, + // 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, + }); + // 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; + + 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); + + const queued = await service.setGoal({ + workspaceId, + objective: "Queued replacement", + expectedGoalId: created.goalId, + }); + expect(queued.success).toBe(true); + + await extensionMetadata.setStreaming(workspaceId, false); + await setGoalOk(service, { workspaceId, objective: "Concurrent replacement" }); + + const drained = await service.applyPendingAfterStreamEnd(workspaceId); + + expect(drained).toBeNull(); + expect(await service.getGoal(workspaceId)).toMatchObject({ + objective: "Concurrent replacement", + }); + }); + + test("increments accounting for non-compaction stream completions", async () => { + const created = await setGoalOk(service, { workspaceId, objective: "Account for stream" }); + + const updated = await service.recordStreamAccounting({ + workspaceId, + costUsd: 1.235, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + + expect(updated).toMatchObject({ costCents: 124, turnsUsed: 1 }); + expect(await service.getGoal(workspaceId)).toMatchObject({ costCents: 124, turnsUsed: 1 }); + }); + + test("accumulates sub-cent stream costs across goal turns", async () => { + const created = await setGoalOk(service, { + workspaceId, + objective: "Accumulate tiny costs", + budgetCents: 1, + }); + + const first = await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.004, + streamStartedAtMs: created.createdAtMs + 1, + streamOriginKind: "goal_continuation", + }); + const second = await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.004, + streamStartedAtMs: created.createdAtMs + 2, + streamOriginKind: "goal_continuation", + }); + const third = await service.recordStreamAccounting({ + workspaceId, + costUsd: 0.002, + streamStartedAtMs: created.createdAtMs + 3, + streamOriginKind: "goal_continuation", + }); + + expect(first).toMatchObject({ costCents: 0, costMicroCents: 400_000, status: "active" }); + expect(second).toMatchObject({ costCents: 1, costMicroCents: 800_000, status: "active" }); + expect(third).toMatchObject({ + costCents: 1, + costMicroCents: 1_000_000, + status: "budget_limited", + }); + }); + + test("paused goals ignore later stream accounting", async () => { + const created = await setGoalOk(service, { + workspaceId, + objective: "User clarification mid-goal", + 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: "user", + }); + + 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("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("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 () => { diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 530aa1eb46..5b220db50f 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, @@ -58,6 +59,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", @@ -180,6 +183,17 @@ 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; + /** + * 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 @@ -191,7 +205,7 @@ export interface GoalContinuationRuntimeBridge { type PendingGoalContinuationSource = "stream_end" | "kickoff" | "budget_wrapup"; -interface PendingGoalContinuationCandidate { +export interface PendingGoalContinuationCandidate { goalId: string; requestedAtMs: number; streamEndedAtMs: number; @@ -199,6 +213,29 @@ 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 + * 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; /** @@ -208,6 +245,45 @@ 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. + * 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 + * 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 { @@ -231,6 +307,22 @@ 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 + * 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. @@ -400,8 +492,165 @@ 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(); + /** + * 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(); + /** + * 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(); + /** + * 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 + * 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(); + + /** + * 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(); + + /** + * 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(); + + /** + * 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(); + + /** + * 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 lastWrittenGoalIdentities = new Map< + string, + { goalId: string; objective: string } + >(); + private readonly lastWrittenGoalStatuses = 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; + 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 + * 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; @@ -477,7 +726,22 @@ export class WorkspaceGoalService { this.streamInterrupter = interrupter; } - private async readChatTailGoalMode(workspaceId: string): Promise { + private async readChatTailGoalMode( + 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", { @@ -491,6 +755,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)) { @@ -498,15 +770,92 @@ 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. + // 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; + } + if (rowGoalId == null && crossedOtherGoalHistory) { + continue; + } return { mode: "active" }; } if (message.metadata?.muxMetadata?.type === "goal-pause-boundary") { - return { mode: "paused", pausedBy: "pause_boundary" }; + // 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 + // 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", + ...(boundaryGoalId != null && boundaryGoalId === currentGoalId + ? { boundaryGoalScoped: true } + : {}), + }; } 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. + // 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); + // 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. + // 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.metadata?.synthetic !== true; + return { + mode: "paused", + pausedBy: "manual_user", + ...(authoredAtMs != null ? { manualRowAuthoredAtMs: authoredAtMs } : {}), + ...(manualRowProcessed ? { manualRowProcessed: true } : {}), + }; } return { mode: null }; @@ -521,6 +870,11 @@ export class WorkspaceGoalService { 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 // row is appended, so the chat tail still ends at a pre-goal manual user @@ -536,12 +890,110 @@ 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 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). + // + // 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. 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 ( + consentCoversManualRow || + (goal.lastContinuationFiredAtMs == null && chatTailMode.manualRowProcessed === true) + ) { + return goal; + } const candidate = this.pendingContinuationCandidates.get(workspaceId); if (candidate?.source === "kickoff" && candidate.goalId === goal.goalId) { return goal; } } + // 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. + // 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" && + 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 + // 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. + // + // 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.explicitPauseGenerations.get(workspaceId) ?? 0) !== + chatTailMode.pauseGenerationAtRead) + ) { + return goal; + } + const desiredStatus = chatTailMode.mode; if (goal.status === desiredStatus) { return goal; @@ -560,13 +1012,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) { @@ -581,8 +1045,11 @@ export class WorkspaceGoalService { }); } - private async appendGoalPauseBoundaryIfNeeded(workspaceId: string): Promise { - const chatTailMode = await this.readChatTailGoalMode(workspaceId); + private async appendGoalPauseBoundaryIfNeeded( + workspaceId: string, + goalId: string + ): Promise { + const chatTailMode = await this.readChatTailGoalMode(workspaceId, goalId); if (chatTailMode.mode !== "active") { return true; } @@ -591,6 +1058,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", @@ -598,7 +1067,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); @@ -667,8 +1136,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, @@ -697,6 +1173,61 @@ 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. 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 + ); + } + // 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. + // 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 + ); + } } private async renameCorruptGoal( @@ -827,6 +1358,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); @@ -1006,6 +1540,199 @@ 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. + * + * 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. + */ + async restorePendingContinuationCandidate( + workspaceId: string, + candidate: PendingGoalContinuationCandidate + ): Promise { + assert( + workspaceId.trim().length > 0, + "restorePendingContinuationCandidate requires 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) { + 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); + return true; + }); + if (!restored) { + return; + } + this.goalContinuationDispatcher + ?.requestDispatch(workspaceId, GOAL_CONTINUATION_IDLE_CONSUMER_NAME) + .catch((error: unknown) => { + log.warn("Failed to re-request dispatch after candidate restore", { workspaceId, error }); + }); + } + + /** + * 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, 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( + 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"); + // 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; + // 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) || + // 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 + ) { + return { admissible: false }; + } + if (kind === GOAL_BUDGET_LIMIT_KIND) { + // 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 == null || + current.budgetLimitInjectedForGoalId === goalId); + if (!wrapupAdmitted) { + return { admissible: false }; + } + } else if (current.status !== "active") { + return { admissible: false }; + } + return { + admissible: true, + admissionStale: () => + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtBuild || + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== terminalGenerationAtBuild || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtBuild || + this.userStopLandedSince(workspaceId, userStopGenerationAtBuild) || + (this.pendingStopAcknowledgmentCounts.get(workspaceId) ?? 0) > 0, + }; + } + + /** + * 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 @@ -1081,10 +1808,47 @@ 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); + this.streamStartGenerations.set( + workspaceId, + (this.streamStartGenerations.get(workspaceId) ?? 0) + 1 + ); + } + 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 + // mutation nothing will drain (see drainSettledWorkspaces). + this.drainSettledWorkspaces.add(workspaceId); this.pendingContinuationCandidates.delete(workspaceId); this.pendingGoalSnapshots.delete(workspaceId); this.liveGoalPreviewSnapshots.delete(workspaceId); @@ -1096,6 +1860,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") { @@ -1153,12 +1941,48 @@ 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; + // 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, options: candidate.sendOptions, 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 || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== + wrapupIdentityGenerationAtDispatch + ); + }, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); @@ -1194,12 +2018,41 @@ 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; + // 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; + // 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, options: candidate.sendOptions, startStreamInBackground: candidate.source === "kickoff", kind: GOAL_CONTINUATION_KIND, + goalId: goal.goalId, + admissionStale: () => + this.pendingContinuationCandidates.get(workspaceId) !== candidate || + (this.explicitPauseGenerations.get(workspaceId) ?? 0) !== pauseGenerationAtDispatch || + (this.terminalStatusGenerations.get(workspaceId) ?? 0) !== + terminalGenerationAtDispatch || + (this.goalIdentityGenerations.get(workspaceId) ?? 0) !== identityGenerationAtDispatch, }); if (accepted !== true) { this.scheduleContinuationReRequest(workspaceId, Date.now() + 1_000); @@ -1415,14 +2268,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) @@ -1444,15 +2306,31 @@ 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) { 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; } @@ -1631,6 +2509,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) { @@ -1968,6 +2872,29 @@ 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; + // 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 + // 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 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 @@ -1987,11 +2914,33 @@ 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 (!(await this.isWorkspaceStreaming(input.workspaceId))) { + if (this.userStopLandedSince(input.workspaceId, userStopGenerationAtEntry)) { + 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.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 drained. + // Persist immediately instead of queueing after stream-end already + // 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); @@ -2006,6 +2955,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, @@ -2029,6 +2979,7 @@ export class WorkspaceGoalService { status: input.status, completionSummary: input.completionSummary, }); + projectedIsFreshGoal = true; } if ( (projected.status === "active" || projected.status === "budget_limited") && @@ -2039,13 +2990,38 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } - if (!(await this.isWorkspaceStreaming(input.workspaceId))) { + if (this.userStopLandedSince(input.workspaceId, userStopGenerationAtEntry)) { + 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.drainRanForRelevantStreamSince( + input.workspaceId, + drainGenerationAtEntry, + setterStreamStartGenerationAtEntry + ) + ) { // 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; } - this.pendingGoalMutations.set(input.workspaceId, { + // 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, + streamStartGeneration: this.streamStartGenerations.get(input.workspaceId) ?? 0, ...(Object.hasOwn(input, "budgetCents") ? { budgetCents: input.budgetCents ?? null } : {}), @@ -2061,16 +3037,54 @@ 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. ...(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 && + 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, + // 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. + // 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 + // 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)); + } return Ok(projected); }); if (deferredResult != null) { @@ -2078,7 +3092,30 @@ export class WorkspaceGoalService { } } - return this.setGoalImmediately({ ...input, objective }); + 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. + return Err({ + type: "invalid_transition" as const, + message: GOAL_SET_DISCARDED_BY_USER_STOP_MESSAGE, + }); + } + // 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 `generationAtEntry`. */ + private userStopLandedSince(workspaceId: string, generationAtEntry: number): boolean { + return (this.userStopGenerationsByWorkspace.get(workspaceId) ?? 0) !== generationAtEntry; } private async canRunBudgetedGoalOnKickoffModel( @@ -2097,9 +3134,56 @@ export class WorkspaceGoalService { private async setGoalImmediately( input: SetGoalInput & { objective?: string }, - options?: { replacementGoalId?: string | null } + options?: GoalPersistenceOptions ): Promise> { 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); + } + } + } + + /** + * 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?: 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) ?? @@ -2154,7 +3238,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)) @@ -2164,9 +3253,34 @@ export class WorkspaceGoalService { message: UNPRICED_TARGET_MODEL_GOAL_MESSAGE, }); } + const stoppedBeforeEditWrite = discardIfUserStopLanded(); + if (stoppedBeforeEditWrite) { + 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); + // 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"); @@ -2182,7 +3296,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); } @@ -2205,6 +3332,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. @@ -2220,13 +3351,60 @@ 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 + // 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); + // 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"); - 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", { @@ -2239,13 +3417,14 @@ export class WorkspaceGoalService { return Ok(updated); } - const next = this.createGoal({ + let next = this.createGoal({ objective, budgetCents: input.budgetCents ?? null, turnCap: input.turnCap ?? null, status: input.status, completionSummary: input.completionSummary, goalId: options?.replacementGoalId ?? null, + createdAtMs: options?.replacementCreatedAtMs ?? null, }); if ( (next.status === "active" || next.status === "budget_limited") && @@ -2256,6 +3435,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 @@ -2268,9 +3451,78 @@ 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; + } + } + if (options?.replacementCreatedAtMs == null) { + // Codex P2 (PRRT_kwDOPxxmWM6cBr9B): direct creations must also carry a + // 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({ + ...next, + createdAtMs: publishedAtMs, + 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 + // 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); + // 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, @@ -2279,11 +3531,32 @@ 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, + 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); @@ -2301,8 +3574,24 @@ 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); + const pauseBoundaryReady = await this.appendGoalPauseBoundaryIfNeeded( + input.workspaceId, + result.data.goalId + ); if (!pauseBoundaryReady) { return result; } @@ -2311,7 +3600,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 @@ -2321,7 +3612,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; @@ -2387,6 +3678,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 { @@ -2506,6 +3814,27 @@ 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" || + // 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; + } this.recordLastGoalStream(workspaceId, GOAL_CONTINUATION_KIND, goal.goalId); this.armImmediateContinuationCandidate(workspaceId, goal, "budget_wrapup", sendOptions); @@ -2543,7 +3872,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); @@ -2555,6 +3887,23 @@ 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. 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) { + await this.pushSnapshot(workspaceId, current); + return current; + } const next = this.applyMutableFields(current, { workspaceId, @@ -2566,6 +3915,72 @@ 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, + 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); + // 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. + // 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. + // 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, + budgetLimitOriginKind: "user", + 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); + } else { + markLiveStampUserOrigin(); + } + }); + } + async requireUserAcknowledgmentForCrashRecovery( workspaceId: string, sinceMs = Date.now() @@ -2598,6 +4013,36 @@ 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 { + 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); + } + /** * Push a live cost preview to the activity snapshot. The cost is the * cumulative current-stream cost on top of the durable base; @@ -2648,7 +4093,18 @@ 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 + // 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 this.resetIneligibleCostPreview(input.workspaceId, current); } const preview = GoalRecordV1Schema.parse({ @@ -2695,8 +4151,46 @@ export class WorkspaceGoalService { return null; } - if ((current.status === "paused" || current.status === "complete") && originKind === "user") { - this.recordLastGoalStream(input.workspaceId, originKind, current.goalId); + // 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 + // 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 !== "active" && !isGoalDrivenStream) { + // 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 preserveExistingStamp = + current.status === "budget_limited" && existingStamp?.goalId === current.goalId; + if (!preserveExistingStamp) { + // 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; } @@ -2832,6 +4326,15 @@ 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.lastWrittenGoalIdentities.delete(workspaceId); + this.lastWrittenGoalStatuses.delete(workspaceId); + this.goalIdentityGenerations.set( + workspaceId, + (this.goalIdentityGenerations.get(workspaceId) ?? 0) + 1 + ); await this.pushSnapshot(workspaceId, null); this.emitLifecycle("goal_cleared", { finalStatus: current.status, @@ -2883,36 +4386,205 @@ export class WorkspaceGoalService { } } + 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 { this.liveGoalPreviewSnapshots.delete(workspaceId); - const pending = this.pendingGoalMutations.get(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 + // already have stopped watching for. + this.bumpStreamEndDrainGeneration(workspaceId, streamStartGenerationAtEntry); let drained: GoalRecordV1 | null = null; - if (pending) { - this.pendingGoalMutations.delete(workspaceId); - this.pendingGoalSnapshots.delete(workspaceId); + // 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, 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; + } // 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, ...pendingInput } = pending; - const result = await this.setGoalImmediately( - { workspaceId, ...pendingInput }, - { replacementGoalId: projectedGoalId ?? null } - ); - drained = result.success ? result.data : 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; + } + // 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); + // 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, + streamStartGeneration: _claimedGeneration, + ...pendingInput + } = claimed; + const input = { workspaceId, ...pendingInput }; + 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, userStopGate }; + }); + if (tenure == null) { + break; + } + try { + 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 ( + 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, 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); + } } } + // 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, 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 + // 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. + // + // 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. // // Runs AFTER any queued setGoal drains so the deferred setGoal can @@ -3466,6 +5138,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); @@ -3538,7 +5213,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; @@ -3572,12 +5248,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); } /** @@ -3591,7 +5275,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`. @@ -3626,6 +5313,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 @@ -3643,9 +5334,38 @@ 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. + 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, diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index fab07f6fcd..25a4d1f965 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"; @@ -6266,6 +6324,168 @@ 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 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", + agentId: "exec", + }); + await waitForCondition(() => pricingStarted); + expect(probe!()).toBe(true); + + releasePricing(); + const result = await sendPromise; + expect(result.success).toBe(true); + 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 { + realSession.dispose(); + } + }); + + 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 { @@ -15214,6 +15434,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 482a164e64..93d59d189c 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: @@ -4218,6 +4250,15 @@ export class WorkspaceService extends EventEmitter { onPostCompactionStateChange: () => { this.schedulePostCompactionMetadataRefresh(workspaceId); }, + // Codex P1 (PRRT_kwDOPxxmWM6cRJD-): expose service-level send + // 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, }); } @@ -10563,6 +10604,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; @@ -10614,6 +10657,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; @@ -10701,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); @@ -10809,10 +10860,18 @@ export class WorkspaceService extends EventEmitter { ); if (!pricingGate.success) { if (internal?.synthetic !== true) { - 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, + goalId: internal?.goalId, cancelState: internal?.cancelState, cancelSignal: internal?.cancelSignal, onCanceled: internal?.onCanceled, @@ -10821,6 +10880,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, admissionStale: internal?.admissionStale, }); } @@ -10832,6 +10895,43 @@ 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, + }); + } + + // 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. @@ -10907,12 +11007,14 @@ 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, { synthetic: internal?.synthetic, agentInitiated: internal?.agentInitiated, + authoredAtMs, workspaceTurnContinuation: internal?.workspaceTurnContinuation, dedupeKey: internal?.queueDedupeKey, removableDedupeKey: internal?.removableQueueDedupeKey, @@ -11011,14 +11113,29 @@ export class WorkspaceService extends EventEmitter { claimedAutoTitle = true; } + // 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, + goalId: internal?.goalId, goalContinuation: internal?.goalContinuation, 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, @@ -11174,6 +11291,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)) { @@ -11250,9 +11368,18 @@ export class WorkspaceService extends EventEmitter { }); } + // 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, @@ -13394,7 +13521,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, }; @@ -13504,7 +13641,9 @@ export class WorkspaceService extends EventEmitter { message: string; startStreamInBackground?: boolean; 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"); @@ -13530,7 +13669,11 @@ export class WorkspaceService extends EventEmitter { : undefined, requireIdle: true, goalKind, + goalId: input.goalId, goalContinuation: true, + // Composed with the requireIdle preflight probe (see the requireIdle + // admission section in sendMessage). + admissionStale: input.admissionStale, } );