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 (53 of 167 files). Show all

Unified · Split

53 files
  1. apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift +189 −0
  2. apps/mobile/scripts/notification-center-manager.test.ts +65 −0
  3. apps/server/src/git/GitManager.ts +1 −1
  4. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +4 −1
  5. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +1 −0
  6. apps/server/src/orchestration-v2/Orchestrator.ts +55 −31
  7. apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +363 −0
  8. apps/server/src/orchestration-v2/ProjectionStore.ts +192 −0
  9. apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +2 −0
  10. apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +6 −2
  11. apps/server/src/orchestration-v2/ThreadSettlementService.ts +6 −6
  12. apps/server/src/orchestration-v2/runtimeLayer.test.ts +32 −0
  13. apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson +1 −1
  14. apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson +1 −1
  15. apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson +1 −1
  16. apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson +1 −1
  17. apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson +2 −2
  18. apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson +1 −1
  19. apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson +1 −1
  20. apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson +1 −1
  21. apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson +1 −1
  22. apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson +1 −1
  23. apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson +1 −1
  24. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson +1 −1
  25. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson +1 −1
  26. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson +1 −1
  27. apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson +1 −1
  28. apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson +1 −1
  29. apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson +1 −1
  30. apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson +1 −1
  31. apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson +1 −1
  32. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson +1 −1
  33. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson +1 −1
  34. apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson +1 −1
  35. apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson +1 −1
  36. apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson +1 −1
  37. apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson +1 −1
  38. apps/server/src/persistence/Migrations.ts +2 −0
  39. apps/server/src/persistence/Migrations/059_OrchestrationV2ShellIndexes.ts +24 −0
  40. apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +16 −0
  41. apps/server/src/provider/Layers/EventNdjsonLogger.ts +1 −0
  42. apps/server/src/relay/AgentAwarenessRelay.test.ts +573 −0
  43. apps/server/src/relay/AgentAwarenessRelay.ts +167 −26
  44. apps/server/src/server.ts +1 −1
  45. apps/server/src/vcs/GitVcsDriver.ts +9 −1
  46. apps/server/src/vcs/GitVcsDriverCore.test.ts +84 −7
  47. apps/server/src/vcs/GitVcsDriverCore.ts +21 −9
  48. apps/web/src/components/CommandPalette.tsx +9 −1
  49. apps/web/src/components/ThreadStatusIndicators.subscriptions.test.tsx +180 −0
  50. apps/web/src/components/ThreadStatusIndicators.test.ts +19 −0
  51. apps/web/src/components/ThreadStatusIndicators.tsx +59 −17
  52. apps/web/src/connection/desktopLocal.test.ts +62 −1
  53. apps/web/src/connection/desktopLocal.ts +19 −2
apps/mobile/scripts/fixtures/NotificationCenterManagerRegression.swift+189−0
@@ -0,0 +1,189 @@
1+import Foundation
2+
3+// The registry runs on macOS without starting a simulator. Only the OS-facing
4+// types are replaced; the tests compile the dependency's actual Swift source.
5+public enum UIBackgroundFetchResult {
6+ case noData
7+}
8+
9+public struct UNNotificationPresentationOptions: OptionSet {
10+ public let rawValue: Int
11+ public init(rawValue: Int) { self.rawValue = rawValue }
12+}
13+
14+public final class UNNotification: NSObject {}
15+
16+public final class UNNotificationResponse: NSObject {
17+ let identifier: String
18+ init(_ identifier: String) { self.identifier = identifier }
19+}
20+
21+public protocol UNUserNotificationCenterDelegate: AnyObject {}
22+
23+public final class UNUserNotificationCenter: NSObject {
24+ private static let instance = UNUserNotificationCenter()
25+ public weak var delegate: UNUserNotificationCenterDelegate?
26+ public static func current() -> UNUserNotificationCenter { instance }
27+}
28+
29+private final class TestDelegate: NotificationDelegate {
30+ private let lock = NSLock()
31+ private var recordedEvents: [String] = []
32+ var onEvent: ((String) -> Void)?
33+ var onResponse: ((UNNotificationResponse) -> Bool)?
34+
35+ var events: [String] { lock.withLock { recordedEvents } }
36+
37+ private func record(_ event: String) {
38+ lock.withLock { recordedEvents.append(event) }
39+ onEvent?(event)
40+ }
41+
42+ func didRegister(_ deviceToken: String) { record("registered") }
43+ func didFailRegistration(_ error: Error) { record("failed") }
44+ func openSettings(_ notification: UNNotification?) { record("settings") }
45+
46+ func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
47+ record("present")
48+ return false
49+ }
50+
51+ func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
52+ record("background")
53+ return false
54+ }
55+
56+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
57+ record(response.identifier)
58+ return onResponse?(response) ?? false
59+ }
60+}
61+
62+private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
63+ if !condition() {
64+ throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
65+ }
66+}
67+
68+private func deliver(_ identifier: String) {
69+ NotificationCenterManager.shared.userNotificationCenter(
70+ UNUserNotificationCenter.current(),
71+ didReceive: UNNotificationResponse(identifier),
72+ withCompletionHandler: {}
73+ )
74+}
75+
76+private func reentrantCallbacks() throws {
77+ let manager = NotificationCenterManager.shared
78+ let center = UNUserNotificationCenter.current()
79+ let callbacks: [(String, () -> Void)] = [
80+ ("registered", { manager.didRegister("token") }),
81+ ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }),
82+ ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }),
83+ ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }),
84+ ("background", { manager.didReceive([:], completionHandler: { _ in }) })
85+ ]
86+
87+ for (event, callback) in callbacks {
88+ let original = TestDelegate()
89+ let replacement = TestDelegate()
90+ original.onEvent = { [weak original] _ in
91+ guard let original else { return }
92+ manager.removeDelegate(original)
93+ manager.addDelegate(replacement)
94+ }
95+ manager.addDelegate(original)
96+ callback()
97+ try require(original.events == [event], "The original delegate must receive the callback once")
98+ try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot")
99+ callback()
100+ try require(replacement.events == [event], "Reentrant registration must take effect for the next callback")
101+ manager.removeDelegate(replacement)
102+ }
103+}
104+
105+private func registrationDuringDelivery() throws {
106+ let manager = NotificationCenterManager.shared
107+ let original = TestDelegate()
108+ let receiver = TestDelegate()
109+ receiver.onResponse = { _ in true }
110+ original.onResponse = { _ in
111+ manager.addDelegate(receiver)
112+ return false
113+ }
114+ manager.addDelegate(original)
115+ deliver("handoff")
116+ try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response")
117+ manager.removeDelegate(original)
118+ manager.removeDelegate(receiver)
119+}
120+
121+private func responseDuringReplay() throws {
122+ let manager = NotificationCenterManager.shared
123+ deliver("first")
124+ let original = TestDelegate()
125+ original.onResponse = { response in
126+ if response.identifier == "first" {
127+ deliver("second")
128+ return true
129+ }
130+ return false
131+ }
132+ manager.addDelegate(original)
133+ let receiver = TestDelegate()
134+ receiver.onResponse = { _ in true }
135+ manager.addDelegate(receiver)
136+ try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks")
137+ manager.removeDelegate(original)
138+ manager.removeDelegate(receiver)
139+}
140+
141+private func concurrentRegistrations() throws {
142+ let manager = NotificationCenterManager.shared
143+ let delegates = (0..<512).map { _ in
144+ let delegate = TestDelegate()
145+ delegate.onResponse = { _ in true }
146+ return delegate
147+ }
148+ DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
149+ manager.addDelegate(delegates[index])
150+ if index.isMultiple(of: 16) {
151+ manager.didRegister("during-add")
152+ deliver("during-add")
153+ }
154+ }
155+ let before = delegates.map { $0.events.count }
156+ manager.didRegister("after-add")
157+ for (index, delegate) in delegates.enumerated() {
158+ try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once")
159+ }
160+ DispatchQueue.concurrentPerform(iterations: delegates.count) { index in
161+ manager.removeDelegate(delegates[index])
162+ if index.isMultiple(of: 16) {
163+ manager.didRegister("during-remove")
164+ deliver("during-remove")
165+ }
166+ }
167+ let removed = delegates.map { $0.events.count }
168+ manager.didRegister("after-remove")
169+ try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks")
170+}
171+
172+@main
173+private enum RegressionTests {
174+ static func main() {
175+ do {
176+ switch CommandLine.arguments.last {
177+ case "reentrant": try reentrantCallbacks()
178+ case "handoff": try registrationDuringDelivery()
179+ case "pending": try responseDuringReplay()
180+ case "concurrent": try concurrentRegistrations()
181+ default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2)
182+ }
183+ print("passed")
184+ } catch {
185+ FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
186+ exit(1)
187+ }
188+ }
189+}
apps/mobile/scripts/notification-center-manager.test.ts+65−0
@@ -0,0 +1,65 @@
1+// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly.
2+import * as NodeChildProcess from "node:child_process";
3+import * as NodeFS from "node:fs";
4+import * as NodeOS from "node:os";
5+import * as NodePath from "node:path";
6+import * as NodeURL from "node:url";
7+import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
8+
9+// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler.
10+describe.skipIf(NodeOS.platform() !== "darwin")(
11+ "NotificationCenterManager native concurrency",
12+ () => {
13+ let directory: string;
14+ let executable: string;
15+
16+ beforeAll(() => {
17+ directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-"));
18+ executable = NodePath.join(directory, "notification-regression");
19+ const source = NodeFS.readFileSync(
20+ new URL(
21+ "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift",
22+ import.meta.url,
23+ ),
24+ "utf8",
25+ );
26+ const manager = NodePath.join(directory, "NotificationCenterManager.swift");
27+ NodeFS.writeFileSync(
28+ manager,
29+ source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""),
30+ );
31+ NodeChildProcess.execFileSync(
32+ "swiftc",
33+ [
34+ "-swift-version",
35+ "5",
36+ "-sanitize=thread",
37+ manager,
38+ NodeURL.fileURLToPath(
39+ new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url),
40+ ),
41+ "-o",
42+ executable,
43+ ],
44+ { timeout: 30_000, encoding: "utf8" },
45+ );
46+ });
47+
48+ afterAll(() => {
49+ if (directory) NodeFS.rmSync(directory, { recursive: true, force: true });
50+ });
51+
52+ it.each([
53+ ["reentrant", "allows callbacks to replace delegates without deadlocking"],
54+ ["handoff", "delivers responses to delegates registering during delivery"],
55+ ["pending", "retains new responses received while replaying pending responses"],
56+ ["concurrent", "registers, removes, and broadcasts concurrently without data races"],
57+ ])("%s: %s", (name) => {
58+ const output = NodeChildProcess.execFileSync(executable, [name], {
59+ encoding: "utf8",
60+ timeout: 15_000,
61+ });
62+ expect(output.trim()).toBe("passed");
63+ });
64+ },
65+);
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(
apps/web/src/components/CommandPalette.tsx+9−1
@@ -1186,7 +1186,14 @@ function OpenCommandPaletteDialog(props: { · 1185 unmodified lines
11861186 projectTitleById,
11871187 sortOrder: clientSettings.sidebarThreadSortOrder,
11881188 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
1189− renderLeadingContent: (thread) => <ThreadRowLeadingStatus thread={thread} />,
1189+ renderLeadingContent: (thread) => (
1190+ <ThreadRowLeadingStatus
1191+ thread={thread}
1192+ snapshot={changeRequestSnapshotByKey.get(
1193+ scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
1194+ )}
1195+ />
1196+ ),
11901197 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
11911198 renderDescription: (thread, { projectTitle }) => {
11921199 const modelInstanceId =
@@ -1238,6 +1245,7 @@ function OpenCommandPaletteDialog(props: { · 45 unmodified lines
12381245 }),
12391246 [
12401247 activeThreadId,
1248+ changeRequestSnapshotByKey,
12411249 clientSettings.sidebarThreadSortOrder,
12421250 navigate,
12431251 projectCwdById,
apps/web/src/components/ThreadStatusIndicators.subscriptions.test.tsx+180−0
@@ -0,0 +1,180 @@
1+import { ProjectId, ThreadId, type VcsStatusResult } from "@t3tools/contracts";
2+import { act, type ComponentProps, type ReactNode } from "react";
3+import { create, type ReactTestRenderer } from "react-test-renderer";
4+import { afterEach, expect, it, vi } from "vite-plus/test";
5+
6+import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
7+import { makeThreadFixture } from "../test-fixtures";
8+
9+const state = vi.hoisted(() => ({ queries: [] as string[] }));
10+
11+vi.mock("../state/entities", () => ({ useProject: () => null }));
12+vi.mock("../uiStateStore", () => ({ useUiStateStore: () => undefined }));
13+vi.mock("../state/vcs", async () => {
14+ const { AsyncResult, Atom } = await import("effect/unstable/reactivity");
15+ const statuses = Atom.family((cwd: string) =>
16+ Atom.make(() => {
17+ state.queries.push(`vcs:${cwd}`);
18+ return AsyncResult.success({
19+ isRepo: true,
20+ hasPrimaryRemote: true,
21+ isDefaultRef: false,
22+ refName: "feature/test",
23+ hasWorkingTreeChanges: false,
24+ workingTree: { files: [], insertions: 0, deletions: 0 },
25+ hasUpstream: true,
26+ aheadCount: 0,
27+ behindCount: 0,
28+ pr: {
29+ number: 42,
30+ title: "Observed worktree PR",
31+ url: "https://github.com/example/repo/pull/42",
32+ baseRef: "main",
33+ headRef: "feature/test",
34+ state: "open",
35+ },
36+ } satisfies VcsStatusResult);
37+ }),
38+ );
39+ return {
40+ vcsEnvironment: { status: ({ input }: { input: { cwd: string } }) => statuses(input.cwd) },
41+ };
42+});
43+vi.mock("../state/pullRequests", async (importOriginal) => {
44+ const original = await importOriginal<typeof import("../state/pullRequests")>();
45+ const { AsyncResult, Atom } = await import("effect/unstable/reactivity");
46+ const { ProjectId } = await import("@t3tools/contracts");
47+ const summaries = Atom.family((number: number) =>
48+ Atom.make(() => {
49+ state.queries.push(`pr:${number}`);
50+ return AsyncResult.success({
51+ provider: "github" as const,
52+ projectId: ProjectId.make("project-test"),
53+ repository: "example/repo",
54+ number,
55+ title: "Observed linked PR",
56+ url: `https://github.com/example/repo/pull/${number}`,
57+ state: "open" as const,
58+ headBranch: "feature/test",
59+ baseBranch: "main",
60+ updatedAt: "2026-09-04T00:00:00.000Z",
61+ });
62+ }),
63+ );
64+ return {
65+ ...original,
66+ linkedPullRequestDetailAtom: ({ input }: { input: { number: number } }) =>
67+ summaries(input.number),
68+ };
69+});
70+vi.mock("./ui/tooltip", async () => {
71+ const { cloneElement, isValidElement } = await import("react");
72+ return {
73+ Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
74+ TooltipTrigger({
75+ render,
76+ children,
77+ }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) {
78+ return isValidElement(render) ? cloneElement(render, undefined, children) : children;
79+ },
80+ TooltipPopup: () => null,
81+ };
82+});
83+
84+import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators";
85+
86+afterEach(() => {
87+ vi.unstubAllGlobals();
88+});
89+
90+it("leases only visible palette results while retaining observed and cached PR badges", async () => {
91+ const observers: Array<{ setVisible: (visible: boolean) => void }> = [];
92+ const scrollRoot = {};
93+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
94+ vi.stubGlobal(
95+ "IntersectionObserver",
96+ class {
97+ constructor(callback: (entries: ReadonlyArray<{ isIntersecting: boolean }>) => void) {
98+ observers.push({ setVisible: (visible) => callback([{ isIntersecting: visible }]) });
99+ }
100+ observe() {}
101+ disconnect() {}
102+ },
103+ );
104+ const threads = Array.from({ length: 128 }, (_, index) =>
105+ makeThreadFixture({
106+ id: ThreadId.make(`palette-thread-${index}`),
107+ branch: "feature/test",
108+ worktreePath: `/test/worktree-${index}`,
109+ linkedPullRequest:
110+ index % 2 === 0
111+ ? null
112+ : {
113+ projectId: ProjectId.make("project-test"),
114+ repository: "example/repo",
115+ number: index,
116+ url: `https://github.com/example/repo/pull/${index}`,
117+ },
118+ }),
119+ );
120+ let renderer: ReactTestRenderer | undefined;
121+ state.queries.length = 0;
122+ try {
123+ await act(async () => {
124+ renderer = create(
125+ <AppAtomRegistryProvider>
126+ {threads.map((thread, index) => (
127+ <span key={thread.id}>
128+ <ThreadRowLeadingStatus
129+ thread={thread}
130+ snapshot={
131+ index === 126
132+ ? {
133+ branch: "feature/test",
134+ sourceControlProvider: undefined,
135+ pr: {
136+ number: 126,
137+ title: "Cached badge",
138+ url: "https://github.com/example/repo/pull/126",
139+ baseRef: "main",
140+ headRef: "feature/test",
141+ state: "open",
142+ },
143+ }
144+ : undefined
145+ }
146+ />
147+ {thread.title}
148+ </span>
149+ ))}
150+ </AppAtomRegistryProvider>,
151+ { createNodeMock: () => ({ parentElement: { closest: () => scrollRoot } }) },
152+ );
153+ });
154+ const mounted = renderer!;
155+ const badgeCount = () =>
156+ mounted.root.findAll(
157+ (node) => node.type === "span" && String(node.props["aria-label"] ?? "").startsWith("PR #"),
158+ ).length;
159+ expect(observers).toHaveLength(128);
160+ expect(state.queries).toEqual([]);
161+ expect(badgeCount()).toBe(1);
162+
163+ await act(async () => {
164+ for (const observer of observers.slice(0, 8)) observer.setVisible(true);
165+ });
166+ expect(state.queries).toHaveLength(8);
167+ expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4);
168+ expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4);
169+ expect(badgeCount()).toBe(9);
170+
171+ await act(async () => {
172+ for (const observer of observers.slice(0, 8)) observer.setVisible(false);
173+ for (const observer of observers.slice(8, 16)) observer.setVisible(true);
174+ });
175+ expect(state.queries).toHaveLength(16);
176+ expect(badgeCount()).toBe(17);
177+ } finally {
178+ await act(async () => renderer?.unmount());
179+ }
180+});
apps/web/src/components/ThreadStatusIndicators.test.ts+19−0
@@ -554,6 +554,25 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { · 553 unmodified lines
554554 ).toEqual(mergedPr);
555555 });
556556
557+ it("shows the last matching PR snapshot while its live status lease is released", () => {
558+ const openPr = { ...mergedPr, state: "open" as const };
559+ const input = {
560+ threadBranch: featureBranch,
561+ gitStatus: null,
562+ snapshot: snapshotFor(featureBranch, openPr, provider),
563+ retainTerminalOnBranchMismatch: false,
564+ };
565+ expect(resolveDisplayedThreadPr(input)).toEqual(openPr);
566+ expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider);
567+ expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull();
568+ expect(
569+ resolveDisplayedThreadPr({
570+ ...input,
571+ snapshot: { ...input.snapshot, linkedPullRequest },
572+ }),
573+ ).toBeNull();
574+ });
575+
557576 it("retains a merged PR after a main checkout", () => {
558577 const matchingStatus = status({
559578 refName: featureBranch,
apps/web/src/components/ThreadStatusIndicators.tsx+59−17
@@ -12,7 +12,7 @@ import { · 11 unmodified lines
1212 } from "@t3tools/contracts";
1313 import { Atom } from "effect/unstable/reactivity";
1414 import { FolderGit2Icon, TerminalIcon } from "lucide-react";
15−import { useMemo } from "react";
15+import { useCallback, useMemo } from "react";
1616 import { appAtomRegistry } from "../rpc/atomRegistry";
1717 import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
1818 import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
@@ -27,6 +27,8 @@ import { · 8 unmodified lines
2727 resolveThreadLastVisitedAt,
2828 resolveThreadStatusPill,
2929 type ThreadStatusPill,
30+ useRetainedValue,
31+ useSidebarRowSubscriptionLease,
3032 } from "./Sidebar.logic";
3133 import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
3234 import type { SidebarThreadSummary } from "../types";
@@ -58,9 +60,10 @@ export interface LinkedThreadPullRequestStatus { · 25 unmodified lines
5860 export function useLinkedThreadPullRequest(
5961 environmentId: EnvironmentId | null,
6062 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
63+ enabled = true,
6164 ): LinkedThreadPullRequestStatus | null {
6265 const queried = useEnvironmentQuery(
63− environmentId === null || linkedPullRequest == null
66+ !enabled || environmentId === null || linkedPullRequest == null
6467 ? null
6568 : linkedPullRequestDetailAtom({
6669 environmentId,
@@ -353,7 +356,7 @@ export function nextThreadChangeRequestSnapshot(input: { · 286 unmodified lines
353356 * checkouts only, a cached merged/closed PR for the thread. Local thread
354357 * metadata follows the shared checkout, so the cached branch intentionally
355358 * survives that metadata changing to the newly checked-out branch. Open PRs
356− * are never retained — their state can still change.
359+ * are retained only while live status is absent and the branch still matches.
357360 */
358361 export function resolveDisplayedThreadPr(input: {
359362 threadBranch: string | null;
@@ -388,6 +391,15 @@ export function resolveDisplayedThreadPr(input: { · 28 unmodified lines
388391 return gitStatus.pr;
389392 }
390393
394+ if (
395+ gitStatus === null &&
396+ threadBranch !== null &&
397+ snapshot?.branch === threadBranch &&
398+ snapshot.linkedPullRequest === undefined
399+ ) {
400+ return snapshot.pr;
401+ }
402+
391403 if (
392404 threadBranch !== null &&
393405 retainTerminalOnBranchMismatch &&
@@ -434,6 +446,15 @@ export function resolveDisplayedThreadPrProvider(input: { · 40 unmodified lines
434446 return gitStatus.sourceControlProvider;
435447 }
436448
449+ if (
450+ gitStatus === null &&
451+ threadBranch !== null &&
452+ snapshot?.branch === threadBranch &&
453+ snapshot.linkedPullRequest === undefined
454+ ) {
455+ return snapshot.sourceControlProvider;
456+ }
457+
437458 if (
438459 threadBranch !== null &&
439460 retainTerminalOnBranchMismatch &&
@@ -550,7 +571,19 @@ export function ThreadStatusLabel({ · 110 unmodified lines
550571 * like the command palette. Shows the change request state icon (if present) and the
551572 * thread status dot, matching the sidebar's leading indicators.
552573 */
553−export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) {
574+export function ThreadRowLeadingStatus({
575+ thread,
576+ snapshot,
577+}: {
578+ thread: SidebarThreadSummary;
579+ snapshot?: ThreadChangeRequestSnapshot | undefined;
580+}) {
581+ const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false);
582+ // Observe the containing title even when this thread has no badge yet.
583+ const statusRef = useCallback(
584+ (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null),
585+ [rowRef],
586+ );
554587 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
555588 const localLastVisitedAt = useUiStateStore(
556589 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
@@ -567,9 +600,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 10 unmodified lines
567600 const linkedPullRequest = useLinkedThreadPullRequest(
568601 thread.environmentId,
569602 thread.linkedPullRequest,
603+ leaseLiveStatus,
570604 );
571605 const gitStatus = useEnvironmentQuery(
572− thread.linkedPullRequest == null &&
606+ leaseLiveStatus &&
607+ thread.linkedPullRequest == null &&
573608 (thread.branch != null || thread.worktreePath !== null) &&
574609 gitCwd !== null
575610 ? vcsEnvironment.status({
@@ -578,14 +613,20 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 2 unmodified lines
578613 })
579614 : null,
580615 );
581− const pr =
582− thread.linkedPullRequest == null
583− ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data })
584− : (linkedPullRequest?.pr ?? null);
585− const prStatus = prStatusIndicator(
586− pr,
587− linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
616+ const visibleGitStatus = useRetainedValue(
617+ JSON.stringify([thread.environmentId, gitCwd]),
618+ gitStatus.data,
588619 );
620+ const displayedPrInput = {
621+ threadBranch: thread.branch,
622+ gitStatus: visibleGitStatus,
623+ snapshot,
624+ retainTerminalOnBranchMismatch: thread.worktreePath === null,
625+ linkedPullRequest: thread.linkedPullRequest,
626+ linkedPullRequestStatus: linkedPullRequest,
627+ };
628+ const pr = resolveDisplayedThreadPr(displayedPrInput);
629+ const prStatus = prStatusIndicator(pr, resolveDisplayedThreadPrProvider(displayedPrInput));
589630 const threadStatus = resolveThreadStatusPill({
590631 thread: {
591632 ...thread,
@@ -593,12 +634,13 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 1 unmodified line
593634 },
594635 });
595636
596− if (!prStatus && !threadStatus) {
597− return null;
598− }
599−
600637 return (
601− <span className="inline-flex shrink-0 items-center gap-1.5">
638+ <span
639+ ref={statusRef}
640+ className={
641+ prStatus || threadStatus ? "inline-flex shrink-0 items-center gap-1.5" : "contents"
642+ }
643+ >
602644 {prStatus && pr ? (
603645 <Tooltip>
604646 <TooltipTrigger
apps/web/src/connection/desktopLocal.test.ts+62−1
@@ -2,7 +2,11 @@ import { · 1 unmodified line
22 BearerConnectionTarget,
33 PrimaryConnectionTarget,
44 } from "@t3tools/client-runtime/connection";
5−import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
5+import {
6+ type DesktopEnvironmentBootstrap,
7+ EnvironmentId,
8+ PRIMARY_LOCAL_ENVIRONMENT_ID,
9+} from "@t3tools/contracts";
610 import { describe, expect, it } from "vite-plus/test";
711
812 import {
@@ -38,6 +42,63 @@ describe("desktop local connection identity", () => { · 29 unmodified lines
3842 });
3943
4044 describe("desktop local topology reads", () => {
45+ it("reuses snapshots when polling returns fresh objects for the same topology", () => {
46+ const secondary: DesktopEnvironmentBootstrap = {
47+ id: "wsl:default",
48+ label: "WSL",
49+ runningDistro: "Ubuntu",
50+ httpBaseUrl: "http://127.0.0.1:4000",
51+ wsBaseUrl: "ws://127.0.0.1:4000",
52+ bootstrapToken: "bootstrap-1",
53+ };
54+ let entries = [secondary];
55+ const reader = createDesktopSecondaryBootstrapsReader(() => ({
56+ getLocalEnvironmentBootstraps: () => entries.map((entry) => ({ ...entry })),
57+ }));
58+
59+ const connected = reader.readSnapshot();
60+ expect(reader.readSnapshot()).toBe(connected);
61+ expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: connected });
62+ expect(reader.readSnapshot()).toBe(connected);
63+
64+ entries = [];
65+ const empty = reader.readSnapshot();
66+ expect(empty).toEqual([]);
67+ expect(reader.readSnapshot()).toBe(empty);
68+ });
69+
70+ it.each<Partial<DesktopEnvironmentBootstrap>>([
71+ { id: "wsl:Debian" },
72+ { label: "Renamed backend" },
73+ { runningDistro: "Debian" },
74+ { httpBaseUrl: "http://127.0.0.1:4001" },
75+ { wsBaseUrl: "ws://127.0.0.1:4001" },
76+ { bootstrapToken: "bootstrap-2" },
77+ ])("publishes bootstrap changes: %j", (change) => {
78+ let secondary: DesktopEnvironmentBootstrap = {
79+ id: "wsl:default",
80+ label: "WSL",
81+ runningDistro: "Ubuntu",
82+ httpBaseUrl: "http://127.0.0.1:4000",
83+ wsBaseUrl: "ws://127.0.0.1:4000",
84+ bootstrapToken: "bootstrap-1",
85+ };
86+ const reader = createDesktopSecondaryBootstrapsReader(() => ({
87+ getLocalEnvironmentBootstraps: () => [{ ...secondary }],
88+ }));
89+ const before = reader.readSnapshot();
90+ secondary = { ...secondary, ...change };
91+ const after = reader.readSnapshot();
92+ expect(after).not.toBe(before);
93+ expect(after).toEqual([secondary]);
94+ expect(reader.readSnapshot()).toBe(after);
95+ });
96+
97+ it("keeps the empty snapshot stable without a desktop bridge", () => {
98+ const reader = createDesktopSecondaryBootstrapsReader(() => undefined);
99+ expect(reader.readSnapshot()).toBe(reader.readSnapshot());
100+ });
101+
41102 it("distinguishes a successful empty topology from a read failure", () => {
42103 let readBootstraps = () => [];
43104 const reader = createDesktopSecondaryBootstrapsReader(() => ({
apps/web/src/connection/desktopLocal.ts+19−2
@@ -67,13 +67,30 @@ export function createDesktopSecondaryBootstrapsReader( · 66 unmodified lines
6767 const readResult = (): DesktopSecondaryBootstrapsRead => {
6868 const bridge = resolveBridge();
6969 if (bridge === undefined) {
70− snapshot = [];
70+ if (snapshot.length > 0) snapshot = [];
7171 return { _tag: "Success", bootstraps: snapshot };
7272 }
7373 try {
74− snapshot = bridge
74+ const next = bridge
7575 .getLocalEnvironmentBootstraps()
7676 .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID);
77+ if (
78+ next.length !== snapshot.length ||
79+ next.some((entry, index) => {
80+ const previous = snapshot[index];
81+ return (
82+ previous === undefined ||
83+ entry.id !== previous.id ||
84+ entry.label !== previous.label ||
85+ entry.runningDistro !== previous.runningDistro ||
86+ entry.httpBaseUrl !== previous.httpBaseUrl ||
87+ entry.wsBaseUrl !== previous.wsBaseUrl ||
88+ entry.bootstrapToken !== previous.bootstrapToken
89+ );
90+ })
91+ ) {
92+ snapshot = next;
93+ }
7794 return { _tag: "Success", bootstraps: snapshot };
7895 } catch (cause) {
7996 return { _tag: "Failure", cause };

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