Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
e67e217
πŸ€– fix: stop heartbeats and background wakes from pausing goals
ThomasK33 Aug 25, 2026
02ab459
πŸ€– review: address Codex findings (projected createdAtMs, crash-safe r…
ThomasK33 Aug 25, 2026
4bd9a18
πŸ€– review: round 2 β€” persist authoring metadata on rejected sends, gat…
ThomasK33 Aug 25, 2026
e712711
πŸ€– review: round 3 β€” stamp queued goal creation at publication time
ThomasK33 Aug 25, 2026
930f188
πŸ€– review: round 4 β€” stamp goal creation after publication, clear kick…
ThomasK33 Aug 25, 2026
1340998
πŸ€– review: round 5 β€” install pending mutation before publication, capt…
ThomasK33 Aug 25, 2026
e81cc18
πŸ€– review: round 6 β€” locked handoff between stream-end drain and mid-p…
ThomasK33 Aug 25, 2026
a9b94d8
πŸ€– review: security round β€” preserve the newest authoring time in batc…
ThomasK33 Aug 25, 2026
04e46a9
πŸ€– review: round 7 β€” validate persisted authoring timestamps before go…
ThomasK33 Aug 25, 2026
43b64d2
πŸ€– review: round 8 β€” claim the pending mutation inside one lock tenure
ThomasK33 Aug 25, 2026
c5815f2
πŸ€– review: round 9 β€” loop the stream-end drain until no pending mutati…
ThomasK33 Aug 25, 2026
28cf28f
πŸ€– review: round 10 β€” atomic claim+persist tenure, until-empty drain, …
ThomasK33 Aug 25, 2026
169c910
πŸ€– review: round 11 β€” direct-creation publication stamp, unconditional…
ThomasK33 Aug 25, 2026
5be7ae2
πŸ€– review: round 12 β€” reject stop-spanning setters, settle workspaces …
ThomasK33 Aug 25, 2026
3b794dc
πŸ€– review: round 13 β€” clear settled marker on stream start, stop-gate …
ThomasK33 Aug 25, 2026
40c503b
πŸ€– review: round 14 β€” atomic publication stamp, durable user-origin wr…
ThomasK33 Aug 25, 2026
3aaeccc
πŸ€– review: round 15 β€” preflight busy predicate, stale-pause guard, gat…
ThomasK33 Aug 25, 2026
795fe87
πŸ€– review: round 16 β€” drain stream-start generation, goal-scoped pause…
ThomasK33 Aug 25, 2026
05babe1
πŸ€– review: round 16 addendum β€” verify goal state under lock before res…
ThomasK33 Aug 25, 2026
89d250f
ci: re-trigger PR checks (dropped webhook event for 05babe146)
ThomasK33 Aug 25, 2026
1404cf7
Merge remote-tracking branch 'origin/main' into goals-c35v
ThomasK33 Aug 25, 2026
6a50c17
πŸ€– review: round 18 β€” idle admission atomic with preflight, scan past …
ThomasK33 Aug 25, 2026
b36b942
πŸ€– review: round 19 β€” treat same-millisecond authoring as stale for th…
ThomasK33 Aug 25, 2026
6521c32
πŸ€– review: round 20 β€” goal-scope continuation rows so a replaced goal …
ThomasK33 Aug 25, 2026
b714c12
πŸ€– review: round 21 β€” carry goalId through compaction follow-ups/retri…
ThomasK33 Aug 25, 2026
5543b6e
πŸ€– review: round 22 β€” stream-scoped drain claims, live idle-admission …
ThomasK33 Aug 25, 2026
cc07fc0
πŸ€– review: round 23 β€” neutralize live wrap-up stamp on manual suppress…
ThomasK33 Aug 25, 2026
f9d1a02
πŸ€– review: round 24 β€” stream-scoped drain-generation staleness; durabl…
ThomasK33 Aug 25, 2026
765b817
πŸ€– review: round 25 β€” goal-scoped wrap-up suppression, post-write stop…
ThomasK33 Aug 25, 2026
7208ef9
πŸ€– review: round 26 β€” stream-end drain carries the user-stop gate thro…
ThomasK33 Aug 25, 2026
896c97c
πŸ€– review: round 26 β€” explicit-pause generation gates stale pausedβ†’act…
ThomasK33 Aug 25, 2026
491269e
πŸ€– review: round 26 β€” edit-in-place stop restore, live suppression bef…
ThomasK33 Aug 25, 2026
dd45528
πŸ€– review: round 27 β€” suppress budget wrap-up when auto-pause is rejec…
ThomasK33 Aug 25, 2026
c72292c
πŸ€– review: round 28 β€” validate persisted goal IDs before boundary/cont…
ThomasK33 Aug 25, 2026
4c664f4
πŸ€– review: round 29 β€” stop veto through publication awaits; skip prese…
ThomasK33 Aug 25, 2026
5b4c427
πŸ€– review: round 30 β€” pause-staleness probe on continuation dispatch, …
ThomasK33 Aug 25, 2026
44a8f88
πŸ€– review: round 31 β€” admission probes cover wrap-up suppression and s…
ThomasK33 Aug 25, 2026
e740f8a
πŸ€– review: round 32 β€” candidate restore honors budget-limited goals th…
ThomasK33 Aug 25, 2026
e9afaf2
πŸ€– review: round 33 β€” goal-identity generation in admission probes; co…
ThomasK33 Aug 25, 2026
2792743
πŸ€– review: round 34 β€” close redispatch admission races
ThomasK33 Aug 25, 2026
f308112
πŸ€– review: round 35 β€” validate persisted follow-up goal IDs
ThomasK33 Aug 25, 2026
9f4cad1
πŸ€– review: round 36 β€” live idle probe for redispatched follow-ups; val…
ThomasK33 Aug 25, 2026
c205e21
πŸ€– review: round 37 β€” preflight-visible idle probe; UUID goal-ID valid…
ThomasK33 Aug 25, 2026
af84428
πŸ€– review: round 38 β€” session-invisible preflight counter; heartbeat r…
ThomasK33 Aug 25, 2026
b37d4b6
πŸ€– review: round 39 β€” hold preflight reservation through rejected-send…
ThomasK33 Aug 25, 2026
29228e0
πŸ€– security: anchor queue-race pause bypass on explicit user activatio…
ThomasK33 Aug 25, 2026
e0626eb
πŸ€– review: round 40 β€” pause generation at write commit; reservation li…
ThomasK33 Aug 26, 2026
a9622bd
πŸ€– security: sample the user-stop generation in redispatch admission p…
ThomasK33 Aug 26, 2026
1894d80
πŸ€– review: round 42 β€” stop-ack latch; legacy follow-up discard; strict…
ThomasK33 Aug 26, 2026
9e59e31
πŸ€– review: round 42b β€” objective-revision generation; promotion stop r…
ThomasK33 Aug 26, 2026
f992e34
πŸ€– review: round 43 β€” terminal-exit generation bumps; consent arm inde…
ThomasK33 Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ async function main(): Promise<number> {
});

let goalStopReason: string | null = null;
let cliGoalId: string | undefined;
if (hasGoal) {
const setGoalResult = await workspaceGoalService.setGoal({
workspaceId,
Expand All @@ -843,6 +844,7 @@ async function main(): Promise<number> {
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."
Expand Down Expand Up @@ -1023,6 +1025,7 @@ async function main(): Promise<number> {
synthetic: true,
agentInitiated: true,
goalKind: GOAL_CONTINUATION_KIND,
goalId: cliGoalId,
goalContinuation: true,
}
: undefined
Expand Down
19 changes: 18 additions & 1 deletion src/common/orpc/schemas/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
19 changes: 19 additions & 0 deletions src/common/types/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof GoalStatusSchema>;
export type GoalRecordV1 = z.infer<typeof GoalRecordV1Schema>;
Expand Down
37 changes: 37 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/node/services/agentSession.autoCompaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand All @@ -315,16 +315,21 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
options: SendMessageOptions;
modelForStream: string;
goalKind?: typeof GOAL_CONTINUATION_KIND;
goalId?: string;
}) => CompactionFollowUpRequest;
}
).buildAutoCompactionFollowUp({
messageText: "Continue goal",
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();
});

Expand Down
40 changes: 40 additions & 0 deletions src/node/services/agentSession.budgetGate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/node/services/agentSession.continueMessageAgentId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
14 changes: 8 additions & 6 deletions src/node/services/agentSession.disposeRace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
);

Expand All @@ -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");
});
});
Loading
Loading