History

fix(orchestration): stabilize Codex turn mapping and settlement

- Preserve Codex turn identity while suppressing duplicate diff notifications - Optimize settlement projections and isolate thread visit handling - Add concurrency and regression coverage across server and mobile

Julius Marminge committed 2 months ago

Commit 6502e221 parent 25d2531167 files · +189949 −140Browse files

Showing the changes to apps/server/src (45 of 167 files). Show all

Unified · Split

45 files
  1. apps/server/src/git/GitManager.ts +1 −1
  2. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +4 −1
  3. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +1 −0
  4. apps/server/src/orchestration-v2/Orchestrator.ts +55 −31
  5. apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +363 −0
  6. apps/server/src/orchestration-v2/ProjectionStore.ts +192 −0
  7. apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +2 −0
  8. apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +6 −2
  9. apps/server/src/orchestration-v2/ThreadSettlementService.ts +6 −6
  10. apps/server/src/orchestration-v2/runtimeLayer.test.ts +32 −0
  11. apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson +1 −1
  12. apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson +1 −1
  13. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson +1 −1
  14. apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson +1 −1
  15. apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson +2 −2
  16. apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson +1 −1
  17. apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson +1 −1
  18. apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson +1 −1
  19. apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson +1 −1
  20. apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson +1 −1
  21. apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson +1 −1
  22. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson +1 −1
  23. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson +1 −1
  24. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson +1 −1
  25. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson +1 −1
  26. apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson +1 −1
  27. apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson +1 −1
  28. apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson +1 −1
  29. apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson +1 −1
  30. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson +1 −1
  31. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson +1 −1
  32. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson +1 −1
  33. apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson +1 −1
  34. apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson +1 −1
  35. apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson +1 −1
  36. apps/server/src/persistence/Migrations.ts +2 −0
  37. apps/server/src/persistence/Migrations/059_OrchestrationV2ShellIndexes.ts +24 −0
  38. apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +16 −0
  39. apps/server/src/provider/Layers/EventNdjsonLogger.ts +1 −0
  40. apps/server/src/relay/AgentAwarenessRelay.test.ts +573 −0
  41. apps/server/src/relay/AgentAwarenessRelay.ts +167 −26
  42. apps/server/src/server.ts +1 −1
  43. apps/server/src/vcs/GitVcsDriver.ts +9 −1
  44. apps/server/src/vcs/GitVcsDriverCore.test.ts +84 −7
  45. apps/server/src/vcs/GitVcsDriverCore.ts +21 −9
apps/server/src/git/GitManager.ts+1−1
@@ -923,7 +923,7 @@ export const make = Effect.gen(function* () { · 922 unmodified lines
923923 } satisfies GitVcsDriver.GitStatusDetails;
924924 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
925925 const details = yield* gitCore
926− .statusDetailsLocal(cwd)
926+ .statusDetailsLocal(cwd, { includeDivergence: false })
927927 .pipe(
928928 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
929929 );
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+4−1
@@ -1083,7 +1083,10 @@ function codexReplayPreamble(input: { · 1082 unmodified lines
10831083 method: "initialize",
10841084 params: {
10851085 clientInfo: { name: "t3code_desktop", title: "T3 Code Desktop", version: "0.1.0" },
1086− capabilities: { experimentalApi: true },
1086+ capabilities: {
1087+ experimentalApi: true,
1088+ optOutNotificationMethods: ["turn/diff/updated"],
1089+ },
10871090 },
10881091 },
10891092 },
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+1−0
@@ -173,6 +173,7 @@ const CODEX_CLIENT_INFO = { · 172 unmodified lines
173173 } as const;
174174 const CODEX_CLIENT_CAPABILITIES = {
175175 experimentalApi: true,
176+ optOutNotificationMethods: ["turn/diff/updated"],
176177 } as const;
177178
178179 export const CodexProviderCapabilitiesV2 = {
apps/server/src/orchestration-v2/Orchestrator.ts+55−31
@@ -1386,6 +1386,49 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 1385 unmodified lines
13861386 });
13871387 });
13881388
1389+ const dispatchThreadVisit = Effect.fn("orchestrationV2.dispatch.threadVisit")(function* (
1390+ command: Extract<OrchestrationV2Command, { readonly type: "thread.visit" }>,
1391+ events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
1392+ ) {
1393+ const thread = yield* projectionStore
1394+ .getThread(command.threadId)
1395+ .pipe(
1396+ Effect.mapError(
1397+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
1398+ ),
1399+ );
1400+ if (thread.deletedAt !== null) {
1401+ return yield* new OrchestratorDispatchError({
1402+ commandId: command.commandId,
1403+ commandType: command.type,
1404+ cause: `Thread ${command.threadId} is deleted.`,
1405+ });
1406+ }
1407+ const visitedAt = DateTime.make(command.visitedAt);
1408+ if (Option.isNone(visitedAt)) {
1409+ return yield* new OrchestratorDispatchError({
1410+ commandId: command.commandId,
1411+ commandType: command.type,
1412+ cause: `Thread ${command.threadId} visit time ${command.visitedAt} is not a valid timestamp.`,
1413+ });
1414+ }
1415+ const movesForward =
1416+ thread.lastVisitedAt === null ||
1417+ DateTime.toEpochMillis(visitedAt.value) > DateTime.toEpochMillis(thread.lastVisitedAt);
1418+ // Viewing a thread changes read state only. Loading its transcript (or
1419+ // bumping updatedAt) makes a routine read receipt scale with its history.
1420+ yield* emit(
1421+ events,
1422+ command,
1423+ )({
1424+ type: "thread.visited",
1425+ threadId: command.threadId,
1426+ providerInstanceId: thread.providerInstanceId,
1427+ occurredAt: yield* DateTime.now,
1428+ payload: movesForward ? { ...thread, lastVisitedAt: visitedAt.value } : thread,
1429+ });
1430+ });
1431+
13891432 const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* (
13901433 command: Extract<
13911434 OrchestrationV2Command,
@@ -1401,7 +1444,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 9 unmodified lines
14011444 | "thread.pin"
14021445 | "thread.unpin"
14031446 | "thread.pin.reorder"
1404− | "thread.visit"
14051447 | "thread.mark-unread"
14061448 | "thread.metadata.update"
14071449 | "thread.title.regeneration.complete"
@@ -1548,18 +1590,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 140 unmodified lines
15481590 }
15491591 snoozedUntil = parsedSnoozedUntil.value;
15501592 }
1551− let visitedAt: DateTime.Utc | null = null;
1552− if (command.type === "thread.visit") {
1553− const parsedVisitedAt = DateTime.make(command.visitedAt);
1554− if (Option.isNone(parsedVisitedAt)) {
1555− return yield* new OrchestratorDispatchError({
1556− commandId: command.commandId,
1557− commandType: command.type,
1558− cause: `Thread ${command.threadId} visit time ${command.visitedAt} is not a valid timestamp.`,
1559− });
1560− }
1561− visitedAt = parsedVisitedAt.value;
1562− }
15631593 let markUnreadVisitedAt: DateTime.Utc | null = null;
15641594 if (command.type === "thread.mark-unread") {
15651595 const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null;
@@ -1678,17 +1708,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 112 unmodified lines
16781708 updatedAt: keyUnchanged ? thread.updatedAt : now,
16791709 };
16801710 }
1681− // Visited tracking records read state only; it must not bump updatedAt,
1682− // otherwise viewing a thread would count as activity (and re-trigger the
1683− // clients that dispatch thread.visit whenever updatedAt advances).
1684− case "thread.visit": {
1685− const previousVisitedAt = thread.lastVisitedAt;
1686− const movesForward =
1687− visitedAt !== null &&
1688− (previousVisitedAt === null ||
1689− DateTime.toEpochMillis(visitedAt) > DateTime.toEpochMillis(previousVisitedAt));
1690− return movesForward ? { ...thread, lastVisitedAt: visitedAt } : thread;
1691− }
16921711 case "thread.mark-unread":
16931712 return { ...thread, lastVisitedAt: markUnreadVisitedAt };
16941713 case "thread.metadata.update":
@@ -1751,8 +1770,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 56 unmodified lines
17511770 return "thread.unpinned" as const;
17521771 case "thread.pin.reorder":
17531772 return "thread.pin-reordered" as const;
1754− case "thread.visit":
1755− return "thread.visited" as const;
17561773 case "thread.mark-unread":
17571774 return "thread.marked-unread" as const;
17581775 case "thread.metadata.update":
@@ -6921,16 +6938,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 5162 unmodified lines
69216938 case "thread.create":
69226939 yield* dispatchThreadCreate(command, events);
69236940 break;
6941+ case "thread.visit":
6942+ yield* dispatchThreadVisit(command, events);
6943+ break;
69246944 case "thread.auto-settle": {
69256945 // Automatic settlement (#8600): the sweep evaluated a shell snapshot,
69266946 // so re-check against the live thread before settling. Any change
69276947 // after the snapshot — or any explicit override, including the
69286948 // un-settle button's "active" — wins over the sweep.
6929− const projection = yield* loadProjectionForCommand(command);
6949+ const thread = yield* projectionStore
6950+ .getThread(command.threadId)
6951+ .pipe(
6952+ Effect.mapError(
6953+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
6954+ ),
6955+ );
69306956 if (
6931− projection.thread.settledOverride !== null ||
6932− DateTime.toEpochMillis(projection.thread.updatedAt) >
6933− DateTime.toEpochMillis(command.snapshotAt)
6957+ thread.settledOverride !== null ||
6958+ DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt)
69346959 ) {
69356960 return yield* new OrchestratorDispatchError({
69366961 commandId: command.commandId,
@@ -6960,7 +6985,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 23 unmodified lines
69606985 case "thread.pin":
69616986 case "thread.unpin":
69626987 case "thread.pin.reorder":
6963− case "thread.visit":
69646988 case "thread.mark-unread":
69656989 case "thread.metadata.update":
69666990 case "thread.title.regeneration.complete":
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+363−0
@@ -0,0 +1,363 @@
1+import { assert, it } from "@effect/vitest";
2+import {
3+ EventId,
4+ MessageId,
5+ NodeId,
6+ type OrchestrationV2AppThread,
7+ type OrchestrationV2Run,
8+ ProjectId,
9+ ProviderDriverKind,
10+ ProviderInstanceId,
11+ ProviderThreadId,
12+ RunId,
13+ RuntimeRequestId,
14+ ThreadId,
15+ TurnItemId,
16+} from "@t3tools/contracts";
17+import * as DateTime from "effect/DateTime";
18+import * as Effect from "effect/Effect";
19+import * as Layer from "effect/Layer";
20+import * as Schema from "effect/Schema";
21+import * as SqlClient from "effect/unstable/sql/SqlClient";
22+import * as Statement from "effect/unstable/sql/Statement";
23+
24+import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
25+import {
26+ ProjectionStoreV2,
27+ layer as projectionStoreLayer,
28+ layerMemory,
29+} from "./ProjectionStore.ts";
30+import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts";
31+
32+const SqlLayer = projectionStoreLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory));
33+const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z");
34+const old = DateTime.subtract(now, { days: 10 });
35+const providerInstanceId = ProviderInstanceId.make("codex");
36+const modelSelection = { instanceId: providerInstanceId, model: "test-model" };
37+const encodeNumbers = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Number)));
38+
39+const createThread = Effect.fn(function* (
40+ name: string,
41+ overrides: Partial<OrchestrationV2AppThread> = {},
42+) {
43+ const store = yield* ProjectionStoreV2;
44+ const threadId = ThreadId.make(`thread:settlement:${name}`);
45+ const thread: OrchestrationV2AppThread = {
46+ createdBy: "user",
47+ creationSource: "web",
48+ id: threadId,
49+ projectId: ProjectId.make("project:settlement"),
50+ title: name,
51+ providerInstanceId,
52+ modelSelection,
53+ runtimeMode: "full-access",
54+ interactionMode: "default",
55+ branch: "feature",
56+ worktreePath: null,
57+ activeProviderThreadId: null,
58+ lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
59+ forkedFrom: null,
60+ createdAt: old,
61+ updatedAt: old,
62+ archivedAt: null,
63+ settledOverride: null,
64+ settledAt: null,
65+ lastVisitedAt: null,
66+ deletedAt: null,
67+ ...overrides,
68+ };
69+ yield* store.apply({
70+ id: EventId.make(`event:${threadId}`),
71+ type: "thread.created",
72+ threadId,
73+ occurredAt: old,
74+ payload: thread,
75+ });
76+ return threadId;
77+});
78+
79+const createRun = Effect.fn(function* (
80+ threadId: ThreadId,
81+ status: OrchestrationV2Run["status"] = "completed",
82+ ordinal = 1,
83+) {
84+ const store = yield* ProjectionStoreV2;
85+ const runId = RunId.make(`run:${threadId}:${ordinal}`);
86+ yield* store.apply({
87+ id: EventId.make(`event:${runId}`),
88+ type: "run.created",
89+ threadId,
90+ occurredAt: old,
91+ payload: {
92+ id: runId,
93+ threadId,
94+ ordinal,
95+ providerInstanceId,
96+ modelSelection,
97+ providerThreadId: null,
98+ userMessageId: MessageId.make(`message:${runId}`),
99+ rootNodeId: null,
100+ activeAttemptId: null,
101+ status,
102+ requestedAt: old,
103+ startedAt: status === "queued" ? null : old,
104+ completedAt: ["completed", "rolled_back", "failed"].includes(status) ? old : null,
105+ checkpointId: null,
106+ contextHandoffId: null,
107+ },
108+ });
109+ return runId;
110+});
111+
112+const createItem = Effect.fn(function* (
113+ threadId: ThreadId,
114+ runId: RunId,
115+ status: "idle" | "running" | "completed",
116+ persistent = false,
117+) {
118+ const store = yield* ProjectionStoreV2;
119+ yield* store.apply({
120+ id: EventId.make(`event:item:${runId}`),
121+ type: "turn-item.updated",
122+ threadId,
123+ occurredAt: old,
124+ payload: {
125+ id: TurnItemId.make(`item:${runId}`),
126+ threadId,
127+ runId,
128+ nodeId: null,
129+ providerThreadId: null,
130+ providerTurnId: null,
131+ nativeItemRef: null,
132+ parentItemId: null,
133+ ordinal: 1,
134+ type: "dynamic_tool",
135+ status,
136+ title: null,
137+ startedAt: old,
138+ completedAt: status === "completed" ? old : null,
139+ updatedAt: old,
140+ toolName: "background",
141+ input: persistent ? { persistent: true } : null,
142+ output: null,
143+ },
144+ });
145+});
146+
147+for (const [name, testLayer] of [
148+ ["sql", SqlLayer],
149+ ["memory", layerMemory],
150+] as const) {
151+ it.effect(
152+ `${name}: discovers settlement work with the same activity and background semantics as the shell`,
153+ () =>
154+ Effect.gen(function* () {
155+ const store = yield* ProjectionStoreV2;
156+ const idle = yield* createThread("idle");
157+ const completed = yield* createThread("completed");
158+ yield* createRun(completed);
159+ for (const [name, overrides] of [
160+ ["archived", { archivedAt: old }],
161+ ["deleted", { deletedAt: old }],
162+ ["settled", { settledOverride: "settled" }],
163+ ["unsettled", { settledOverride: "active" }],
164+ ["pinned", { pinnedAt: old }],
165+ ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) {
166+ yield* createRun(yield* createThread(name, overrides));
167+ }
168+ for (const status of ["preparing", "starting", "running", "waiting"] as const) {
169+ yield* createRun(yield* createThread(status), status);
170+ }
171+ const queued = yield* createThread("queued");
172+ yield* createRun(queued, "queued");
173+ const blocked = yield* createThread("blocked");
174+ yield* store.apply({
175+ id: EventId.make("event:settlement:request"),
176+ type: "runtime-request.updated",
177+ threadId: blocked,
178+ occurredAt: old,
179+ payload: {
180+ id: RuntimeRequestId.make("request:settlement"),
181+ nodeId: NodeId.make("node:settlement"),
182+ providerTurnId: null,
183+ nativeRequestRef: null,
184+ kind: "user_input",
185+ status: "pending",
186+ responseCapability: { type: "not_resumable", reason: "Process stopped" },
187+ createdAt: old,
188+ resolvedAt: null,
189+ },
190+ });
191+ const recent = yield* createThread("recent-message");
192+ yield* createRun(recent);
193+ yield* store.apply({
194+ id: EventId.make("event:settlement:message"),
195+ type: "message.updated",
196+ threadId: recent,
197+ occurredAt: now,
198+ payload: {
199+ createdBy: "user",
200+ creationSource: "web",
201+ id: MessageId.make("message:settlement:recent"),
202+ threadId: recent,
203+ runId: null,
204+ nodeId: null,
205+ role: "user",
206+ text: "Next turn",
207+ attachments: [],
208+ streaming: false,
209+ createdAt: now,
210+ updatedAt: now,
211+ },
212+ });
213+ yield* createRun(
214+ yield* createThread("snoozed", {
215+ snoozedUntil: DateTime.add(now, { days: 1 }),
216+ snoozedAt: now,
217+ }),
218+ );
219+ const woke = yield* createThread("woke", {
220+ snoozedUntil: DateTime.add(now, { days: 1 }),
221+ snoozedAt: DateTime.subtract(old, { days: 1 }),
222+ });
223+ yield* createRun(woke);
224+ const background = yield* createThread("idle-background");
225+ yield* createItem(background, yield* createRun(background), "idle");
226+ const persistent = yield* createThread("persistent-monitor");
227+ yield* createItem(persistent, yield* createRun(persistent), "running", true);
228+ const rolledBack = yield* createThread("rolled-back-background");
229+ yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running");
230+ yield* createRun(rolledBack, "completed", 2);
231+ const roster = yield* createThread("provider-roster");
232+ yield* createRun(roster);
233+ yield* store.apply({
234+ id: EventId.make("event:settlement:roster"),
235+ type: "provider-thread.updated",
236+ threadId: roster,
237+ occurredAt: old,
238+ payload: {
239+ id: ProviderThreadId.make("provider-thread:settlement"),
240+ appThreadId: roster,
241+ ownerNodeId: null,
242+ driver: ProviderDriverKind.make("codex"),
243+ providerInstanceId,
244+ providerSessionId: null,
245+ nativeThreadRef: null,
246+ nativeConversationHeadRef: null,
247+ status: "idle",
248+ firstRunOrdinal: null,
249+ lastRunOrdinal: null,
250+ handoffIds: [],
251+ forkedFrom: null,
252+ createdAt: old,
253+ updatedAt: old,
254+ pendingBackgroundTasks: [{ taskId: "running-task" }],
255+ },
256+ });
257+ const candidates = yield* store.getSettlementCandidates();
258+ const shell = yield* store.getShellSnapshot({ location: "active" });
259+ const eligible = candidates.filter((thread) =>
260+ isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)),
261+ );
262+ assert.deepEqual(
263+ new Set(eligible.map((thread) => thread.id)),
264+ new Set([idle, completed, queued, woke, persistent, rolledBack]),
265+ );
266+ assert.deepEqual(
267+ new Set(eligible.map((thread) => thread.id)),
268+ new Set(
269+ shell.threads
270+ .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)))
271+ .map((thread) => thread.id),
272+ ),
273+ );
274+ for (const candidate of candidates) {
275+ const expected = shell.threads.find((thread) => thread.id === candidate.id)!;
276+ assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks);
277+ const settings = {
278+ pullRequest: null,
279+ nowMs: DateTime.toEpochMillis(now),
280+ autoSettleAfterDays: 7,
281+ autoSettleOnMerge: false,
282+ };
283+ assert.deepEqual(
284+ resolveAutoSettlementAt({ ...settings, thread: candidate }),
285+ resolveAutoSettlementAt({ ...settings, thread: expected }),
286+ );
287+ }
288+ assert.equal(
289+ DateTime.formatIso((yield* store.getThread(completed)).createdAt),
290+ DateTime.formatIso(old),
291+ );
292+ assert.equal(
293+ (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag,
294+ "ProjectionStoreThreadNotFoundError",
295+ );
296+ }).pipe(Effect.provide(testLayer)),
297+ );
298+}
299+
300+it.effect(
301+ "reads settlement candidates and thread metadata without loading historical or archived payloads",
302+ () =>
303+ Effect.gen(function* () {
304+ const store = yield* ProjectionStoreV2;
305+ const sql = yield* SqlClient.SqlClient;
306+ const source = yield* createThread("historical-source", { archivedAt: old });
307+ const sourceRun = yield* createRun(source);
308+ const threadId = yield* createThread("fork", {
309+ forkedFrom: { type: "run", threadId: source, runId: sourceRun },
310+ });
311+ const runId = yield* createRun(threadId);
312+ yield* createItem(threadId, runId, "completed");
313+ yield* sql`UPDATE orchestration_v2_projection_turn_items SET payload_json = '{broken' WHERE thread_id = ${threadId}`;
314+ yield* sql`UPDATE orchestration_v2_projection_runs SET payload_json = '{broken' WHERE thread_id = ${source}`;
315+ yield* sql`INSERT INTO orchestration_v2_projection_turn_items
316+ SELECT 'history:' || value, i.thread_id, i.run_id, i.node_id, i.provider_thread_id, i.provider_turn_id,
317+ i.parent_item_id, i.ordinal + value, i.type, i.status, i.updated_at, i.payload_json
318+ FROM orchestration_v2_projection_turn_items i, json_each(${encodeNumbers(Array.from({ length: 2000 }, (_, i) => i + 1))})
319+ WHERE i.thread_id = ${threadId}`;
320+ const queries: Array<readonly [string, ReadonlyArray<unknown>]> = [];
321+ const record: Statement.Transformer = (statement) =>
322+ Effect.sync(() => {
323+ queries.push(statement.compile());
324+ return statement;
325+ });
326+ const candidates = yield* store
327+ .getSettlementCandidates()
328+ .pipe(Effect.provideService(Statement.CurrentTransformer, record));
329+ assert.deepEqual(
330+ candidates.map((thread) => thread.id),
331+ [threadId],
332+ );
333+ assert.isTrue(queries.every(([query]) => !query.includes("COUNT(")));
334+ const pendingQuery = queries.find(([query]) =>
335+ query.includes("FROM orchestration_v2_projection_turn_items"),
336+ );
337+ assert.isDefined(pendingQuery);
338+ const metadata = yield* store.getThread(threadId);
339+ assert.equal(metadata.id, threadId);
340+ assert.equal((yield* store.getThreadProjection(threadId).pipe(Effect.exit))._tag, "Failure");
341+ const plan = yield* sql.unsafe<{ readonly detail: string }>(
342+ `EXPLAIN QUERY PLAN ${pendingQuery![0]}`,
343+ pendingQuery![1],
344+ );
345+ assert.isTrue(plan.some((row) => row.detail.includes("turn_items_shell_pending_idx")));
346+ const countsPlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN
347+ SELECT thread_id, run_id, COUNT(*) FROM orchestration_v2_projection_turn_items
348+ WHERE thread_id = ${threadId} AND run_id IS NOT NULL GROUP BY thread_id, run_id`;
349+ assert.isTrue(
350+ countsPlan.some((row) =>
351+ row.detail.includes(
352+ "COVERING INDEX orchestration_v2_projection_turn_items_thread_run_idx",
353+ ),
354+ ),
355+ );
356+ assert.isFalse(countsPlan.some((row) => row.detail.includes("TEMP B-TREE")));
357+ const messagePlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN
358+ SELECT updated_at FROM orchestration_v2_projection_messages
359+ WHERE thread_id = ${threadId} AND role = 'user' ORDER BY updated_at DESC, message_id DESC LIMIT 1`;
360+ assert.isTrue(messagePlan.some((row) => row.detail.includes("messages_latest_user_idx")));
361+ assert.isFalse(messagePlan.some((row) => row.detail.includes("TEMP B-TREE")));
362+ }).pipe(Effect.provide(SqlLayer)),
363+);
apps/server/src/orchestration-v2/ProjectionStore.ts+192−0
@@ -1,4 +1,5 @@
11 import type {
2+ OrchestrationV2AppThread,
23 OrchestrationV2ConversationMessage,
34 OrchestrationV2DomainEvent,
45 OrchestrationV2ProjectedTurnItem,
@@ -106,6 +107,32 @@ export type ProjectionRecoveryKind = · 101 unmodified lines
106107 | "subagent-results"
107108 | "delegated-completions";
108109
110+/** Thread activity needed by settlement, without transcript or fork history. */
111+export type ProjectionSettlementCandidate = Pick<
112+ OrchestrationV2ThreadShell,
113+ | "id"
114+ | "projectId"
115+ | "branch"
116+ | "worktreePath"
117+ | "linkedPullRequest"
118+ | "createdAt"
119+ | "updatedAt"
120+ | "archivedAt"
121+ | "settledOverride"
122+ | "pinnedAt"
123+ | "snoozedUntil"
124+ | "snoozedAt"
125+ | "latestRunId"
126+ | "latestRunRequestedAt"
127+ | "latestRunStartedAt"
128+ | "latestRunCompletedAt"
129+ | "latestUserMessageAt"
130+ | "status"
131+ | "activityRunStatus"
132+ | "pendingRuntimeRequest"
133+ | "pendingBackgroundTasks"
134+>;
135+
109136 const ProjectionCheckpointContext = Schema.Struct({
110137 runs: Schema.Array(
111138 OrchestrationV2RunJsonSchema.mapFields(({ id, ordinal, status }) => ({ id, ordinal, status })),
@@ -143,6 +170,13 @@ export interface ProjectionStoreV2Shape { · 31 unmodified lines
143170 readonly getThreadShell: (
144171 threadId: ThreadId,
145172 ) => Effect.Effect<OrchestrationV2ThreadShell | null, ProjectionStoreV2Error>;
173+ readonly getThread: (
174+ threadId: ThreadId,
175+ ) => Effect.Effect<OrchestrationV2AppThread, ProjectionStoreV2Error>;
176+ readonly getSettlementCandidates: () => Effect.Effect<
177+ ReadonlyArray<ProjectionSettlementCandidate>,
178+ ProjectionStoreV2Error
179+ >;
146180 readonly getThreadProjection: (
147181 threadId: ThreadId,
148182 ) => Effect.Effect<OrchestrationV2ThreadProjection, ProjectionStoreV2Error>;
@@ -585,6 +619,18 @@ type ShellRunRow = { · 436 unmodified lines
585619 readonly ordinal: number;
586620 };
587621
622+type SettlementThreadRow = Pick<
623+ ShellThreadRow,
624+ | "thread_id"
625+ | "payload_json"
626+ | "latest_run_id"
627+ | "latest_run_status"
628+ | "latest_run_requested_at"
629+ | "latest_run_started_at"
630+ | "latest_run_completed_at"
631+ | "latest_user_message_at"
632+>;
633+
588634 type ShellRunItemCountRow = {
589635 readonly thread_id: string;
590636 readonly run_id: string;
@@ -2954,6 +3000,24 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 2363 unmodified lines
29543000 const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) =>
29553001 readProjection(threadId, new Set());
29563002
3003+ const getThread: ProjectionStoreV2Shape["getThread"] = (threadId) =>
3004+ Effect.gen(function* () {
3005+ const rows = yield* sql<PayloadRow>`
3006+ SELECT payload_json FROM orchestration_v2_projection_threads
3007+ WHERE thread_id = ${threadId}
3008+ `;
3009+ if (rows[0] === undefined) {
3010+ return yield* new ProjectionStoreThreadNotFoundError({ threadId });
3011+ }
3012+ return yield* decodeThreadPayload(rows[0].payload_json);
3013+ }).pipe(
3014+ Effect.mapError((cause) =>
3015+ isProjectionStoreThreadNotFoundError(cause)
3016+ ? cause
3017+ : new ProjectionStoreReadError({ threadId, cause }),
3018+ ),
3019+ );
3020+
29573021 const getCheckpointContext: ProjectionStoreV2Shape["getCheckpointContext"] = (threadId) =>
29583022 sql
29593023 .withTransaction(
@@ -3320,6 +3384,104 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 360 unmodified lines
33203384 return { providerThreadsByThreadId, pendingTurnItemsByThreadId };
33213385 });
33223386
3387+ const getSettlementCandidates: ProjectionStoreV2Shape["getSettlementCandidates"] = () =>
3388+ sql
3389+ .withTransaction(
3390+ Effect.gen(function* () {
3391+ // Settlement needs activity timestamps, not item counts, run history,
3392+ // provider sessions or inherited fork rows. Reject parked and busy
3393+ // threads before loading the remaining candidates' background work.
3394+ const rows = yield* sql<SettlementThreadRow>`
3395+ SELECT t.thread_id, t.payload_json,
3396+ r.run_id AS latest_run_id,
3397+ r.status AS latest_run_status,
3398+ r.requested_at AS latest_run_requested_at,
3399+ json_extract(r.payload_json, '$.startedAt') AS latest_run_started_at,
3400+ r.completed_at AS latest_run_completed_at,
3401+ (
3402+ SELECT message.updated_at
3403+ FROM orchestration_v2_projection_messages message
3404+ WHERE message.thread_id = t.thread_id AND message.role = 'user'
3405+ ORDER BY message.updated_at DESC, message.message_id DESC
3406+ LIMIT 1
3407+ ) AS latest_user_message_at
3408+ FROM orchestration_v2_projection_threads t
3409+ LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = (
3410+ SELECT latest.run_id FROM orchestration_v2_projection_runs latest
3411+ WHERE latest.thread_id = t.thread_id
3412+ ORDER BY latest.ordinal DESC, latest.run_id DESC
3413+ LIMIT 1
3414+ )
3415+ WHERE t.deleted_at IS NULL
3416+ AND json_extract(t.payload_json, '$.archivedAt') IS NULL
3417+ AND json_extract(t.payload_json, '$.settledOverride') IS NULL
3418+ AND json_extract(t.payload_json, '$.pinnedAt') IS NULL
3419+ AND NOT EXISTS (
3420+ SELECT 1 FROM orchestration_v2_projection_runs active
3421+ WHERE active.thread_id = t.thread_id
3422+ AND active.status IN ('preparing', 'starting', 'running', 'waiting')
3423+ )
3424+ AND NOT EXISTS (
3425+ SELECT 1 FROM orchestration_v2_projection_runtime_requests request
3426+ WHERE request.thread_id = t.thread_id AND request.status = 'pending'
3427+ )
3428+ ORDER BY t.updated_at ASC, t.thread_id ASC
3429+ `;
3430+ if (rows.length === 0) return [];
3431+ const threadIds = rows.map((row) => ThreadId.make(row.thread_id));
3432+ const { providerThreadsByThreadId, pendingTurnItemsByThreadId } =
3433+ yield* pendingBackgroundDataByThreadId({
3434+ providerThreadRows: yield* selectShellProviderThreadRows(threadIds),
3435+ pendingTurnItemRows: yield* selectShellPendingTurnItemRows(threadIds),
3436+ });
3437+ return yield* Effect.forEach(rows, (row) =>
3438+ Effect.gen(function* () {
3439+ const thread = yield* decodeThreadPayload(row.payload_json);
3440+ const status = shellStatusFromStoredRunStatus(row.latest_run_status);
3441+ const latestRunId =
3442+ row.latest_run_id === null ? null : RunId.make(row.latest_run_id);
3443+ return {
3444+ ...thread,
3445+ pinnedAt: thread.pinnedAt ?? null,
3446+ snoozedUntil: thread.snoozedUntil ?? null,
3447+ snoozedAt: thread.snoozedAt ?? null,
3448+ status,
3449+ latestRunId,
3450+ latestRunRequestedAt:
3451+ row.latest_run_requested_at === null
3452+ ? null
3453+ : DateTime.makeUnsafe(row.latest_run_requested_at),
3454+ latestRunStartedAt:
3455+ row.latest_run_started_at === null
3456+ ? null
3457+ : DateTime.makeUnsafe(row.latest_run_started_at),
3458+ latestRunCompletedAt:
3459+ row.latest_run_completed_at === null
3460+ ? null
3461+ : DateTime.makeUnsafe(row.latest_run_completed_at),
3462+ latestUserMessageAt:
3463+ row.latest_user_message_at === null
3464+ ? null
3465+ : DateTime.makeUnsafe(row.latest_user_message_at),
3466+ activityRunStatus: null,
3467+ pendingRuntimeRequest: null,
3468+ pendingBackgroundTasks: derivePendingBackgroundWork({
3469+ latestRun:
3470+ latestRunId === null || status === "idle"
3471+ ? null
3472+ : { id: latestRunId, ordinal: 0, status },
3473+ providerThreads: providerThreadsByThreadId.get(thread.id) ?? [],
3474+ turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [],
3475+ activeProviderThreadId: thread.activeProviderThreadId,
3476+ hasActiveRun: false,
3477+ }),
3478+ } satisfies ProjectionSettlementCandidate;
3479+ }),
3480+ );
3481+ }),
3482+ )
3483+ .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })));
3484+
33233485 const shellThreadStateFromRow = (input: {
33243486 readonly row: ShellThreadRow;
33253487 readonly runOrdinalsByThreadId: ReadonlyMap<ThreadId, Map<RunId, number>>;
@@ -3566,6 +3728,8 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 240 unmodified lines
35663728 apply,
35673729 getShellSnapshot,
35683730 getThreadShell,
3731+ getThread,
3732+ getSettlementCandidates,
35693733 getThreadProjection,
35703734 getCheckpointContext,
35713735 getRecoveryThreadIds,
@@ -3638,6 +3802,34 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( · 66 unmodified lines
36383802 .pipe(Effect.map(threadShellFromProjection));
36393803 return shell.deletedAt === null ? shell : null;
36403804 }),
3805+ getThread: (threadId) =>
3806+ Effect.gen(function* () {
3807+ const projection = (yield* Ref.get(replayState)).projections.get(threadId);
3808+ if (projection === undefined) {
3809+ return yield* new ProjectionStoreThreadNotFoundError({ threadId });
3810+ }
3811+ return projection.thread;
3812+ }),
3813+ getSettlementCandidates: () =>
3814+ Effect.gen(function* () {
3815+ const projections = (yield* Ref.get(replayState)).projections;
3816+ return [...projections.values()]
3817+ .filter(
3818+ ({ thread, runs, runtimeRequests }) =>
3819+ thread.deletedAt === null &&
3820+ thread.archivedAt === null &&
3821+ thread.settledOverride === null &&
3822+ thread.pinnedAt == null &&
3823+ !runs.some(isActivityRunForShell) &&
3824+ !runtimeRequests.some((request) => request.status === "pending"),
3825+ )
3826+ .map(threadShellFromProjection)
3827+ .toSorted(
3828+ (left, right) =>
3829+ DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) ||
3830+ left.id.localeCompare(right.id),
3831+ );
3832+ }),
36413833 getRecoveryThreadIds: (kind) =>
36423834 Effect.gen(function* () {
36433835 const projections = (yield* Ref.get(replayState)).projections;
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+2−0
@@ -216,6 +216,8 @@ it.effect( · 215 unmodified lines
216216 apply: () => Effect.void,
217217 getShellSnapshot: () => Effect.die("unused getShellSnapshot"),
218218 getThreadShell: () => Effect.die("unused getThreadShell"),
219+ getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)),
220+ getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"),
219221 getThreadProjection: () => Ref.get(projection),
220222 getCheckpointContext: () => Effect.die("not used"),
221223 getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"),
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+6−2
@@ -34,6 +34,7 @@ import { ServerActivation } from "../serverActivation.ts"; · 33 unmodified lines
3434 import { ServerSettingsService } from "../serverSettings.ts";
3535 import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
3636 import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts";
37+import { ProjectionStoreV2 } from "./ProjectionStore.ts";
3738 import * as ThreadSettlementService from "./ThreadSettlementService.ts";
3839
3940 import {
@@ -417,12 +418,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 377 unmodified lines
417418 getProjectShellsWithoutEnrichment: () =>
418419 Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),
419420 }),
420− Layer.mock(OrchestratorV2)({
421− getShellSnapshot: () =>
421+ Layer.mock(ProjectionStoreV2)({
422+ getSettlementCandidates: () =>
422423 Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
423424 Effect.tap((count) => Queue.offer(snapshotReads, count)),
424425 Effect.andThen(Ref.get(snapshots)),
426+ Effect.map((snapshot) => snapshot.threads),
425427 ),
428+ }),
429+ Layer.mock(OrchestratorV2)({
426430 dispatch,
427431 }),
428432 Layer.mock(GitManager)({
apps/server/src/orchestration-v2/ThreadSettlementService.ts+6−6
@@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; · 16 unmodified lines
1717 import { forkParked } from "../serverActivation.ts";
1818 import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
1919 import { OrchestratorV2 } from "./Orchestrator.ts";
20+import { ProjectionStoreV2, type ProjectionSettlementCandidate } from "./ProjectionStore.ts";
2021
2122 export interface SettlementPullRequest {
2223 readonly state: "open" | "closed" | "merged";
@@ -94,7 +95,7 @@ function pullRequestSettles( · 71 unmodified lines
9495
9596 /** Cheap checks that run before any source control lookup. */
9697 export function isAutoSettlementCandidate(
97− thread: OrchestrationV2ThreadShell,
98+ thread: ProjectionSettlementCandidate,
9899 nowMs: number,
99100 ): boolean {
100101 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
@@ -118,7 +119,7 @@ export function isAutoSettlementCandidate( · 17 unmodified lines
118119 }
119120
120121 export function resolveAutoSettlementAt(input: {
121− readonly thread: OrchestrationV2ThreadShell;
122+ readonly thread: ProjectionSettlementCandidate;
122123 readonly pullRequest: SettlementPullRequest | null;
123124 readonly nowMs: number;
124125 readonly autoSettleAfterDays: number | null;
@@ -151,6 +152,7 @@ export class ThreadSettlementServiceV2 extends Context.Service< · 26 unmodified lines
151152
152153 export const make = Effect.gen(function* () {
153154 const orchestrator = yield* OrchestratorV2;
155+ const projections = yield* ProjectionStoreV2;
154156 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
155157 const settingsService = yield* ServerSettings.ServerSettingsService;
156158 const git = yield* GitManager.GitManager;
@@ -161,7 +163,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
161163 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
162164 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
163165 ) {
164− const snapshot = yield* orchestrator.getShellSnapshot();
166+ const threads = yield* projections.getSettlementCandidates();
165167 const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment();
166168 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
167169 const projects = new Map(projectShells.map((project) => [project.id, project]));
@@ -169,9 +171,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
169171 // the merged pull request: most threads carry no link and settle from
170172 // their branch lookup, which would otherwise wait for the next minute's
171173 // sweep on a possibly stale cached answer.
172− const candidates = snapshot.threads.filter((thread) =>
173− isAutoSettlementCandidate(thread, nowMs),
174− );
174+ const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs));
175175 // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
176176 const lookupCwdByThreadId = new Map<string, string>();
177177 yield* Effect.forEach(
apps/server/src/orchestration-v2/runtimeLayer.test.ts+32−0
@@ -1634,6 +1634,38 @@ it.layer(SharedApplicationDataPlaneTestLayer)("visited projection", (it) => { · 1633 unmodified lines
16341634 })
16351635 .pipe(Effect.flip);
16361636 assert.instanceOf(markUnread, OrchestratorDispatchError);
1637+
1638+ // A read receipt must not decode any transcript, including inherited or
1639+ // unreadable historical rows. It only advances the thread's watermark.
1640+ const sql = yield* SqlClient.SqlClient;
1641+ yield* sql`INSERT INTO orchestration_v2_projection_turn_items (
1642+ turn_item_id, thread_id, ordinal, type, status, updated_at, payload_json
1643+ ) VALUES (
1644+ 'item:visited:unreadable-history', ${threadId}, 1, 'dynamic_tool', 'completed',
1645+ ${visitedAt}, '{broken'
1646+ )`;
1647+ const nextVisitedAt = "2026-07-24T02:00:00.000Z";
1648+ yield* orchestrator.dispatch({
1649+ type: "thread.visit",
1650+ commandId: CommandId.make("runtime-layer-visited-without-history"),
1651+ threadId,
1652+ visitedAt: nextVisitedAt,
1653+ });
1654+ const [watermark] = yield* sql<{ readonly visited_at: string; readonly updated_at: string }>`
1655+ SELECT json_extract(payload_json, '$.lastVisitedAt') AS visited_at, updated_at
1656+ FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}
1657+ `;
1658+ assert.equal(watermark!.visited_at, nextVisitedAt);
1659+ assert.equal(watermark!.updated_at, DateTime.formatIso(createdUpdatedAt));
1660+ const invalidVisit = yield* orchestrator
1661+ .dispatch({
1662+ type: "thread.visit",
1663+ commandId: CommandId.make("runtime-layer-visited-invalid-timestamp"),
1664+ threadId,
1665+ visitedAt: "invalid-timestamp",
1666+ })
1667+ .pipe(Effect.flip);
1668+ assert.instanceOf(invalidVisit, OrchestratorDispatchError);
16371669 }),
16381670 );
16391671 });
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.128.0","scenario":"message_steering","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"message_steering.ndjson","description":"One active turn receives an immediate turn/steer request."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.128.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex_p","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"multi_turn","metadata":{"source":"codex-app-server-probe","fileName":"multi_turn.ndjson","description":"One thread with two sequential user turns."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"plan_questions","metadata":{"source":"codex-app-server-probe","fileName":"plan_questions.ndjson","description":"One plan-mode turn that asks a structured clarifying question."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"proposed_plan","metadata":{"source":"codex-app-server-probe","fileName":"proposed_plan.ndjson","description":"One plan-mode turn that emits a proposed plan document."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson+2−2
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"provider_thread_resume","metadata":{"source":"codex-app-server-probe","fileName":"provider_thread_resume.ndjson","description":"One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
@@ -33,7 +33,7 @@ · 27 unmodified lines
3333 {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","status":{"type":"idle"}}}}
3434 {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","turn":{"id":"019db117-b519-7fa3-85c8-7762b26478a7","items":[],"status":"completed","error":null,"startedAt":1776792614,"completedAt":1776792622,"durationMs":8794}}}}
3535 {"type":"runtime_exit","status":"success"}
36−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
36+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
3737 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
3838 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
3939 {"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","excludeTurns":true}}}
apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"queued_turn","metadata":{"source":"codex-app-server-probe","fileName":"queued_turn.ndjson","description":"One thread with a queued second user turn."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"simple","metadata":{"source":"codex-app-server-probe","fileName":"simple.ndjson","description":"One thread and one turn with a deterministic text-only response."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"subagent","metadata":{"source":"codex-app-server-probe","fileName":"subagent.ndjson","description":"One root turn that asks Codex to spawn two collab agents."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"subagent_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_continue.ndjson","description":"A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.141.0","scenario":"subagent_v2","metadata":{"source":"observed-codex-app-server-replay-fixture","fileName":"subagent_v2.ndjson","description":"Agents-v2 Codex subagent spawn observed from a real run: parent emits subAgentActivity(started), child emits a normal turn and final agent message."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.141.0","scenario":"subagent_v2_nested","metadata":{"source":"observed-codex-app-server-replay-fixture","description":"Agents-v2 Codex nested subagent spawn: root emits a subAgentActivity, that child emits another, and that grandchild emits a final nested child."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn."}}
2−{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_fork_native_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_continue.ndjson","description":"One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, native thread/fork, rollback of the fork to the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle."}}
2−{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_fork_native_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_siblings.ndjson","description":"One source marker turn and two independent native forks that each recall the source plus only their own fork marker."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.137.0","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0 (Mac OS 26.5.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_rollback","metadata":{"source":"codex-app-server-probe","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"todo_list","metadata":{"source":"codex-app-server-probe","fileName":"todo_list.ndjson","description":"One turn that asks Codex to emit progress through update_plan."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_read_only_on_request","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_read_only_on_request.ndjson","description":"Write a small probe file with read-only full filesystem visibility and on-request approvals."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_restricted_granular","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_restricted_granular.ndjson","description":"Write a small probe file with restricted read access and granular approval flags enabled."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"tool_call_workspace_never","metadata":{"source":"codex-app-server-probe","fileName":"tool_call_workspace_never.ndjson","description":"Write a small probe file with workspace-write sandbox policy and never approvals."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"turn_interrupt","metadata":{"source":"codex-app-server-probe","fileName":"turn_interrupt.ndjson","description":"One active turn is interrupted before it finishes naturally."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.128.0","scenario":"turn_interrupt_mid_tool","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt_mid_tool.ndjson","description":"One active turn is interrupted after Codex has already executed a local command."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.128.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex_p","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
11 {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"web_search","metadata":{"source":"codex-app-server-probe","fileName":"web_search.ndjson","description":"One turn with a native Codex webSearch item that should become normalized progress."}}
2−{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
2+{"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"t3code_desktop","title":"T3 Code Desktop","version":"0.1.0"}}}}
33 {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/Users/julius/.codex","platformFamily":"unix","platformOs":"macos"}}}
44 {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
55 {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
apps/server/src/persistence/Migrations.ts+2−0
@@ -70,6 +70,7 @@ import Migration0055 from "./Migrations/055_ScheduledTasks.ts"; · 69 unmodified lines
7070 import Migration0056 from "./Migrations/056_LegacyV1ImportState.ts";
7171 import Migration0057 from "./Migrations/057_ApplicationEventSequenceIndexes.ts";
7272 import Migration0058 from "./Migrations/058_OrchestrationV2RecoveryIndexes.ts";
73+import Migration0059 from "./Migrations/059_OrchestrationV2ShellIndexes.ts";
7374
7475 /**
7576 * Migration loader with all migrations defined inline.
@@ -140,6 +141,7 @@ export const migrationEntries = [ · 64 unmodified lines
140141 [56, "LegacyV1ImportState", Migration0056],
141142 [57, "ApplicationEventSequenceIndexes", Migration0057],
142143 [58, "OrchestrationV2RecoveryIndexes", Migration0058],
144+ [59, "OrchestrationV2ShellIndexes", Migration0059],
143145 ] as const;
144146
145147 export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/059_OrchestrationV2ShellIndexes.ts+24−0
@@ -0,0 +1,24 @@
1+import * as Effect from "effect/Effect";
2+import * as SqlClient from "effect/unstable/sql/SqlClient";
3+
4+export default Effect.gen(function* () {
5+ const sql = yield* SqlClient.SqlClient;
6+
7+ yield* sql`
8+ CREATE INDEX orchestration_v2_projection_turn_items_thread_run_idx
9+ ON orchestration_v2_projection_turn_items(thread_id, run_id)
10+ `;
11+ // Shell background work includes idle items as well as pending/running/waiting.
12+ // Keep this predicate aligned with ProjectionStore's pending-item query.
13+ yield* sql`
14+ CREATE INDEX orchestration_v2_projection_turn_items_shell_pending_idx
15+ ON orchestration_v2_projection_turn_items(thread_id, run_id)
16+ WHERE type IN ('command_execution', 'dynamic_tool', 'subagent')
17+ AND status NOT IN ('completed', 'interrupted', 'failed', 'cancelled')
18+ `;
19+ yield* sql`
20+ CREATE INDEX orchestration_v2_projection_messages_latest_user_idx
21+ ON orchestration_v2_projection_messages(thread_id, updated_at DESC, message_id DESC)
22+ WHERE role = 'user'
23+ `;
24+});
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+16−0
@@ -319,6 +319,22 @@ describe("EventNdjsonLogger", () => { · 318 unmodified lines
319319 { method: "thread/realtime/transcript/delta", payload: circularDelta },
320320 threadId,
321321 );
322+ yield* native.write({ method: "turn/diff/updated", payload: circularDelta }, threadId);
323+ yield* native.write(
324+ {
325+ event: {
326+ direction: "incoming",
327+ stage: "decoded",
328+ payload: {
329+ method: "turn/diff/updated",
330+ get params() {
331+ throw new Error("unused diff snapshots must not be traversed");
332+ },
333+ },
334+ },
335+ },
336+ threadId,
337+ );
322338 yield* native.write(
323339 {
324340 event: {
apps/server/src/provider/Layers/EventNdjsonLogger.ts+1−0
@@ -57,6 +57,7 @@ const transientNativeMethods = new Set([ · 56 unmodified lines
5757 "item/reasoning/textDelta",
5858 "thread/realtime/outputAudio/delta",
5959 "thread/realtime/transcript/delta",
60+ "turn/diff/updated",
6061 ]);
6162 const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
6263
apps/server/src/relay/AgentAwarenessRelay.test.ts+573−0
@@ -0,0 +1,573 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3+import {
4+ EnvironmentId,
5+ type OrchestrationV2ThreadShell,
6+ ProjectId,
7+ ProviderInstanceId,
8+ ThreadId,
9+} from "@t3tools/contracts";
10+import { RelayAgentActivityState } from "@t3tools/contracts/relay";
11+import * as DateTime from "effect/DateTime";
12+import * as Deferred from "effect/Deferred";
13+import * as Effect from "effect/Effect";
14+import * as Fiber from "effect/Fiber";
15+import * as Option from "effect/Option";
16+import * as Ref from "effect/Ref";
17+import * as Schema from "effect/Schema";
18+import * as Stream from "effect/Stream";
19+import * as TestClock from "effect/testing/TestClock";
20+import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
21+
22+import { SecretStoreReadError, ServerSecretStore } from "../auth/ServerSecretStore.ts";
23+import {
24+ PUBLISH_AGENT_ACTIVITY_SECRET,
25+ RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
26+ RELAY_ISSUER_SECRET,
27+ RELAY_URL_SECRET,
28+} from "../cloud/config.ts";
29+import { ServerEnvironment } from "../environment/ServerEnvironment.ts";
30+import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
31+import { ProjectService } from "../project/ProjectService.ts";
32+import {
33+ make,
34+ makeAgentAwarenessPublishWorker,
35+ shouldPublishAgentAwarenessEvent,
36+} from "./AgentAwarenessRelay.ts";
37+
38+const THREAD_ID = ThreadId.make("relay-thread");
39+const SECOND_THREAD_ID = ThreadId.make("relay-thread-2");
40+const PROJECT_ID = ProjectId.make("relay-project");
41+const NOW = "2026-09-04T12:00:00.000Z";
42+
43+function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell {
44+ return {
45+ id: THREAD_ID,
46+ projectId: PROJECT_ID,
47+ title: "Thread",
48+ providerInstanceId: ProviderInstanceId.make("codex"),
49+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" },
50+ runtimeMode: "full-access",
51+ interactionMode: "default",
52+ worktreePath: null,
53+ activeProviderThreadId: null,
54+ lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null },
55+ forkedFrom: null,
56+ createdBy: "user",
57+ creationSource: "web",
58+ activeRunId: null,
59+ latestVisibleMessage: null,
60+ hasActionableProposedPlan: false,
61+ itemCount: 0,
62+ visibleItemCount: 0,
63+ lastVisitedAt: null,
64+ deletedAt: null,
65+ branch: null,
66+ linkedPullRequest: null,
67+ status: "running",
68+ activityRunStatus: null,
69+ pendingRuntimeRequest: null,
70+ pendingBackgroundTasks: [],
71+ latestRunId: null,
72+ latestRunRequestedAt: null,
73+ latestRunStartedAt: null,
74+ latestRunCompletedAt: null,
75+ latestUserMessageAt: null,
76+ createdAt: DateTime.makeUnsafe(NOW),
77+ updatedAt: DateTime.makeUnsafe(NOW),
78+ archivedAt: null,
79+ settledOverride: null,
80+ settledAt: null,
81+ snoozedUntil: null,
82+ snoozedAt: null,
83+ pinnedAt: null,
84+ ...overrides,
85+ };
86+}
87+
88+const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) });
89+const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload));
90+const unused = () => Effect.die("Unexpected test dependency call");
91+
92+const makeTestRelay = Effect.fnUntraced(function* (
93+ options: {
94+ readonly respond?: (attempt: number) => Response;
95+ readonly failSecretRead?: (name: string) => boolean;
96+ } = {},
97+) {
98+ const values = new Map<string, Uint8Array>([
99+ [PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")],
100+ [RELAY_URL_SECRET, new TextEncoder().encode("https://relay.example.test")],
101+ [RELAY_ISSUER_SECRET, new TextEncoder().encode("https://relay.example.test")],
102+ [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, new TextEncoder().encode("credential-1")],
103+ ]);
104+ const secretReads: string[] = [];
105+ const secrets = ServerSecretStore.of({
106+ get: (name) =>
107+ Effect.suspend(() => {
108+ secretReads.push(name);
109+ if (options.failSecretRead?.(name)) {
110+ return Effect.fail(
111+ new SecretStoreReadError({ resource: name, cause: "temporary read failure" }),
112+ );
113+ }
114+ return Effect.succeed(Option.fromUndefinedOr(values.get(name)));
115+ }),
116+ create: (name, value) => Effect.sync(() => void values.set(name, value)),
117+ set: (name, value) => Effect.sync(() => void values.set(name, value)),
118+ remove: (name) => Effect.sync(() => void values.delete(name)),
119+ getOrCreateRandom: unused,
120+ });
121+ const currentShell = yield* Ref.make<OrchestrationV2ThreadShell | null>(shell());
122+ const shellReads: ThreadId[] = [];
123+ const threads = ThreadManagementService.of({
124+ getThreadShell: (threadId) =>
125+ Effect.sync(() => shellReads.push(threadId)).pipe(Effect.andThen(Ref.get(currentShell))),
126+ getShellSnapshot: unused,
127+ ensureLegacyTranscript: unused,
128+ dispatch: unused,
129+ getThreadProjection: unused,
130+ getCheckpointContext: unused,
131+ getThreadSnapshot: unused,
132+ getThreadSnapshotWindow: unused,
133+ getProjectThread: unused,
134+ listProjectThreads: unused,
135+ sendToThread: unused,
136+ waitForThread: unused,
137+ interruptThread: unused,
138+ getThreadEventSequence: unused,
139+ streamStoredEvents: Stream.empty,
140+ streamStoredEventsFrom: () => Stream.empty,
141+ streamDomainEvents: Stream.empty,
142+ });
143+ const publications: Array<{
144+ readonly url: string;
145+ readonly authorization: string | null;
146+ readonly state: RelayAgentActivityState | null;
147+ }> = [];
148+ const fetch: typeof globalThis.fetch = Object.assign(
149+ (
150+ input: Parameters<typeof globalThis.fetch>[0],
151+ init?: Parameters<typeof globalThis.fetch>[1],
152+ ) => {
153+ const body = init?.body;
154+ if (typeof body !== "string" && !(body instanceof Uint8Array)) {
155+ return Promise.reject(new Error("Expected a serialized activity publish payload"));
156+ }
157+ const payload = decodePublishPayload(
158+ typeof body === "string" ? body : new TextDecoder().decode(body),
159+ );
160+ publications.push({
161+ url: String(input),
162+ authorization: new Headers(init?.headers).get("authorization"),
163+ state: payload.state,
164+ });
165+ return Promise.resolve(
166+ options.respond?.(publications.length) ?? Response.json({ ok: true, deliveries: [] }),
167+ );
168+ },
169+ { preconnect: () => {} },
170+ );
171+ const relay = yield* make.pipe(
172+ Effect.provideService(ServerSecretStore, secrets),
173+ Effect.provideService(ThreadManagementService, threads),
174+ Effect.provideService(ServerEnvironment, {
175+ getEnvironmentId: Effect.succeed(EnvironmentId.make("relay-environment")),
176+ getDescriptor: unused(),
177+ }),
178+ Effect.provideService(ProjectService, {
179+ create: unused,
180+ bootstrap: unused,
181+ update: unused,
182+ delete: unused,
183+ getByWorkspaceRoot: unused,
184+ snapshot: unused(),
185+ getById: () =>
186+ Effect.succeed(
187+ Option.some({
188+ id: PROJECT_ID,
189+ title: "Project",
190+ workspaceRoot: "/workspace",
191+ defaultModelSelection: null,
192+ scripts: [],
193+ createdAt: NOW,
194+ updatedAt: NOW,
195+ deletedAt: null,
196+ }),
197+ ),
198+ }),
199+ Effect.provideService(FetchHttpClient.Fetch, fetch),
200+ Effect.provide(NodeCrypto.layer),
201+ );
202+ return { relay, secrets, secretReads, currentShell, shellReads, publications };
203+});
204+
205+describe("AgentAwarenessRelay", () => {
206+ it("ignores transcript and tool updates but retains activity and metadata changes", () => {
207+ for (const type of [
208+ "message.updated",
209+ "turn-item.updated",
210+ "provider-turn.updated",
211+ "thread.visited",
212+ "thread.pinned",
213+ ] as const) {
214+ assert.isFalse(shouldPublishAgentAwarenessEvent({ type }));
215+ }
216+ for (const type of [
217+ "run.created",
218+ "run.updated",
219+ "runtime-request.updated",
220+ "thread.metadata-updated",
221+ "thread.model-selection-updated",
222+ "thread.provider-switched",
223+ "thread.archived",
224+ "thread.unarchived",
225+ "thread.deleted",
226+ ] as const) {
227+ assert.isTrue(shouldPublishAgentAwarenessEvent({ type }));
228+ }
229+ });
230+
231+ it.effect("coalesces queued updates and reruns a thread dirtied during publishing", () =>
232+ Effect.gen(function* () {
233+ const started = yield* Deferred.make<void>();
234+ const release = yield* Deferred.make<void>();
235+ const rerunStarted = yield* Deferred.make<void>();
236+ const releaseRerun = yield* Deferred.make<void>();
237+ const processed: Array<{ threadId: ThreadId; revision: number }> = [];
238+ let revision = 1;
239+ const worker = yield* makeAgentAwarenessPublishWorker((threadId) =>
240+ Effect.gen(function* () {
241+ const currentRevision = revision;
242+ const index = processed.length;
243+ processed.push({ threadId, revision: currentRevision });
244+ if (index === 0) {
245+ yield* Deferred.succeed(started, undefined);
246+ yield* Deferred.await(release);
247+ } else if (threadId === THREAD_ID) {
248+ yield* Deferred.succeed(rerunStarted, undefined);
249+ yield* Deferred.await(releaseRerun);
250+ }
251+ }),
252+ );
253+ yield* worker.enqueue(THREAD_ID);
254+ yield* Deferred.await(started);
255+ revision = 2;
256+ for (let i = 0; i < 100; i++) {
257+ yield* worker.enqueue(SECOND_THREAD_ID);
258+ yield* worker.enqueue(THREAD_ID);
259+ }
260+ const drained = yield* Deferred.make<void>();
261+ const draining = yield* worker.drain.pipe(
262+ Effect.andThen(Deferred.succeed(drained, undefined)),
263+ Effect.forkChild,
264+ );
265+ yield* Deferred.succeed(release, undefined);
266+ yield* Deferred.await(rerunStarted);
267+ assert.isFalse(yield* Deferred.isDone(drained));
268+ yield* Deferred.succeed(releaseRerun, undefined);
269+ yield* Fiber.join(draining);
270+ assert.deepEqual(processed, [
271+ { threadId: THREAD_ID, revision: 1 },
272+ { threadId: SECOND_THREAD_ID, revision: 2 },
273+ { threadId: THREAD_ID, revision: 2 },
274+ ]);
275+ }),
276+ );
277+
278+ it.effect("deduplicates state and republishes title changes", () =>
279+ Effect.gen(function* () {
280+ const { relay, currentShell, publications } = yield* makeTestRelay();
281+ yield* relay.publishThread(THREAD_ID);
282+ yield* Ref.set(
283+ currentShell,
284+ shell({ updatedAt: DateTime.makeUnsafe("2026-09-04T13:00:00Z") }),
285+ );
286+ yield* relay.publishThread(THREAD_ID);
287+ assert.equal(publications.length, 1);
288+ yield* Ref.set(currentShell, shell({ title: "Renamed thread" }));
289+ yield* relay.publishThread(THREAD_ID);
290+ assert.equal(publications.length, 2);
291+ assert.equal(publications[1]?.state?.threadTitle, "Renamed thread");
292+ }),
293+ );
294+
295+ it.effect("stops before shell reads when disabled and republishes after re-enabling", () =>
296+ Effect.gen(function* () {
297+ const { relay, secrets, secretReads, shellReads, publications } = yield* makeTestRelay();
298+ yield* relay.publishThread(THREAD_ID);
299+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false"));
300+ const previousReads = secretReads.length;
301+ yield* relay.publishThread(THREAD_ID);
302+ assert.deepEqual(secretReads.slice(previousReads), [PUBLISH_AGENT_ACTIVITY_SECRET]);
303+ assert.equal(shellReads.length, 1);
304+ assert.equal(publications.length, 1);
305+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true"));
306+ yield* relay.publishThread(THREAD_ID);
307+ assert.equal(publications.length, 2);
308+ }),
309+ );
310+
311+ it.effect("republishes unchanged state with fresh credentials after relinking", () =>
312+ Effect.gen(function* () {
313+ const { relay, secrets, publications } = yield* makeTestRelay();
314+ yield* relay.publishThread(THREAD_ID);
315+ yield* secrets.set(
316+ RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
317+ new TextEncoder().encode("credential-2"),
318+ );
319+ yield* relay.publishThread(THREAD_ID);
320+ assert.equal(publications.length, 2);
321+ assert.equal(publications[0]?.authorization, "Bearer credential-1");
322+ assert.equal(publications[1]?.authorization, "Bearer credential-2");
323+ yield* secrets.set(
324+ RELAY_URL_SECRET,
325+ new TextEncoder().encode("https://new-relay.example.test"),
326+ );
327+ yield* relay.publishThread(THREAD_ID);
328+ assert.equal(publications.length, 3);
329+ assert.isTrue(publications[2]?.url.startsWith("https://new-relay.example.test/") ?? false);
330+ }),
331+ );
332+
333+ it.effect("retries a failed final state without another thread event", () =>
334+ Effect.gen(function* () {
335+ const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({
336+ respond: (attempt) =>
337+ attempt === 2
338+ ? new Response("relay unavailable", { status: 503 })
339+ : Response.json({ ok: true, deliveries: [] }),
340+ });
341+ yield* relay.publishThread(THREAD_ID);
342+ yield* Ref.set(currentShell, shell({ status: "completed" }));
343+ yield* relay.publishThread(THREAD_ID);
344+ assert.equal(publications.length, 2);
345+ yield* TestClock.adjust("1 second");
346+ yield* relay.drain;
347+ assert.equal(publications.length, 3);
348+ assert.equal(publications[2]?.state?.phase, "completed");
349+ yield* TestClock.adjust("1 minute");
350+ yield* relay.drain;
351+ assert.equal(shellReads.length, 3);
352+ }),
353+ );
354+
355+ it.effect("retries the latest shell and credentials with first-completion confirmation", () =>
356+ Effect.gen(function* () {
357+ const { relay, secrets, currentShell, publications } = yield* makeTestRelay({
358+ respond: (attempt) =>
359+ attempt === 1
360+ ? new Response("relay unavailable", { status: 503 })
361+ : Response.json({ ok: true, deliveries: [] }),
362+ });
363+ yield* relay.publishThread(THREAD_ID);
364+ yield* Ref.set(currentShell, shell({ status: "completed", title: "Final title" }));
365+ yield* secrets.set(
366+ RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
367+ new TextEncoder().encode("credential-2"),
368+ );
369+ yield* TestClock.adjust("1 second");
370+ yield* relay.drain;
371+ assert.equal(publications.length, 1);
372+ yield* TestClock.adjust("5 seconds");
373+ yield* relay.drain;
374+ assert.equal(publications.length, 2);
375+ assert.equal(publications[1]?.authorization, "Bearer credential-2");
376+ assert.equal(publications[1]?.state?.phase, "completed");
377+ assert.equal(publications[1]?.state?.threadTitle, "Final title");
378+ }),
379+ );
380+
381+ it.effect("cancels a stale retry when a newer update publishes successfully", () =>
382+ Effect.gen(function* () {
383+ const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({
384+ respond: (attempt) =>
385+ attempt === 1
386+ ? new Response("relay unavailable", { status: 503 })
387+ : Response.json({ ok: true, deliveries: [] }),
388+ });
389+ yield* relay.publishThread(THREAD_ID);
390+ yield* Ref.set(currentShell, shell({ title: "New title" }));
391+ yield* relay.publishThread(THREAD_ID);
392+ assert.equal(publications.length, 2);
393+ assert.equal(publications[1]?.state?.threadTitle, "New title");
394+ yield* TestClock.adjust("1 minute");
395+ yield* relay.drain;
396+ assert.equal(shellReads.length, 2);
397+ }),
398+ );
399+
400+ it.effect.each(["disable", "unlink"] as const)(
401+ "stops retries before shell reads after %s",
402+ (change) =>
403+ Effect.gen(function* () {
404+ const { relay, secrets, shellReads, publications } = yield* makeTestRelay({
405+ respond: () => new Response("relay unavailable", { status: 503 }),
406+ });
407+ yield* relay.publishThread(THREAD_ID);
408+ if (change === "disable") {
409+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false"));
410+ } else {
411+ yield* secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET);
412+ }
413+ yield* TestClock.adjust("1 second");
414+ yield* relay.drain;
415+ yield* TestClock.adjust("1 minute");
416+ yield* relay.drain;
417+ assert.equal(publications.length, 1);
418+ assert.equal(shellReads.length, 1);
419+ }),
420+ );
421+
422+ it.effect("recovers from a transient configuration read failure", () =>
423+ Effect.gen(function* () {
424+ let failRead = false;
425+ const { relay, shellReads, publications } = yield* makeTestRelay({
426+ failSecretRead: (name) => failRead && name === PUBLISH_AGENT_ACTIVITY_SECRET,
427+ });
428+ failRead = true;
429+ yield* relay.publishThread(THREAD_ID);
430+ assert.equal(shellReads.length, 0);
431+ failRead = false;
432+ yield* TestClock.adjust("1 second");
433+ yield* relay.drain;
434+ assert.equal(publications.length, 1);
435+ assert.equal(publications[0]?.state?.phase, "running");
436+ }),
437+ );
438+
439+ it.effect("bounds retry attempts and resets the budget for a newer update", () =>
440+ Effect.gen(function* () {
441+ const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({
442+ respond: () => new Response("relay unavailable", { status: 503 }),
443+ });
444+ yield* relay.publishThread(THREAD_ID);
445+ let attempts = 1;
446+ for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) {
447+ yield* TestClock.adjust(delay - 1);
448+ yield* relay.drain;
449+ assert.equal(publications.length, attempts);
450+ yield* TestClock.adjust(1);
451+ yield* relay.drain;
452+ attempts += 1;
453+ assert.equal(publications.length, attempts);
454+ }
455+ yield* TestClock.adjust("1 minute");
456+ yield* relay.drain;
457+ assert.equal(publications.length, 6);
458+ assert.equal(shellReads.length, 6);
459+ yield* Ref.set(currentShell, shell({ title: "New update" }));
460+ yield* relay.publishThread(THREAD_ID);
461+ yield* TestClock.adjust("1 second");
462+ yield* relay.drain;
463+ assert.equal(publications.length, 8);
464+ assert.equal(publications[7]?.state?.threadTitle, "New update");
465+ }),
466+ );
467+
468+ it.effect("retries a confirmed completion without restarting its confirmation window", () =>
469+ Effect.gen(function* () {
470+ const { relay, currentShell, publications } = yield* makeTestRelay({
471+ respond: (attempt) =>
472+ attempt <= 2
473+ ? new Response("relay unavailable", { status: 503 })
474+ : Response.json({ ok: true, deliveries: [] }),
475+ });
476+ yield* Ref.set(currentShell, shell({ status: "completed" }));
477+ yield* relay.publishThread(THREAD_ID);
478+ yield* TestClock.adjust("5 seconds");
479+ yield* relay.drain;
480+ assert.equal(publications.length, 1);
481+ yield* TestClock.adjust("1 second");
482+ yield* relay.drain;
483+ assert.equal(publications.length, 2);
484+ yield* TestClock.adjust("2 seconds");
485+ yield* relay.drain;
486+ assert.equal(publications.length, 3);
487+ assert.isTrue(publications.every((publication) => publication.state?.phase === "completed"));
488+ yield* TestClock.adjust("1 minute");
489+ yield* relay.drain;
490+ assert.equal(publications.length, 3);
491+ }),
492+ );
493+
494+ it.effect("does not reset an exhausted retry budget for old confirmation timers", () =>
495+ Effect.gen(function* () {
496+ let unavailable = false;
497+ const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({
498+ respond: () =>
499+ unavailable
500+ ? new Response("relay unavailable", { status: 503 })
501+ : Response.json({ ok: true, deliveries: [] }),
502+ });
503+ yield* relay.publishThread(THREAD_ID);
504+ // Leave six obsolete confirmations at 5,000 through 5,005 milliseconds.
505+ for (let i = 0; i < 6; i++) {
506+ yield* Ref.set(currentShell, null);
507+ yield* relay.publishThread(THREAD_ID);
508+ yield* Ref.set(currentShell, shell());
509+ yield* relay.publishThread(THREAD_ID);
510+ yield* TestClock.adjust(1);
511+ }
512+ unavailable = true;
513+ yield* Ref.set(currentShell, shell({ status: "failed" }));
514+ yield* relay.publishThread(THREAD_ID);
515+ yield* TestClock.adjust(1_000);
516+ yield* relay.drain;
517+ yield* TestClock.adjust(2_000);
518+ yield* relay.drain;
519+ yield* TestClock.adjust(1_994);
520+ yield* relay.drain;
521+ for (let i = 0; i < 5; i++) {
522+ yield* TestClock.adjust(1);
523+ yield* relay.drain;
524+ }
525+ const attempts = publications.length;
526+ const reads = shellReads.length;
527+ yield* TestClock.adjust("1 minute");
528+ yield* relay.drain;
529+ assert.equal(publications.length, attempts);
530+ assert.equal(shellReads.length, reads);
531+ }),
532+ );
533+
534+ it.effect("confirms tombstones and cancels stale confirmations when activity recovers", () =>
535+ Effect.gen(function* () {
536+ const { relay, currentShell, publications } = yield* makeTestRelay();
537+ yield* relay.publishThread(THREAD_ID);
538+ yield* Ref.set(currentShell, null);
539+ yield* relay.publishThread(THREAD_ID);
540+ assert.equal(publications.length, 1);
541+ yield* Ref.set(currentShell, shell());
542+ yield* relay.publishThread(THREAD_ID);
543+ yield* TestClock.adjust("5 seconds");
544+ yield* relay.drain;
545+ assert.equal(publications.length, 1);
546+ yield* Ref.set(currentShell, null);
547+ yield* relay.publishThread(THREAD_ID);
548+ assert.equal(publications.length, 1);
549+ yield* TestClock.adjust("5 seconds");
550+ yield* relay.drain;
551+ assert.equal(publications.length, 2);
552+ assert.equal(publications[1]?.state, null);
553+ }),
554+ );
555+
556+ it.effect("confirms a first completed state and respects disabling during confirmation", () =>
557+ Effect.gen(function* () {
558+ const { relay, secrets, currentShell, publications } = yield* makeTestRelay();
559+ yield* Ref.set(currentShell, shell({ status: "completed" }));
560+ yield* relay.publishThread(THREAD_ID);
561+ assert.equal(publications.length, 0);
562+ yield* TestClock.adjust("5 seconds");
563+ yield* relay.drain;
564+ assert.equal(publications[0]?.state?.phase, "completed");
565+ yield* Ref.set(currentShell, null);
566+ yield* relay.publishThread(THREAD_ID);
567+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false"));
568+ yield* TestClock.adjust("5 seconds");
569+ yield* relay.drain;
570+ assert.equal(publications.length, 1);
571+ }),
572+ );
573+});
apps/server/src/relay/AgentAwarenessRelay.ts+167−26
@@ -23,6 +23,7 @@ import * as Context from "effect/Context"; · 22 unmodified lines
2323 import * as Crypto from "effect/Crypto";
2424 import * as DateTime from "effect/DateTime";
2525 import * as Effect from "effect/Effect";
26+import * as Fiber from "effect/Fiber";
2627 import * as Layer from "effect/Layer";
2728 import * as Option from "effect/Option";
2829 import * as Ref from "effect/Ref";
@@ -51,6 +52,7 @@ export class AgentAwarenessRelay extends Context.Service< · 22 unmodified lines
5152 AgentAwarenessRelay,
5253 {
5354 readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>;
55+ readonly drain: Effect.Effect<void>;
5456 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
5557 }
5658 >()("t3/relay/AgentAwarenessRelay") {}
@@ -59,13 +61,74 @@ export function eventThreadId(event: OrchestrationV2DomainEvent): ThreadId { · 2 unmodified lines
5961 return event.threadId;
6062 }
6163
62−export function shouldPublishAgentAwarenessEvent(_event: OrchestrationV2DomainEvent): boolean {
63− // Publishing is identity-deduplicated below. Watching every V2 event keeps
64− // this consumer correct as the shell projection evolves without duplicating
65− // projection-specific relevance rules here.
66− return true;
64+export function shouldPublishAgentAwarenessEvent(
65+ event: Pick<OrchestrationV2DomainEvent, "type">,
66+): boolean {
67+ // projectThreadAwarenessV2 reads thread metadata, run status, and pending requests.
68+ // Message bodies and tool progress cannot change the published activity.
69+ switch (event.type) {
70+ case "thread.created":
71+ case "thread.archived":
72+ case "thread.unarchived":
73+ case "thread.deleted":
74+ case "thread.metadata-updated":
75+ case "thread.model-selection-updated":
76+ case "thread.provider-switched":
77+ case "run.created":
78+ case "run.updated":
79+ case "runtime-request.updated":
80+ return true;
81+ case "thread.settled":
82+ case "thread.unsettled":
83+ case "thread.snoozed":
84+ case "thread.unsnoozed":
85+ case "thread.pinned":
86+ case "thread.unpinned":
87+ case "thread.pin-reordered":
88+ case "thread.visited":
89+ case "thread.marked-unread":
90+ case "thread.runtime-mode-updated":
91+ case "thread.interaction-mode-updated":
92+ case "run-attempt.created":
93+ case "run-attempt.updated":
94+ case "node.updated":
95+ case "subagent.updated":
96+ case "provider-session.attached":
97+ case "provider-session.updated":
98+ case "provider-session.detached":
99+ case "provider-thread.updated":
100+ case "provider-turn.updated":
101+ case "message.updated":
102+ case "turn-item.updated":
103+ case "plan.updated":
104+ case "checkpoint-scope.created":
105+ case "checkpoint.captured":
106+ case "checkpoint.rollback-requested":
107+ case "context-handoff.updated":
108+ case "context-transfer.created":
109+ case "context-transfer.updated":
110+ return false;
111+ }
67112 }
68113
114+export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* <R>(
115+ publish: (threadId: ThreadId) => Effect.Effect<void, never, R>,
116+) {
117+ const queued = new Set<ThreadId>();
118+ const worker = yield* makeDrainableWorker((threadId: ThreadId) =>
119+ Effect.sync(() => queued.delete(threadId)).pipe(Effect.andThen(publish(threadId))),
120+ );
121+ const enqueue = (threadId: ThreadId) =>
122+ Effect.suspend(() => {
123+ if (queued.has(threadId)) return Effect.void;
124+ // Removing the ID when processing starts allows one new queue entry
125+ // for updates received while the current snapshot is being published.
126+ queued.add(threadId);
127+ return worker.enqueue(threadId);
128+ }).pipe(Effect.uninterruptible);
129+ return { enqueue, drain: worker.drain };
130+});
131+
69132 export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string {
70133 if (state === null) {
71134 return "null";
@@ -90,6 +153,7 @@ export function resolveAgentActivityPublishingStartupState(input: { · 18 unmodified lines
90153
91154 const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160;
92155 const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed.";
156+const RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
93157
94158 export function sanitizeRelayAgentActivityState(
95159 state: RelayAgentActivityState | null,
@@ -268,6 +332,7 @@ export const make = Effect.gen(function* () { · 172 unmodified lines
268332 const threads = yield* ThreadManagement.ThreadManagementService;
269333 const projects = yield* ProjectService.ProjectService;
270334 const crypto = yield* Crypto.Crypto;
335+ const scope = yield* Effect.scope;
271336 const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
272337 const activeSnapshotPublishedRef = yield* Ref.make(false);
273338 const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>());
@@ -305,6 +370,9 @@ export const make = Effect.gen(function* () { · 31 unmodified lines
305370 transformClient: relayEnvironmentClient(relayConfig.environmentCredential),
306371 }).pipe(Effect.provide(FetchHttpClient.layer));
307372
373+ let cachedRelayClient: Effect.Success<ReturnType<typeof makeRelayClient>> | undefined;
374+ let publishedRelayConfig: NonNullable<Effect.Success<typeof readRelayConfig>> | undefined;
375+
308376 // Deadlines for publishes that need confirmation (tombstones and
309377 // first-state completions). The confirming publish is re-enqueued through
310378 // the same drainable worker as every other publish, so a confirmed
@@ -312,25 +380,53 @@ export const make = Effect.gen(function* () { · 1 unmodified line
312380 // clears the deadline. Assigned after the worker exists.
313381 const publishConfirmDeadlines = new Map<ThreadId, number>();
314382 let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void;
383+ const publishRetries = new Map<
384+ ThreadId,
385+ { readonly attempts: number; timer: Fiber.Fiber<void> | undefined }
386+ >();
387+ const cancelPublishRetry = Effect.fnUntraced(function* (threadId: ThreadId) {
388+ const retry = publishRetries.get(threadId);
389+ publishRetries.delete(threadId);
390+ if (retry?.timer !== undefined) yield* Fiber.interrupt(retry.timer);
391+ });
392+ const cancelPublishRetries = Effect.suspend(() =>
393+ Effect.forEach([...publishRetries.keys()], cancelPublishRetry, { discard: true }),
394+ );
395+ let schedulePublishRetry: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void;
396+ const resetPublishedConnection = Effect.gen(function* () {
397+ publishedRelayConfig = undefined;
398+ cachedRelayClient = undefined;
399+ publishConfirmDeadlines.clear();
400+ yield* Ref.set(publishedStateByThreadRef, new Map());
401+ });
315402
316403 const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) {
317− const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
318− Effect.orElseSucceed(() => false),
319− );
404+ const publishAgentActivity = yield* readPublishAgentActivityEnabled;
320405 if (!publishAgentActivity) {
406+ yield* cancelPublishRetries;
407+ yield* resetPublishedConnection;
321408 yield* Effect.logDebug("agent activity publish skipped; publication disabled", {
322409 threadId,
323410 });
324411 return;
325412 }
326− const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));
413+ const relayConfig = yield* readRelayConfig;
327414 if (!relayConfig) {
415+ yield* cancelPublishRetries;
416+ yield* resetPublishedConnection;
328417 yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", {
329418 threadId,
330419 });
331420 return;
332421 }
333− const relayClient = yield* makeRelayClient(relayConfig);
422+ if (
423+ publishedRelayConfig?.url !== relayConfig.url ||
424+ publishedRelayConfig.issuer !== relayConfig.issuer ||
425+ publishedRelayConfig.environmentCredential !== relayConfig.environmentCredential
426+ ) {
427+ yield* resetPublishedConnection;
428+ publishedRelayConfig = relayConfig;
429+ }
334430 const environmentId = yield* serverEnvironment.getEnvironmentId;
335431
336432 const publishState = (input: {
@@ -339,6 +435,10 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
339435 readonly reason: string;
340436 }) =>
341437 Effect.gen(function* () {
438+ if (cachedRelayClient === undefined) {
439+ cachedRelayClient = yield* makeRelayClient(relayConfig);
440+ }
441+ const relayClient = cachedRelayClient;
342442 const proof = yield* makePublishProof({
343443 privateKey: cloudLinkKeyPair.privateKey,
344444 relayIssuer: relayConfig.issuer,
@@ -441,7 +541,6 @@ export const make = Effect.gen(function* () { · 96 unmodified lines
441541 if (nowMs < deadline) {
442542 return;
443543 }
444− publishConfirmDeadlines.delete(threadId);
445544 yield* Effect.logInfo("agent activity deferred publish confirmed", {
446545 environmentId,
447546 threadId,
@@ -471,6 +570,7 @@ export const make = Effect.gen(function* () { · 23 unmodified lines
471570 state: snapshot.state,
472571 reason: snapshot.reason,
473572 });
573+ publishConfirmDeadlines.delete(threadId);
474574 yield* Ref.update(publishedStateByThreadRef, (publishedStates) => {
475575 const nextPublishedStates = new Map(publishedStates);
476576 nextPublishedStates.set(threadId, publishIdentity);
@@ -478,17 +578,63 @@ export const make = Effect.gen(function* () { · 1 unmodified line
478578 });
479579 });
480580
481− const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>
482− publishThreadUnsafe(threadId).pipe(
581+ const processThreadPublish = Effect.fnUntraced(function* (threadId: ThreadId) {
582+ const retry = publishRetries.get(threadId);
583+ if (retry?.timer !== undefined) {
584+ const timer = retry.timer;
585+ retry.timer = undefined;
586+ yield* Fiber.interrupt(timer);
587+ }
588+ yield* publishThreadUnsafe(threadId).pipe(
589+ Effect.tap(() => cancelPublishRetry(threadId)),
483590 Effect.catchCause((cause) => {
591+ if (Cause.hasInterruptsOnly(cause)) return Effect.void;
484592 return Effect.logWarning("agent activity publish failed", {
485593 threadId,
486594 cause: Cause.pretty(cause),
487− });
595+ }).pipe(Effect.andThen(schedulePublishRetry(threadId)));
488596 }),
489597 Effect.withSpan("AgentAwarenessRelay.publishThread"),
490598 withRelayClientTracing,
491599 );
600+ });
601+
602+ const worker = yield* makeAgentAwarenessPublishWorker(processThreadPublish);
603+ const enqueueThreadPublish = (threadId: ThreadId) =>
604+ cancelPublishRetry(threadId).pipe(Effect.andThen(worker.enqueue(threadId)));
605+ const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>
606+ enqueueThreadPublish(threadId).pipe(Effect.andThen(worker.drain));
607+
608+ schedulePublishRetry = Effect.fnUntraced(function* (threadId: ThreadId) {
609+ const attempts = publishRetries.get(threadId)?.attempts ?? 0;
610+ const delayMs = RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS[attempts];
611+ if (delayMs === undefined) {
612+ // Keep the exhausted budget until fresh activity or a successful publish
613+ // clears it; an old confirmation timer must not start another retry series.
614+ yield* Effect.logWarning("agent activity publish retry budget exhausted", { threadId });
615+ return;
616+ }
617+ const retry = {
618+ attempts: attempts + 1,
619+ timer: undefined as Fiber.Fiber<void> | undefined,
620+ };
621+ publishRetries.set(threadId, retry);
622+ const timer = yield* Effect.sleep(delayMs).pipe(
623+ Effect.andThen(
624+ Effect.suspend(() => {
625+ if (publishRetries.get(threadId) !== retry) return Effect.void;
626+ retry.timer = undefined;
627+ return worker.enqueue(threadId);
628+ }),
629+ ),
630+ Effect.forkIn(scope),
631+ );
632+ if (publishRetries.get(threadId) !== retry) {
633+ yield* Fiber.interrupt(timer);
634+ } else {
635+ retry.timer = timer;
636+ }
637+ });
492638
493639 const publishActiveThreadsUnsafe = Effect.gen(function* () {
494640 const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
@@ -520,7 +666,8 @@ export const make = Effect.gen(function* () { · 25 unmodified lines
520666 yield* Effect.logInfo("publishing active agent activity snapshot", {
521667 count: activeThreadIds.length,
522668 });
523− yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true });
669+ yield* Effect.forEach(activeThreadIds, enqueueThreadPublish, { discard: true });
670+ yield* worker.drain;
524671 return true;
525672 });
526673
@@ -542,10 +689,8 @@ export const make = Effect.gen(function* () { · 15 unmodified lines
542689 }
543690 });
544691
545− const worker = yield* makeDrainableWorker(publishThread);
546−
547692 schedulePublishConfirm = (threadId) =>
548− Effect.forkDetach(
693+ Effect.forkIn(
549694 Effect.sleep("5 seconds").pipe(
550695 Effect.andThen(worker.enqueue(threadId)),
551696 Effect.catchCause((cause) =>
@@ -555,6 +700,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
555700 }),
556701 ),
557702 ),
703+ scope,
558704 ).pipe(Effect.asVoid);
559705
560706 const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")(
@@ -591,18 +737,12 @@ export const make = Effect.gen(function* () { · 30 unmodified lines
591737 Stream.runForEach(threads.streamDomainEvents, (event) => {
592738 const threadId = eventThreadId(event);
593739 if (!shouldPublishAgentAwarenessEvent(event)) {
594− return Effect.logDebug(
595− "agent activity publishing ignored event without activity changes",
596− {
597− eventType: event.type,
598− threadId,
599− },
600− );
740+ return Effect.void;
601741 }
602742 return Effect.logDebug("agent activity publishing queued thread publish", {
603743 eventType: event.type,
604744 threadId,
605− }).pipe(Effect.andThen(worker.enqueue(threadId)));
745+ }).pipe(Effect.andThen(enqueueThreadPublish(threadId)));
606746 }),
607747 );
608748 },
@@ -610,6 +750,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
610750
611751 return AgentAwarenessRelay.of({
612752 publishThread,
753+ drain: worker.drain,
613754 start,
614755 });
615756 });
apps/server/src/server.ts+1−1
@@ -412,7 +412,7 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( · 411 unmodified lines
412412 // so every client sees the same shelf.
413413 const ThreadSettlementWorkerLive = Layer.effectDiscard(
414414 ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())),
415−).pipe(Layer.provide(PullRequestServiceLive));
415+).pipe(Layer.provide(PullRequestServiceLive), Layer.provide(ProjectionStoreV2.layer));
416416
417417 const AntigravityInstallationRefreshLive = Layer.effectDiscard(
418418 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriver.ts+9−1
@@ -74,6 +74,11 @@ export interface GitStatusDetails { · 73 unmodified lines
7474 aheadOfDefaultCount: number;
7575 }
7676
77+export interface GitLocalStatusOptions {
78+ /** Skip revision walks and return zero divergence counts for local-only consumers. */
79+ readonly includeDivergence?: boolean;
80+}
81+
7782 export interface GitRemoteStatusDetails {
7883 isRepo: boolean;
7984 defaultBranch: string | null;
@@ -246,7 +251,10 @@ export class GitVcsDriver extends Context.Service< · 166 unmodified lines
246251 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
247252 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
248253 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
249− readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
254+ readonly statusDetailsLocal: (
255+ cwd: string,
256+ options?: GitLocalStatusOptions,
257+ ) => Effect.Effect<GitStatusDetails, GitCommandError>;
250258 readonly statusDetailsRemote: (
251259 cwd: string,
252260 options?: GitRemoteStatusOptions,
apps/server/src/vcs/GitVcsDriverCore.test.ts+84−7
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
88 import * as FileSystem from "effect/FileSystem";
99 import * as Fiber from "effect/Fiber";
1010 import * as Layer from "effect/Layer";
11+import * as Logger from "effect/Logger";
1112 import * as Path from "effect/Path";
1213 import * as PlatformError from "effect/PlatformError";
1314 import * as Ref from "effect/Ref";
@@ -553,7 +554,7 @@ it.effect("refreshes the current branch after an external checkout", () => · 539 unmodified lines
553554 ).pipe(Effect.provide(TestLayer)),
554555 );
555556
556−it.effect("backs off failed upstream refreshes across linked worktrees", () =>
557+it.effect("backs off and logs failed fetch attempts across linked worktrees", () =>
557558 Effect.scoped(
558559 Effect.gen(function* () {
559560 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -573,6 +574,15 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => · 13 unmodified lines
573574 const driver = yield* makeGitVcsDriverCore().pipe(
574575 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner),
575576 );
577+ const warnings: string[] = [];
578+ const logger = Logger.make<unknown, void>(({ message }) => {
579+ warnings.push(String(message));
580+ });
581+ const readRemoteStatus = (workingDirectory: string) =>
582+ driver
583+ .statusDetailsRemote(workingDirectory)
584+ .pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])));
585+ const fileSystem = yield* FileSystem.FileSystem;
576586 const cwd = yield* makeTmpDir();
577587 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
578588 const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-");
@@ -616,25 +626,31 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => · 37 unmodified lines
616626 );
617627 yield* Ref.set(fetchAttempts, 0);
618628
619− yield* driver.statusDetailsRemote(cwd);
620− yield* driver.statusDetailsRemote(worktreePath);
629+ yield* readRemoteStatus(cwd);
630+ yield* readRemoteStatus(worktreePath);
621631 assert.equal(yield* Ref.get(fetchAttempts), 1);
632+ assert.lengthOf(warnings, 1);
633+ assert.include(warnings[0], "Background Git fetch failed");
622634
623635 yield* TestClock.adjust("29 seconds");
624− yield* driver.statusDetailsRemote(worktreePath);
636+ yield* readRemoteStatus(worktreePath);
625637 assert.equal(yield* Ref.get(fetchAttempts), 1);
638+ assert.lengthOf(warnings, 1);
626639
627640 yield* TestClock.adjust("1 second");
628− yield* driver.statusDetailsRemote(cwd);
641+ yield* readRemoteStatus(cwd);
629642 assert.equal(yield* Ref.get(fetchAttempts), 2);
643+ assert.lengthOf(warnings, 2);
630644
631645 yield* TestClock.adjust("59 seconds");
632− yield* driver.statusDetailsRemote(worktreePath);
646+ yield* readRemoteStatus(worktreePath);
633647 assert.equal(yield* Ref.get(fetchAttempts), 2);
648+ assert.lengthOf(warnings, 2);
634649
635650 yield* TestClock.adjust("1 second");
636− yield* driver.statusDetailsRemote(cwd);
651+ yield* readRemoteStatus(cwd);
637652 assert.equal(yield* Ref.get(fetchAttempts), 3);
653+ assert.lengthOf(warnings, 3);
638654 }),
639655 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
640656 );
@@ -1027,6 +1043,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 386 unmodified lines
10271043 }),
10281044 );
10291045
1046+ it.effect("can read local files without traversing revision history", () =>
1047+ Effect.gen(function* () {
1048+ const cwd = yield* makeTmpDir();
1049+ const remote = yield* makeTmpDir("git-vcs-driver-remote-");
1050+ const { initialBranch } = yield* initRepoWithCommit(cwd);
1051+ yield* git(remote, ["init", "--bare"]);
1052+ yield* git(cwd, ["remote", "add", "origin", remote]);
1053+ yield* git(cwd, ["push", "-u", "origin", initialBranch]);
1054+ yield* git(cwd, ["checkout", "-b", "feature/local-only"]);
1055+ yield* writeTextFile(cwd, "feature.txt", "feature\n");
1056+ yield* git(cwd, ["add", "feature.txt"]);
1057+ yield* git(cwd, ["commit", "-m", "feature commit"]);
1058+ yield* writeTextFile(cwd, "feature.txt", "feature\nlocal edit\n");
1059+ yield* writeTextFile(cwd, "untracked.txt", "untracked\n");
1060+
1061+ const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
1062+ const commands: ReadonlyArray<string>[] = [];
1063+ const spawner = ChildProcessSpawner.make((command) =>
1064+ Effect.gen(function* () {
1065+ if (!ChildProcess.isStandardCommand(command)) {
1066+ return yield* Effect.die("expected a standard Git command");
1067+ }
1068+ commands.push(command.args);
1069+ return yield* delegate.spawn(command);
1070+ }),
1071+ );
1072+ const driver = yield* makeGitVcsDriverCore().pipe(
1073+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
1074+ Effect.provide(ServerConfigLayer),
1075+ );
1076+
1077+ for (const hasUpstream of [false, true]) {
1078+ if (hasUpstream) {
1079+ yield* git(cwd, ["push", "-u", "origin", "feature/local-only"]);
1080+ }
1081+ commands.length = 0;
1082+ const local = yield* driver.statusDetailsLocal(cwd, { includeDivergence: false });
1083+ assert.isFalse(commands.some((args) => args.includes("rev-list")));
1084+ assert.isTrue(commands.some((args) => args.includes("--no-ahead-behind")));
1085+ assert.equal(local.hasUpstream, hasUpstream);
1086+ assert.isTrue(local.hasWorkingTreeChanges);
1087+ assert.deepEqual(local.workingTree.files, [
1088+ { path: "feature.txt", insertions: 1, deletions: 0 },
1089+ { path: "untracked.txt", insertions: 0, deletions: 0 },
1090+ ]);
1091+
1092+ commands.length = 0;
1093+ const full = yield* driver.statusDetailsLocal(cwd);
1094+ assert.isTrue(commands.some((args) => args.includes("rev-list")));
1095+ assert.equal(full.aheadCount, hasUpstream ? 0 : 1);
1096+ assert.equal(full.aheadOfDefaultCount, 1);
1097+ assert.deepEqual(local, {
1098+ ...full,
1099+ aheadCount: 0,
1100+ behindCount: 0,
1101+ aheadOfDefaultCount: 0,
1102+ });
1103+ }
1104+ }),
1105+ );
1106+
10301107 it.effect("reports remote divergence without reading working-tree details", () =>
10311108 Effect.gen(function* () {
10321109 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+21−9
@@ -1246,6 +1246,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1245 unmodified lines
12461246 return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(
12471247 Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),
12481248 Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),
1249+ Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)),
12491250 Effect.as(true as const),
12501251 );
12511252 });
@@ -1270,13 +1271,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 18 unmodified lines
12701271 const upstream = yield* resolveCurrentUpstream(cwd);
12711272 if (!upstream) return;
12721273 const gitCommonDir = yield* resolveGitCommonDir(cwd);
1274+ // The cache loader logs failed attempts; cache hits keep using the last fetched refs.
12731275 yield* Cache.get(
12741276 statusRemoteRefreshCache,
12751277 new StatusRemoteRefreshCacheKey({
12761278 gitCommonDir,
12771279 remoteName: upstream.remoteName,
12781280 }),
1279− );
1281+ ).pipe(Effect.ignore);
12801282 });
12811283
12821284 const resolveDefaultBranchName = (
@@ -1583,11 +1585,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 300 unmodified lines
15831585 };
15841586 });
15851587
1586− const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
1588+ const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (
1589+ cwd: string,
1590+ options?: GitVcsDriver.GitLocalStatusOptions,
1591+ ) {
1592+ const includeDivergence = options?.includeDivergence !== false;
1593+ const statusArgs = [
1594+ "status",
1595+ "--porcelain=2",
1596+ "--branch",
1597+ ...(includeDivergence ? [] : ["--no-ahead-behind"]),
1598+ ];
15871599 const statusResult = yield* executeGitWithStableDiagnostics(
15881600 "GitVcsDriver.statusDetails.status",
15891601 cwd,
1590− ["status", "--porcelain=2", "--branch"],
1602+ statusArgs,
15911603 {
15921604 allowNonZeroExit: true,
15931605 },
@@ -1610,7 +1622,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 16 unmodified lines
16101622 ...gitCommandContext({
16111623 operation: "GitVcsDriver.statusDetails.status",
16121624 cwd,
1613− args: ["status", "--porcelain=2", "--branch"],
1625+ args: statusArgs,
16141626 }),
16151627 detail: "Git status failed.",
16161628 exitCode: statusResult.exitCode,
@@ -1710,7 +1722,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 93 unmodified lines
17101722 upstreamRef = value.length > 0 ? value : null;
17111723 continue;
17121724 }
1713− if (line.startsWith("# branch.ab ")) {
1725+ if (includeDivergence && line.startsWith("# branch.ab ")) {
17141726 const value = line.slice("# branch.ab ".length).trim();
17151727 const parsed = parseBranchAb(value);
17161728 aheadCount = parsed.ahead;
@@ -1725,7 +1737,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 8 unmodified lines
17251737 }
17261738
17271739 const fallbackAheadCount =
1728− !upstreamRef && refName
1740+ includeDivergence && !upstreamRef && refName
17291741 ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0))
17301742 : null;
17311743
@@ -1738,7 +1750,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 6 unmodified lines
17381750 refName !== null &&
17391751 (refName === defaultBranch ||
17401752 (defaultBranch === null && (refName === "main" || refName === "master")));
1741− if (refName && !isDefaultBranch) {
1753+ if (includeDivergence && refName && !isDefaultBranch) {
17421754 aheadOfDefaultCount =
17431755 fallbackAheadCount !== null
17441756 ? fallbackAheadCount
@@ -1788,8 +1800,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 43 unmodified lines
17881800
17891801 const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn(
17901802 "statusDetailsLocal",
1791− )(function* (cwd) {
1792− return yield* readStatusDetailsLocal(cwd);
1803+ )(function* (cwd, options) {
1804+ return yield* readStatusDetailsLocal(cwd, options);
17931805 });
17941806
17951807 const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn(

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t