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 @@
1import 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.
5public enum UIBackgroundFetchResult {
6 case noData
7}
8
9public struct UNNotificationPresentationOptions: OptionSet {
10 public let rawValue: Int
11 public init(rawValue: Int) { self.rawValue = rawValue }
12}
13
14public final class UNNotification: NSObject {}
15
16public final class UNNotificationResponse: NSObject {
17 let identifier: String
18 init(_ identifier: String) { self.identifier = identifier }
19}
20
21public protocol UNUserNotificationCenterDelegate: AnyObject {}
22
23public 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
29private 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
62private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
63 if !condition() {
64 throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
65 }
66}
67
68private func deliver(_ identifier: String) {
69 NotificationCenterManager.shared.userNotificationCenter(
70 UNUserNotificationCenter.current(),
71 didReceive: UNNotificationResponse(identifier),
72 withCompletionHandler: {}
73 )
74}
75
76private 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
105private 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
121private 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
141private 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
173private 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.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import * as NodeURL from "node:url";
7import { 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.
10describe.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
923 } satisfies GitVcsDriver.GitStatusDetails;923 } satisfies GitVcsDriver.GitStatusDetails;
924 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {924 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
925 const details = yield* gitCore925 const details = yield* gitCore
926 .statusDetailsLocal(cwd)926 .statusDetailsLocal(cwd, { includeDivergence: false })
927 .pipe(927 .pipe(
928 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),928 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
929 );929 );
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+4−1
@@ -1083,7 +1083,10 @@ function codexReplayPreamble(input: { · 1082 unmodified lines
1083 method: "initialize",1083 method: "initialize",
1084 params: {1084 params: {
1085 clientInfo: { name: "t3code_desktop", title: "T3 Code Desktop", version: "0.1.0" },1085 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 },
1087 },1090 },
1088 },1091 },
1089 },1092 },
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+1−0
@@ -173,6 +173,7 @@ const CODEX_CLIENT_INFO = { · 172 unmodified lines
173} as const;173} as const;
174const CODEX_CLIENT_CAPABILITIES = {174const CODEX_CLIENT_CAPABILITIES = {
175 experimentalApi: true,175 experimentalApi: true,
176 optOutNotificationMethods: ["turn/diff/updated"],
176} as const;177} as const;
177 178
178export const CodexProviderCapabilitiesV2 = {179export 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
1386 });1386 });
1387 });1387 });
1388 1388
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
1389 const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* (1432 const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* (
1390 command: Extract<1433 command: Extract<
1391 OrchestrationV2Command,1434 OrchestrationV2Command,
@@ -1401,7 +1444,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 9 unmodified lines
1401 | "thread.pin"1444 | "thread.pin"
1402 | "thread.unpin"1445 | "thread.unpin"
1403 | "thread.pin.reorder"1446 | "thread.pin.reorder"
1404 | "thread.visit"
1405 | "thread.mark-unread"1447 | "thread.mark-unread"
1406 | "thread.metadata.update"1448 | "thread.metadata.update"
1407 | "thread.title.regeneration.complete"1449 | "thread.title.regeneration.complete"
@@ -1548,18 +1590,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 140 unmodified lines
1548 }1590 }
1549 snoozedUntil = parsedSnoozedUntil.value;1591 snoozedUntil = parsedSnoozedUntil.value;
1550 }1592 }
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 }
1563 let markUnreadVisitedAt: DateTime.Utc | null = null;1593 let markUnreadVisitedAt: DateTime.Utc | null = null;
1564 if (command.type === "thread.mark-unread") {1594 if (command.type === "thread.mark-unread") {
1565 const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null;1595 const latestRunCompletedAt = projection.runs.at(-1)?.completedAt ?? null;
@@ -1678,17 +1708,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 112 unmodified lines
1678 updatedAt: keyUnchanged ? thread.updatedAt : now,1708 updatedAt: keyUnchanged ? thread.updatedAt : now,
1679 };1709 };
1680 }1710 }
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 }
1692 case "thread.mark-unread":1711 case "thread.mark-unread":
1693 return { ...thread, lastVisitedAt: markUnreadVisitedAt };1712 return { ...thread, lastVisitedAt: markUnreadVisitedAt };
1694 case "thread.metadata.update":1713 case "thread.metadata.update":
@@ -1751,8 +1770,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 56 unmodified lines
1751 return "thread.unpinned" as const;1770 return "thread.unpinned" as const;
1752 case "thread.pin.reorder":1771 case "thread.pin.reorder":
1753 return "thread.pin-reordered" as const;1772 return "thread.pin-reordered" as const;
1754 case "thread.visit":
1755 return "thread.visited" as const;
1756 case "thread.mark-unread":1773 case "thread.mark-unread":
1757 return "thread.marked-unread" as const;1774 return "thread.marked-unread" as const;
1758 case "thread.metadata.update":1775 case "thread.metadata.update":
@@ -6921,16 +6938,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 5162 unmodified lines
6921 case "thread.create":6938 case "thread.create":
6922 yield* dispatchThreadCreate(command, events);6939 yield* dispatchThreadCreate(command, events);
6923 break;6940 break;
6941 case "thread.visit":
6942 yield* dispatchThreadVisit(command, events);
6943 break;
6924 case "thread.auto-settle": {6944 case "thread.auto-settle": {
6925 // Automatic settlement (#8600): the sweep evaluated a shell snapshot,6945 // Automatic settlement (#8600): the sweep evaluated a shell snapshot,
6926 // so re-check against the live thread before settling. Any change6946 // so re-check against the live thread before settling. Any change
6927 // after the snapshot — or any explicit override, including the6947 // after the snapshot — or any explicit override, including the
6928 // un-settle button's "active" — wins over the sweep.6948 // 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 );
6930 if (6956 if (
6931 projection.thread.settledOverride !== null ||6957 thread.settledOverride !== null ||
6932 DateTime.toEpochMillis(projection.thread.updatedAt) >6958 DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt)
6933 DateTime.toEpochMillis(command.snapshotAt)
6934 ) {6959 ) {
6935 return yield* new OrchestratorDispatchError({6960 return yield* new OrchestratorDispatchError({
6936 commandId: command.commandId,6961 commandId: command.commandId,
@@ -6960,7 +6985,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 23 unmodified lines
6960 case "thread.pin":6985 case "thread.pin":
6961 case "thread.unpin":6986 case "thread.unpin":
6962 case "thread.pin.reorder":6987 case "thread.pin.reorder":
6963 case "thread.visit":
6964 case "thread.mark-unread":6988 case "thread.mark-unread":
6965 case "thread.metadata.update":6989 case "thread.metadata.update":
6966 case "thread.title.regeneration.complete":6990 case "thread.title.regeneration.complete":
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+363−0
@@ -0,0 +1,363 @@
1import { assert, it } from "@effect/vitest";
2import {
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";
17import * as DateTime from "effect/DateTime";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Schema from "effect/Schema";
21import * as SqlClient from "effect/unstable/sql/SqlClient";
22import * as Statement from "effect/unstable/sql/Statement";
23
24import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
25import {
26 ProjectionStoreV2,
27 layer as projectionStoreLayer,
28 layerMemory,
29} from "./ProjectionStore.ts";
30import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts";
31
32const SqlLayer = projectionStoreLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory));
33const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z");
34const old = DateTime.subtract(now, { days: 10 });
35const providerInstanceId = ProviderInstanceId.make("codex");
36const modelSelection = { instanceId: providerInstanceId, model: "test-model" };
37const encodeNumbers = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Number)));
38
39const 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
79const 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
112const 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
147for (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
300it.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 @@
1import type {1import type {
2 OrchestrationV2AppThread,
2 OrchestrationV2ConversationMessage,3 OrchestrationV2ConversationMessage,
3 OrchestrationV2DomainEvent,4 OrchestrationV2DomainEvent,
4 OrchestrationV2ProjectedTurnItem,5 OrchestrationV2ProjectedTurnItem,
@@ -106,6 +107,32 @@ export type ProjectionRecoveryKind = · 101 unmodified lines
106 | "subagent-results"107 | "subagent-results"
107 | "delegated-completions";108 | "delegated-completions";
108 109
110/** Thread activity needed by settlement, without transcript or fork history. */
111export 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
109const ProjectionCheckpointContext = Schema.Struct({136const ProjectionCheckpointContext = Schema.Struct({
110 runs: Schema.Array(137 runs: Schema.Array(
111 OrchestrationV2RunJsonSchema.mapFields(({ id, ordinal, status }) => ({ id, ordinal, status })),138 OrchestrationV2RunJsonSchema.mapFields(({ id, ordinal, status }) => ({ id, ordinal, status })),
@@ -143,6 +170,13 @@ export interface ProjectionStoreV2Shape { · 31 unmodified lines
143 readonly getThreadShell: (170 readonly getThreadShell: (
144 threadId: ThreadId,171 threadId: ThreadId,
145 ) => Effect.Effect<OrchestrationV2ThreadShell | null, ProjectionStoreV2Error>;172 ) => 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 >;
146 readonly getThreadProjection: (180 readonly getThreadProjection: (
147 threadId: ThreadId,181 threadId: ThreadId,
148 ) => Effect.Effect<OrchestrationV2ThreadProjection, ProjectionStoreV2Error>;182 ) => Effect.Effect<OrchestrationV2ThreadProjection, ProjectionStoreV2Error>;
@@ -585,6 +619,18 @@ type ShellRunRow = { · 436 unmodified lines
585 readonly ordinal: number;619 readonly ordinal: number;
586};620};
587 621
622type 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
588type ShellRunItemCountRow = {634type ShellRunItemCountRow = {
589 readonly thread_id: string;635 readonly thread_id: string;
590 readonly run_id: string;636 readonly run_id: string;
@@ -2954,6 +3000,24 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 2363 unmodified lines
2954 const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) =>3000 const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) =>
2955 readProjection(threadId, new Set());3001 readProjection(threadId, new Set());
2956 3002
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
2957 const getCheckpointContext: ProjectionStoreV2Shape["getCheckpointContext"] = (threadId) =>3021 const getCheckpointContext: ProjectionStoreV2Shape["getCheckpointContext"] = (threadId) =>
2958 sql3022 sql
2959 .withTransaction(3023 .withTransaction(
@@ -3320,6 +3384,104 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 360 unmodified lines
3320 return { providerThreadsByThreadId, pendingTurnItemsByThreadId };3384 return { providerThreadsByThreadId, pendingTurnItemsByThreadId };
3321 });3385 });
3322 3386
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
3323 const shellThreadStateFromRow = (input: {3485 const shellThreadStateFromRow = (input: {
3324 readonly row: ShellThreadRow;3486 readonly row: ShellThreadRow;
3325 readonly runOrdinalsByThreadId: ReadonlyMap<ThreadId, Map<RunId, number>>;3487 readonly runOrdinalsByThreadId: ReadonlyMap<ThreadId, Map<RunId, number>>;
@@ -3566,6 +3728,8 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 240 unmodified lines
3566 apply,3728 apply,
3567 getShellSnapshot,3729 getShellSnapshot,
3568 getThreadShell,3730 getThreadShell,
3731 getThread,
3732 getSettlementCandidates,
3569 getThreadProjection,3733 getThreadProjection,
3570 getCheckpointContext,3734 getCheckpointContext,
3571 getRecoveryThreadIds,3735 getRecoveryThreadIds,
@@ -3638,6 +3802,34 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( · 66 unmodified lines
3638 .pipe(Effect.map(threadShellFromProjection));3802 .pipe(Effect.map(threadShellFromProjection));
3639 return shell.deletedAt === null ? shell : null;3803 return shell.deletedAt === null ? shell : null;
3640 }),3804 }),
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 }),
3641 getRecoveryThreadIds: (kind) =>3833 getRecoveryThreadIds: (kind) =>
3642 Effect.gen(function* () {3834 Effect.gen(function* () {
3643 const projections = (yield* Ref.get(replayState)).projections;3835 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
216 apply: () => Effect.void,216 apply: () => Effect.void,
217 getShellSnapshot: () => Effect.die("unused getShellSnapshot"),217 getShellSnapshot: () => Effect.die("unused getShellSnapshot"),
218 getThreadShell: () => Effect.die("unused getThreadShell"),218 getThreadShell: () => Effect.die("unused getThreadShell"),
219 getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)),
220 getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"),
219 getThreadProjection: () => Ref.get(projection),221 getThreadProjection: () => Ref.get(projection),
220 getCheckpointContext: () => Effect.die("not used"),222 getCheckpointContext: () => Effect.die("not used"),
221 getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"),223 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
34import { ServerSettingsService } from "../serverSettings.ts";34import { ServerSettingsService } from "../serverSettings.ts";
35import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";35import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
36import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts";36import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts";
37import { ProjectionStoreV2 } from "./ProjectionStore.ts";
37import * as ThreadSettlementService from "./ThreadSettlementService.ts";38import * as ThreadSettlementService from "./ThreadSettlementService.ts";
38 39
39import {40import {
@@ -417,12 +418,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 377 unmodified lines
417 getProjectShellsWithoutEnrichment: () =>418 getProjectShellsWithoutEnrichment: () =>
418 Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),419 Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),
419 }),420 }),
420 Layer.mock(OrchestratorV2)({421 Layer.mock(ProjectionStoreV2)({
421 getShellSnapshot: () =>422 getSettlementCandidates: () =>
422 Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(423 Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe(
423 Effect.tap((count) => Queue.offer(snapshotReads, count)),424 Effect.tap((count) => Queue.offer(snapshotReads, count)),
424 Effect.andThen(Ref.get(snapshots)),425 Effect.andThen(Ref.get(snapshots)),
426 Effect.map((snapshot) => snapshot.threads),
425 ),427 ),
428 }),
429 Layer.mock(OrchestratorV2)({
426 dispatch,430 dispatch,
427 }),431 }),
428 Layer.mock(GitManager)({432 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
17import { forkParked } from "../serverActivation.ts";17import { forkParked } from "../serverActivation.ts";
18import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";18import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
19import { OrchestratorV2 } from "./Orchestrator.ts";19import { OrchestratorV2 } from "./Orchestrator.ts";
20import { ProjectionStoreV2, type ProjectionSettlementCandidate } from "./ProjectionStore.ts";
20 21
21export interface SettlementPullRequest {22export interface SettlementPullRequest {
22 readonly state: "open" | "closed" | "merged";23 readonly state: "open" | "closed" | "merged";
@@ -94,7 +95,7 @@ function pullRequestSettles( · 71 unmodified lines
94 95
95/** Cheap checks that run before any source control lookup. */96/** Cheap checks that run before any source control lookup. */
96export function isAutoSettlementCandidate(97export function isAutoSettlementCandidate(
97 thread: OrchestrationV2ThreadShell,98 thread: ProjectionSettlementCandidate,
98 nowMs: number,99 nowMs: number,
99): boolean {100): boolean {
100 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;101 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
@@ -118,7 +119,7 @@ export function isAutoSettlementCandidate( · 17 unmodified lines
118}119}
119 120
120export function resolveAutoSettlementAt(input: {121export function resolveAutoSettlementAt(input: {
121 readonly thread: OrchestrationV2ThreadShell;122 readonly thread: ProjectionSettlementCandidate;
122 readonly pullRequest: SettlementPullRequest | null;123 readonly pullRequest: SettlementPullRequest | null;
123 readonly nowMs: number;124 readonly nowMs: number;
124 readonly autoSettleAfterDays: number | null;125 readonly autoSettleAfterDays: number | null;
@@ -151,6 +152,7 @@ export class ThreadSettlementServiceV2 extends Context.Service< · 26 unmodified lines
151 152
152export const make = Effect.gen(function* () {153export const make = Effect.gen(function* () {
153 const orchestrator = yield* OrchestratorV2;154 const orchestrator = yield* OrchestratorV2;
155 const projections = yield* ProjectionStoreV2;
154 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;156 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
155 const settingsService = yield* ServerSettings.ServerSettingsService;157 const settingsService = yield* ServerSettings.ServerSettingsService;
156 const git = yield* GitManager.GitManager;158 const git = yield* GitManager.GitManager;
@@ -161,7 +163,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines
161 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (163 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
162 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,164 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
163 ) {165 ) {
164 const snapshot = yield* orchestrator.getShellSnapshot();166 const threads = yield* projections.getSettlementCandidates();
165 const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment();167 const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment();
166 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);168 const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
167 const projects = new Map(projectShells.map((project) => [project.id, project]));169 const projects = new Map(projectShells.map((project) => [project.id, project]));
@@ -169,9 +171,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
169 // the merged pull request: most threads carry no link and settle from171 // the merged pull request: most threads carry no link and settle from
170 // their branch lookup, which would otherwise wait for the next minute's172 // their branch lookup, which would otherwise wait for the next minute's
171 // sweep on a possibly stale cached answer.173 // sweep on a possibly stale cached answer.
172 const candidates = snapshot.threads.filter((thread) =>174 const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs));
173 isAutoSettlementCandidate(thread, nowMs),
174 );
175 // Use the same cwd as the sidebar so both paths share GitManager's PR cache.175 // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
176 const lookupCwdByThreadId = new Map<string, string>();176 const lookupCwdByThreadId = new Map<string, string>();
177 yield* Effect.forEach(177 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
1634 })1634 })
1635 .pipe(Effect.flip);1635 .pipe(Effect.flip);
1636 assert.instanceOf(markUnread, OrchestratorDispatchError);1636 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);
1637 }),1669 }),
1638 );1670 );
1639});1671});
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson+1−1
@@ -1,5 +1,5 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}
@@ -33,7 +33,7 @@ · 27 unmodified lines
33{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","status":{"type":"idle"}}}}33{"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","status":{"type":"idle"}}}}
34{"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}}}}34{"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}}}}
35{"type":"runtime_exit","status":"success"}35{"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"}}}}
37{"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"}}}37{"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"}}}
38{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}38{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
39{"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","excludeTurns":true}}}39{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}3{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.137.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}3{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}3{"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.141.0","codexHome":"/tmp/codex-home","platformFamily":"unix","platformOs":"macos"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}}3{"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}}
4{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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 @@
1{"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."}}1{"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"}}}}
3{"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"}}}3{"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"}}}
4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}4{"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}}
5{"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}}5{"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
70import Migration0056 from "./Migrations/056_LegacyV1ImportState.ts";70import Migration0056 from "./Migrations/056_LegacyV1ImportState.ts";
71import Migration0057 from "./Migrations/057_ApplicationEventSequenceIndexes.ts";71import Migration0057 from "./Migrations/057_ApplicationEventSequenceIndexes.ts";
72import Migration0058 from "./Migrations/058_OrchestrationV2RecoveryIndexes.ts";72import Migration0058 from "./Migrations/058_OrchestrationV2RecoveryIndexes.ts";
73import Migration0059 from "./Migrations/059_OrchestrationV2ShellIndexes.ts";
73 74
74/**75/**
75 * Migration loader with all migrations defined inline.76 * Migration loader with all migrations defined inline.
@@ -140,6 +141,7 @@ export const migrationEntries = [ · 64 unmodified lines
140 [56, "LegacyV1ImportState", Migration0056],141 [56, "LegacyV1ImportState", Migration0056],
141 [57, "ApplicationEventSequenceIndexes", Migration0057],142 [57, "ApplicationEventSequenceIndexes", Migration0057],
142 [58, "OrchestrationV2RecoveryIndexes", Migration0058],143 [58, "OrchestrationV2RecoveryIndexes", Migration0058],
144 [59, "OrchestrationV2ShellIndexes", Migration0059],
143] as const;145] as const;
144 146
145export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);147export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/059_OrchestrationV2ShellIndexes.ts+24−0
@@ -0,0 +1,24 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export 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
319 { method: "thread/realtime/transcript/delta", payload: circularDelta },319 { method: "thread/realtime/transcript/delta", payload: circularDelta },
320 threadId,320 threadId,
321 );321 );
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 );
322 yield* native.write(338 yield* native.write(
323 {339 {
324 event: {340 event: {
apps/server/src/provider/Layers/EventNdjsonLogger.ts+1−0
@@ -57,6 +57,7 @@ const transientNativeMethods = new Set([ · 56 unmodified lines
57 "item/reasoning/textDelta",57 "item/reasoning/textDelta",
58 "thread/realtime/outputAudio/delta",58 "thread/realtime/outputAudio/delta",
59 "thread/realtime/transcript/delta",59 "thread/realtime/transcript/delta",
60 "turn/diff/updated",
60]);61]);
61const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);62const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]);
62 63
apps/server/src/relay/AgentAwarenessRelay.test.ts+573−0
@@ -0,0 +1,573 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import {
4 EnvironmentId,
5 type OrchestrationV2ThreadShell,
6 ProjectId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10import { RelayAgentActivityState } from "@t3tools/contracts/relay";
11import * as DateTime from "effect/DateTime";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Fiber from "effect/Fiber";
15import * as Option from "effect/Option";
16import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import * as TestClock from "effect/testing/TestClock";
20import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
21
22import { SecretStoreReadError, ServerSecretStore } from "../auth/ServerSecretStore.ts";
23import {
24 PUBLISH_AGENT_ACTIVITY_SECRET,
25 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
26 RELAY_ISSUER_SECRET,
27 RELAY_URL_SECRET,
28} from "../cloud/config.ts";
29import { ServerEnvironment } from "../environment/ServerEnvironment.ts";
30import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts";
31import { ProjectService } from "../project/ProjectService.ts";
32import {
33 make,
34 makeAgentAwarenessPublishWorker,
35 shouldPublishAgentAwarenessEvent,
36} from "./AgentAwarenessRelay.ts";
37
38const THREAD_ID = ThreadId.make("relay-thread");
39const SECOND_THREAD_ID = ThreadId.make("relay-thread-2");
40const PROJECT_ID = ProjectId.make("relay-project");
41const NOW = "2026-09-04T12:00:00.000Z";
42
43function 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
88const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) });
89const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload));
90const unused = () => Effect.die("Unexpected test dependency call");
91
92const 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
205describe("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
23import * as Crypto from "effect/Crypto";23import * as Crypto from "effect/Crypto";
24import * as DateTime from "effect/DateTime";24import * as DateTime from "effect/DateTime";
25import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
26import * as Fiber from "effect/Fiber";
26import * as Layer from "effect/Layer";27import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";28import * as Option from "effect/Option";
28import * as Ref from "effect/Ref";29import * as Ref from "effect/Ref";
@@ -51,6 +52,7 @@ export class AgentAwarenessRelay extends Context.Service< · 22 unmodified lines
51 AgentAwarenessRelay,52 AgentAwarenessRelay,
52 {53 {
53 readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>;54 readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>;
55 readonly drain: Effect.Effect<void>;
54 readonly start: () => Effect.Effect<void, never, Scope.Scope>;56 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
55 }57 }
56>()("t3/relay/AgentAwarenessRelay") {}58>()("t3/relay/AgentAwarenessRelay") {}
@@ -59,13 +61,74 @@ export function eventThreadId(event: OrchestrationV2DomainEvent): ThreadId { · 2 unmodified lines
59 return event.threadId;61 return event.threadId;
60}62}
61 63
62export function shouldPublishAgentAwarenessEvent(_event: OrchestrationV2DomainEvent): boolean {64export function shouldPublishAgentAwarenessEvent(
63 // Publishing is identity-deduplicated below. Watching every V2 event keeps65 event: Pick<OrchestrationV2DomainEvent, "type">,
64 // this consumer correct as the shell projection evolves without duplicating66): boolean {
65 // projection-specific relevance rules here.67 // projectThreadAwarenessV2 reads thread metadata, run status, and pending requests.
66 return true;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 }
67}112}
68 113
114export 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
69export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string {132export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string {
70 if (state === null) {133 if (state === null) {
71 return "null";134 return "null";
@@ -90,6 +153,7 @@ export function resolveAgentActivityPublishingStartupState(input: { · 18 unmodified lines
90 153
91const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160;154const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160;
92const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed.";155const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed.";
156const RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
93 157
94export function sanitizeRelayAgentActivityState(158export function sanitizeRelayAgentActivityState(
95 state: RelayAgentActivityState | null,159 state: RelayAgentActivityState | null,
@@ -268,6 +332,7 @@ export const make = Effect.gen(function* () { · 172 unmodified lines
268 const threads = yield* ThreadManagement.ThreadManagementService;332 const threads = yield* ThreadManagement.ThreadManagementService;
269 const projects = yield* ProjectService.ProjectService;333 const projects = yield* ProjectService.ProjectService;
270 const crypto = yield* Crypto.Crypto;334 const crypto = yield* Crypto.Crypto;
335 const scope = yield* Effect.scope;
271 const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);336 const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets);
272 const activeSnapshotPublishedRef = yield* Ref.make(false);337 const activeSnapshotPublishedRef = yield* Ref.make(false);
273 const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>());338 const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>());
@@ -305,6 +370,9 @@ export const make = Effect.gen(function* () { · 31 unmodified lines
305 transformClient: relayEnvironmentClient(relayConfig.environmentCredential),370 transformClient: relayEnvironmentClient(relayConfig.environmentCredential),
306 }).pipe(Effect.provide(FetchHttpClient.layer));371 }).pipe(Effect.provide(FetchHttpClient.layer));
307 372
373 let cachedRelayClient: Effect.Success<ReturnType<typeof makeRelayClient>> | undefined;
374 let publishedRelayConfig: NonNullable<Effect.Success<typeof readRelayConfig>> | undefined;
375
308 // Deadlines for publishes that need confirmation (tombstones and376 // Deadlines for publishes that need confirmation (tombstones and
309 // first-state completions). The confirming publish is re-enqueued through377 // first-state completions). The confirming publish is re-enqueued through
310 // the same drainable worker as every other publish, so a confirmed378 // the same drainable worker as every other publish, so a confirmed
@@ -312,25 +380,53 @@ export const make = Effect.gen(function* () { · 1 unmodified line
312 // clears the deadline. Assigned after the worker exists.380 // clears the deadline. Assigned after the worker exists.
313 const publishConfirmDeadlines = new Map<ThreadId, number>();381 const publishConfirmDeadlines = new Map<ThreadId, number>();
314 let schedulePublishConfirm: (threadId: ThreadId) => Effect.Effect<void> = () => Effect.void;382 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 });
315 402
316 const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) {403 const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) {
317 const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(404 const publishAgentActivity = yield* readPublishAgentActivityEnabled;
318 Effect.orElseSucceed(() => false),
319 );
320 if (!publishAgentActivity) {405 if (!publishAgentActivity) {
406 yield* cancelPublishRetries;
407 yield* resetPublishedConnection;
321 yield* Effect.logDebug("agent activity publish skipped; publication disabled", {408 yield* Effect.logDebug("agent activity publish skipped; publication disabled", {
322 threadId,409 threadId,
323 });410 });
324 return;411 return;
325 }412 }
326 const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null));413 const relayConfig = yield* readRelayConfig;
327 if (!relayConfig) {414 if (!relayConfig) {
415 yield* cancelPublishRetries;
416 yield* resetPublishedConnection;
328 yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", {417 yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", {
329 threadId,418 threadId,
330 });419 });
331 return;420 return;
332 }421 }
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 }
334 const environmentId = yield* serverEnvironment.getEnvironmentId;430 const environmentId = yield* serverEnvironment.getEnvironmentId;
335 431
336 const publishState = (input: {432 const publishState = (input: {
@@ -339,6 +435,10 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
339 readonly reason: string;435 readonly reason: string;
340 }) =>436 }) =>
341 Effect.gen(function* () {437 Effect.gen(function* () {
438 if (cachedRelayClient === undefined) {
439 cachedRelayClient = yield* makeRelayClient(relayConfig);
440 }
441 const relayClient = cachedRelayClient;
342 const proof = yield* makePublishProof({442 const proof = yield* makePublishProof({
343 privateKey: cloudLinkKeyPair.privateKey,443 privateKey: cloudLinkKeyPair.privateKey,
344 relayIssuer: relayConfig.issuer,444 relayIssuer: relayConfig.issuer,
@@ -441,7 +541,6 @@ export const make = Effect.gen(function* () { · 96 unmodified lines
441 if (nowMs < deadline) {541 if (nowMs < deadline) {
442 return;542 return;
443 }543 }
444 publishConfirmDeadlines.delete(threadId);
445 yield* Effect.logInfo("agent activity deferred publish confirmed", {544 yield* Effect.logInfo("agent activity deferred publish confirmed", {
446 environmentId,545 environmentId,
447 threadId,546 threadId,
@@ -471,6 +570,7 @@ export const make = Effect.gen(function* () { · 23 unmodified lines
471 state: snapshot.state,570 state: snapshot.state,
472 reason: snapshot.reason,571 reason: snapshot.reason,
473 });572 });
573 publishConfirmDeadlines.delete(threadId);
474 yield* Ref.update(publishedStateByThreadRef, (publishedStates) => {574 yield* Ref.update(publishedStateByThreadRef, (publishedStates) => {
475 const nextPublishedStates = new Map(publishedStates);575 const nextPublishedStates = new Map(publishedStates);
476 nextPublishedStates.set(threadId, publishIdentity);576 nextPublishedStates.set(threadId, publishIdentity);
@@ -478,17 +578,63 @@ export const make = Effect.gen(function* () { · 1 unmodified line
478 });578 });
479 });579 });
480 580
481 const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) =>581 const processThreadPublish = Effect.fnUntraced(function* (threadId: ThreadId) {
482 publishThreadUnsafe(threadId).pipe(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)),
483 Effect.catchCause((cause) => {590 Effect.catchCause((cause) => {
591 if (Cause.hasInterruptsOnly(cause)) return Effect.void;
484 return Effect.logWarning("agent activity publish failed", {592 return Effect.logWarning("agent activity publish failed", {
485 threadId,593 threadId,
486 cause: Cause.pretty(cause),594 cause: Cause.pretty(cause),
487 });595 }).pipe(Effect.andThen(schedulePublishRetry(threadId)));
488 }),596 }),
489 Effect.withSpan("AgentAwarenessRelay.publishThread"),597 Effect.withSpan("AgentAwarenessRelay.publishThread"),
490 withRelayClientTracing,598 withRelayClientTracing,
491 );599 );
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 });
492 638
493 const publishActiveThreadsUnsafe = Effect.gen(function* () {639 const publishActiveThreadsUnsafe = Effect.gen(function* () {
494 const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(640 const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe(
@@ -520,7 +666,8 @@ export const make = Effect.gen(function* () { · 25 unmodified lines
520 yield* Effect.logInfo("publishing active agent activity snapshot", {666 yield* Effect.logInfo("publishing active agent activity snapshot", {
521 count: activeThreadIds.length,667 count: activeThreadIds.length,
522 });668 });
523 yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true });669 yield* Effect.forEach(activeThreadIds, enqueueThreadPublish, { discard: true });
670 yield* worker.drain;
524 return true;671 return true;
525 });672 });
526 673
@@ -542,10 +689,8 @@ export const make = Effect.gen(function* () { · 15 unmodified lines
542 }689 }
543 });690 });
544 691
545 const worker = yield* makeDrainableWorker(publishThread);
546
547 schedulePublishConfirm = (threadId) =>692 schedulePublishConfirm = (threadId) =>
548 Effect.forkDetach(693 Effect.forkIn(
549 Effect.sleep("5 seconds").pipe(694 Effect.sleep("5 seconds").pipe(
550 Effect.andThen(worker.enqueue(threadId)),695 Effect.andThen(worker.enqueue(threadId)),
551 Effect.catchCause((cause) =>696 Effect.catchCause((cause) =>
@@ -555,6 +700,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
555 }),700 }),
556 ),701 ),
557 ),702 ),
703 scope,
558 ).pipe(Effect.asVoid);704 ).pipe(Effect.asVoid);
559 705
560 const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")(706 const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")(
@@ -591,18 +737,12 @@ export const make = Effect.gen(function* () { · 30 unmodified lines
591 Stream.runForEach(threads.streamDomainEvents, (event) => {737 Stream.runForEach(threads.streamDomainEvents, (event) => {
592 const threadId = eventThreadId(event);738 const threadId = eventThreadId(event);
593 if (!shouldPublishAgentAwarenessEvent(event)) {739 if (!shouldPublishAgentAwarenessEvent(event)) {
594 return Effect.logDebug(740 return Effect.void;
595 "agent activity publishing ignored event without activity changes",
596 {
597 eventType: event.type,
598 threadId,
599 },
600 );
601 }741 }
602 return Effect.logDebug("agent activity publishing queued thread publish", {742 return Effect.logDebug("agent activity publishing queued thread publish", {
603 eventType: event.type,743 eventType: event.type,
604 threadId,744 threadId,
605 }).pipe(Effect.andThen(worker.enqueue(threadId)));745 }).pipe(Effect.andThen(enqueueThreadPublish(threadId)));
606 }),746 }),
607 );747 );
608 },748 },
@@ -610,6 +750,7 @@ export const make = Effect.gen(function* () { · 1 unmodified line
610 750
611 return AgentAwarenessRelay.of({751 return AgentAwarenessRelay.of({
612 publishThread,752 publishThread,
753 drain: worker.drain,
613 start,754 start,
614 });755 });
615});756});
apps/server/src/server.ts+1−1
@@ -412,7 +412,7 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( · 411 unmodified lines
412// so every client sees the same shelf.412// so every client sees the same shelf.
413const ThreadSettlementWorkerLive = Layer.effectDiscard(413const ThreadSettlementWorkerLive = Layer.effectDiscard(
414 ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())),414 ThreadSettlementService.make.pipe(Effect.flatMap((service) => service.start())),
415).pipe(Layer.provide(PullRequestServiceLive));415).pipe(Layer.provide(PullRequestServiceLive), Layer.provide(ProjectionStoreV2.layer));
416 416
417const AntigravityInstallationRefreshLive = Layer.effectDiscard(417const AntigravityInstallationRefreshLive = Layer.effectDiscard(
418 Effect.gen(function* () {418 Effect.gen(function* () {
apps/server/src/vcs/GitVcsDriver.ts+9−1
@@ -74,6 +74,11 @@ export interface GitStatusDetails { · 73 unmodified lines
74 aheadOfDefaultCount: number;74 aheadOfDefaultCount: number;
75}75}
76 76
77export interface GitLocalStatusOptions {
78 /** Skip revision walks and return zero divergence counts for local-only consumers. */
79 readonly includeDivergence?: boolean;
80}
81
77export interface GitRemoteStatusDetails {82export interface GitRemoteStatusDetails {
78 isRepo: boolean;83 isRepo: boolean;
79 defaultBranch: string | null;84 defaultBranch: string | null;
@@ -246,7 +251,10 @@ export class GitVcsDriver extends Context.Service< · 166 unmodified lines
246 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;251 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
247 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;252 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
248 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;253 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>;
250 readonly statusDetailsRemote: (258 readonly statusDetailsRemote: (
251 cwd: string,259 cwd: string,
252 options?: GitRemoteStatusOptions,260 options?: GitRemoteStatusOptions,
apps/server/src/vcs/GitVcsDriverCore.test.ts+84−7
@@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Fiber from "effect/Fiber";9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Logger from "effect/Logger";
11import * as Path from "effect/Path";12import * as Path from "effect/Path";
12import * as PlatformError from "effect/PlatformError";13import * as PlatformError from "effect/PlatformError";
13import * as Ref from "effect/Ref";14import * as Ref from "effect/Ref";
@@ -553,7 +554,7 @@ it.effect("refreshes the current branch after an external checkout", () => · 539 unmodified lines
553 ).pipe(Effect.provide(TestLayer)),554 ).pipe(Effect.provide(TestLayer)),
554);555);
555 556
556it.effect("backs off failed upstream refreshes across linked worktrees", () =>557it.effect("backs off and logs failed fetch attempts across linked worktrees", () =>
557 Effect.scoped(558 Effect.scoped(
558 Effect.gen(function* () {559 Effect.gen(function* () {
559 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;560 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -573,6 +574,15 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => · 13 unmodified lines
573 const driver = yield* makeGitVcsDriverCore().pipe(574 const driver = yield* makeGitVcsDriverCore().pipe(
574 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner),575 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner),
575 );576 );
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;
576 const cwd = yield* makeTmpDir();586 const cwd = yield* makeTmpDir();
577 const remote = yield* makeTmpDir("git-vcs-driver-remote-");587 const remote = yield* makeTmpDir("git-vcs-driver-remote-");
578 const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-");588 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
616 );626 );
617 yield* Ref.set(fetchAttempts, 0);627 yield* Ref.set(fetchAttempts, 0);
618 628
619 yield* driver.statusDetailsRemote(cwd);629 yield* readRemoteStatus(cwd);
620 yield* driver.statusDetailsRemote(worktreePath);630 yield* readRemoteStatus(worktreePath);
621 assert.equal(yield* Ref.get(fetchAttempts), 1);631 assert.equal(yield* Ref.get(fetchAttempts), 1);
632 assert.lengthOf(warnings, 1);
633 assert.include(warnings[0], "Background Git fetch failed");
622 634
623 yield* TestClock.adjust("29 seconds");635 yield* TestClock.adjust("29 seconds");
624 yield* driver.statusDetailsRemote(worktreePath);636 yield* readRemoteStatus(worktreePath);
625 assert.equal(yield* Ref.get(fetchAttempts), 1);637 assert.equal(yield* Ref.get(fetchAttempts), 1);
638 assert.lengthOf(warnings, 1);
626 639
627 yield* TestClock.adjust("1 second");640 yield* TestClock.adjust("1 second");
628 yield* driver.statusDetailsRemote(cwd);641 yield* readRemoteStatus(cwd);
629 assert.equal(yield* Ref.get(fetchAttempts), 2);642 assert.equal(yield* Ref.get(fetchAttempts), 2);
643 assert.lengthOf(warnings, 2);
630 644
631 yield* TestClock.adjust("59 seconds");645 yield* TestClock.adjust("59 seconds");
632 yield* driver.statusDetailsRemote(worktreePath);646 yield* readRemoteStatus(worktreePath);
633 assert.equal(yield* Ref.get(fetchAttempts), 2);647 assert.equal(yield* Ref.get(fetchAttempts), 2);
648 assert.lengthOf(warnings, 2);
634 649
635 yield* TestClock.adjust("1 second");650 yield* TestClock.adjust("1 second");
636 yield* driver.statusDetailsRemote(cwd);651 yield* readRemoteStatus(cwd);
637 assert.equal(yield* Ref.get(fetchAttempts), 3);652 assert.equal(yield* Ref.get(fetchAttempts), 3);
653 assert.lengthOf(warnings, 3);
638 }),654 }),
639 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),655 ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
640);656);
@@ -1027,6 +1043,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { · 386 unmodified lines
1027 }),1043 }),
1028 );1044 );
1029 1045
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
1030 it.effect("reports remote divergence without reading working-tree details", () =>1107 it.effect("reports remote divergence without reading working-tree details", () =>
1031 Effect.gen(function* () {1108 Effect.gen(function* () {
1032 const cwd = yield* makeTmpDir();1109 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
1246 return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(1246 return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe(
1247 Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),1247 Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))),
1248 Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),1248 Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))),
1249 Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)),
1249 Effect.as(true as const),1250 Effect.as(true as const),
1250 );1251 );
1251 });1252 });
@@ -1270,13 +1271,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 18 unmodified lines
1270 const upstream = yield* resolveCurrentUpstream(cwd);1271 const upstream = yield* resolveCurrentUpstream(cwd);
1271 if (!upstream) return;1272 if (!upstream) return;
1272 const gitCommonDir = yield* resolveGitCommonDir(cwd);1273 const gitCommonDir = yield* resolveGitCommonDir(cwd);
1274 // The cache loader logs failed attempts; cache hits keep using the last fetched refs.
1273 yield* Cache.get(1275 yield* Cache.get(
1274 statusRemoteRefreshCache,1276 statusRemoteRefreshCache,
1275 new StatusRemoteRefreshCacheKey({1277 new StatusRemoteRefreshCacheKey({
1276 gitCommonDir,1278 gitCommonDir,
1277 remoteName: upstream.remoteName,1279 remoteName: upstream.remoteName,
1278 }),1280 }),
1279 );1281 ).pipe(Effect.ignore);
1280 });1282 });
1281 1283
1282 const resolveDefaultBranchName = (1284 const resolveDefaultBranchName = (
@@ -1583,11 +1585,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 300 unmodified lines
1583 };1585 };
1584 });1586 });
1585 1587
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 ];
1587 const statusResult = yield* executeGitWithStableDiagnostics(1599 const statusResult = yield* executeGitWithStableDiagnostics(
1588 "GitVcsDriver.statusDetails.status",1600 "GitVcsDriver.statusDetails.status",
1589 cwd,1601 cwd,
1590 ["status", "--porcelain=2", "--branch"],1602 statusArgs,
1591 {1603 {
1592 allowNonZeroExit: true,1604 allowNonZeroExit: true,
1593 },1605 },
@@ -1610,7 +1622,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 16 unmodified lines
1610 ...gitCommandContext({1622 ...gitCommandContext({
1611 operation: "GitVcsDriver.statusDetails.status",1623 operation: "GitVcsDriver.statusDetails.status",
1612 cwd,1624 cwd,
1613 args: ["status", "--porcelain=2", "--branch"],1625 args: statusArgs,
1614 }),1626 }),
1615 detail: "Git status failed.",1627 detail: "Git status failed.",
1616 exitCode: statusResult.exitCode,1628 exitCode: statusResult.exitCode,
@@ -1710,7 +1722,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 93 unmodified lines
1710 upstreamRef = value.length > 0 ? value : null;1722 upstreamRef = value.length > 0 ? value : null;
1711 continue;1723 continue;
1712 }1724 }
1713 if (line.startsWith("# branch.ab ")) {1725 if (includeDivergence && line.startsWith("# branch.ab ")) {
1714 const value = line.slice("# branch.ab ".length).trim();1726 const value = line.slice("# branch.ab ".length).trim();
1715 const parsed = parseBranchAb(value);1727 const parsed = parseBranchAb(value);
1716 aheadCount = parsed.ahead;1728 aheadCount = parsed.ahead;
@@ -1725,7 +1737,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 8 unmodified lines
1725 }1737 }
1726 1738
1727 const fallbackAheadCount =1739 const fallbackAheadCount =
1728 !upstreamRef && refName1740 includeDivergence && !upstreamRef && refName
1729 ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0))1741 ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0))
1730 : null;1742 : null;
1731 1743
@@ -1738,7 +1750,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 6 unmodified lines
1738 refName !== null &&1750 refName !== null &&
1739 (refName === defaultBranch ||1751 (refName === defaultBranch ||
1740 (defaultBranch === null && (refName === "main" || refName === "master")));1752 (defaultBranch === null && (refName === "main" || refName === "master")));
1741 if (refName && !isDefaultBranch) {1753 if (includeDivergence && refName && !isDefaultBranch) {
1742 aheadOfDefaultCount =1754 aheadOfDefaultCount =
1743 fallbackAheadCount !== null1755 fallbackAheadCount !== null
1744 ? fallbackAheadCount1756 ? fallbackAheadCount
@@ -1788,8 +1800,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 43 unmodified lines
1788 1800
1789 const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn(1801 const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn(
1790 "statusDetailsLocal",1802 "statusDetailsLocal",
1791 )(function* (cwd) {1803 )(function* (cwd, options) {
1792 return yield* readStatusDetailsLocal(cwd);1804 return yield* readStatusDetailsLocal(cwd, options);
1793 });1805 });
1794 1806
1795 const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn(1807 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
1186 projectTitleById,1186 projectTitleById,
1187 sortOrder: clientSettings.sidebarThreadSortOrder,1187 sortOrder: clientSettings.sidebarThreadSortOrder,
1188 icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,1188 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 ),
1190 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,1197 renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />,
1191 renderDescription: (thread, { projectTitle }) => {1198 renderDescription: (thread, { projectTitle }) => {
1192 const modelInstanceId =1199 const modelInstanceId =
@@ -1238,6 +1245,7 @@ function OpenCommandPaletteDialog(props: { · 45 unmodified lines
1238 }),1245 }),
1239 [1246 [
1240 activeThreadId,1247 activeThreadId,
1248 changeRequestSnapshotByKey,
1241 clientSettings.sidebarThreadSortOrder,1249 clientSettings.sidebarThreadSortOrder,
1242 navigate,1250 navigate,
1243 projectCwdById,1251 projectCwdById,
apps/web/src/components/ThreadStatusIndicators.subscriptions.test.tsx+180−0
@@ -0,0 +1,180 @@
1import { ProjectId, ThreadId, type VcsStatusResult } from "@t3tools/contracts";
2import { act, type ComponentProps, type ReactNode } from "react";
3import { create, type ReactTestRenderer } from "react-test-renderer";
4import { afterEach, expect, it, vi } from "vite-plus/test";
5
6import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
7import { makeThreadFixture } from "../test-fixtures";
8
9const state = vi.hoisted(() => ({ queries: [] as string[] }));
10
11vi.mock("../state/entities", () => ({ useProject: () => null }));
12vi.mock("../uiStateStore", () => ({ useUiStateStore: () => undefined }));
13vi.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});
43vi.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});
70vi.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
84import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators";
85
86afterEach(() => {
87 vi.unstubAllGlobals();
88});
89
90it("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
554 ).toEqual(mergedPr);554 ).toEqual(mergedPr);
555 });555 });
556 556
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
557 it("retains a merged PR after a main checkout", () => {576 it("retains a merged PR after a main checkout", () => {
558 const matchingStatus = status({577 const matchingStatus = status({
559 refName: featureBranch,578 refName: featureBranch,
apps/web/src/components/ThreadStatusIndicators.tsx+59−17
@@ -12,7 +12,7 @@ import { · 11 unmodified lines
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { Atom } from "effect/unstable/reactivity";13import { Atom } from "effect/unstable/reactivity";
14import { FolderGit2Icon, TerminalIcon } from "lucide-react";14import { FolderGit2Icon, TerminalIcon } from "lucide-react";
15import { useMemo } from "react";15import { useCallback, useMemo } from "react";
16import { appAtomRegistry } from "../rpc/atomRegistry";16import { appAtomRegistry } from "../rpc/atomRegistry";
17import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";17import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
18import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";18import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
@@ -27,6 +27,8 @@ import { · 8 unmodified lines
27 resolveThreadLastVisitedAt,27 resolveThreadLastVisitedAt,
28 resolveThreadStatusPill,28 resolveThreadStatusPill,
29 type ThreadStatusPill,29 type ThreadStatusPill,
30 useRetainedValue,
31 useSidebarRowSubscriptionLease,
30} from "./Sidebar.logic";32} from "./Sidebar.logic";
31import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";33import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation";
32import type { SidebarThreadSummary } from "../types";34import type { SidebarThreadSummary } from "../types";
@@ -58,9 +60,10 @@ export interface LinkedThreadPullRequestStatus { · 25 unmodified lines
58export function useLinkedThreadPullRequest(60export function useLinkedThreadPullRequest(
59 environmentId: EnvironmentId | null,61 environmentId: EnvironmentId | null,
60 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,62 linkedPullRequest: ThreadLinkedPullRequest | null | undefined,
63 enabled = true,
61): LinkedThreadPullRequestStatus | null {64): LinkedThreadPullRequestStatus | null {
62 const queried = useEnvironmentQuery(65 const queried = useEnvironmentQuery(
63 environmentId === null || linkedPullRequest == null66 !enabled || environmentId === null || linkedPullRequest == null
64 ? null67 ? null
65 : linkedPullRequestDetailAtom({68 : linkedPullRequestDetailAtom({
66 environmentId,69 environmentId,
@@ -353,7 +356,7 @@ export function nextThreadChangeRequestSnapshot(input: { · 286 unmodified lines
353 * checkouts only, a cached merged/closed PR for the thread. Local thread356 * checkouts only, a cached merged/closed PR for the thread. Local thread
354 * metadata follows the shared checkout, so the cached branch intentionally357 * metadata follows the shared checkout, so the cached branch intentionally
355 * survives that metadata changing to the newly checked-out branch. Open PRs358 * 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.
357 */360 */
358export function resolveDisplayedThreadPr(input: {361export function resolveDisplayedThreadPr(input: {
359 threadBranch: string | null;362 threadBranch: string | null;
@@ -388,6 +391,15 @@ export function resolveDisplayedThreadPr(input: { · 28 unmodified lines
388 return gitStatus.pr;391 return gitStatus.pr;
389 }392 }
390 393
394 if (
395 gitStatus === null &&
396 threadBranch !== null &&
397 snapshot?.branch === threadBranch &&
398 snapshot.linkedPullRequest === undefined
399 ) {
400 return snapshot.pr;
401 }
402
391 if (403 if (
392 threadBranch !== null &&404 threadBranch !== null &&
393 retainTerminalOnBranchMismatch &&405 retainTerminalOnBranchMismatch &&
@@ -434,6 +446,15 @@ export function resolveDisplayedThreadPrProvider(input: { · 40 unmodified lines
434 return gitStatus.sourceControlProvider;446 return gitStatus.sourceControlProvider;
435 }447 }
436 448
449 if (
450 gitStatus === null &&
451 threadBranch !== null &&
452 snapshot?.branch === threadBranch &&
453 snapshot.linkedPullRequest === undefined
454 ) {
455 return snapshot.sourceControlProvider;
456 }
457
437 if (458 if (
438 threadBranch !== null &&459 threadBranch !== null &&
439 retainTerminalOnBranchMismatch &&460 retainTerminalOnBranchMismatch &&
@@ -550,7 +571,19 @@ export function ThreadStatusLabel({ · 110 unmodified lines
550 * like the command palette. Shows the change request state icon (if present) and the571 * like the command palette. Shows the change request state icon (if present) and the
551 * thread status dot, matching the sidebar's leading indicators.572 * thread status dot, matching the sidebar's leading indicators.
552 */573 */
553export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) {574export 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 );
554 const threadRef = scopeThreadRef(thread.environmentId, thread.id);587 const threadRef = scopeThreadRef(thread.environmentId, thread.id);
555 const localLastVisitedAt = useUiStateStore(588 const localLastVisitedAt = useUiStateStore(
556 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],589 (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)],
@@ -567,9 +600,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 10 unmodified lines
567 const linkedPullRequest = useLinkedThreadPullRequest(600 const linkedPullRequest = useLinkedThreadPullRequest(
568 thread.environmentId,601 thread.environmentId,
569 thread.linkedPullRequest,602 thread.linkedPullRequest,
603 leaseLiveStatus,
570 );604 );
571 const gitStatus = useEnvironmentQuery(605 const gitStatus = useEnvironmentQuery(
572 thread.linkedPullRequest == null &&606 leaseLiveStatus &&
607 thread.linkedPullRequest == null &&
573 (thread.branch != null || thread.worktreePath !== null) &&608 (thread.branch != null || thread.worktreePath !== null) &&
574 gitCwd !== null609 gitCwd !== null
575 ? vcsEnvironment.status({610 ? vcsEnvironment.status({
@@ -578,14 +613,20 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 2 unmodified lines
578 })613 })
579 : null,614 : null,
580 );615 );
581 const pr =616 const visibleGitStatus = useRetainedValue(
582 thread.linkedPullRequest == null617 JSON.stringify([thread.environmentId, gitCwd]),
583 ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data })618 gitStatus.data,
584 : (linkedPullRequest?.pr ?? null);
585 const prStatus = prStatusIndicator(
586 pr,
587 linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider,
588 );619 );
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));
589 const threadStatus = resolveThreadStatusPill({630 const threadStatus = resolveThreadStatusPill({
590 thread: {631 thread: {
591 ...thread,632 ...thread,
@@ -593,12 +634,13 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 1 unmodified line
593 },634 },
594 });635 });
595 636
596 if (!prStatus && !threadStatus) {
597 return null;
598 }
599
600 return (637 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 >
602 {prStatus && pr ? (644 {prStatus && pr ? (
603 <Tooltip>645 <Tooltip>
604 <TooltipTrigger646 <TooltipTrigger
apps/web/src/connection/desktopLocal.test.ts+62−1
@@ -2,7 +2,11 @@ import { · 1 unmodified line
2 BearerConnectionTarget,2 BearerConnectionTarget,
3 PrimaryConnectionTarget,3 PrimaryConnectionTarget,
4} from "@t3tools/client-runtime/connection";4} from "@t3tools/client-runtime/connection";
5import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";5import {
6 type DesktopEnvironmentBootstrap,
7 EnvironmentId,
8 PRIMARY_LOCAL_ENVIRONMENT_ID,
9} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";10import { describe, expect, it } from "vite-plus/test";
7 11
8import {12import {
@@ -38,6 +42,63 @@ describe("desktop local connection identity", () => { · 29 unmodified lines
38});42});
39 43
40describe("desktop local topology reads", () => {44describe("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
41 it("distinguishes a successful empty topology from a read failure", () => {102 it("distinguishes a successful empty topology from a read failure", () => {
42 let readBootstraps = () => [];103 let readBootstraps = () => [];
43 const reader = createDesktopSecondaryBootstrapsReader(() => ({104 const reader = createDesktopSecondaryBootstrapsReader(() => ({
apps/web/src/connection/desktopLocal.ts+19−2
@@ -67,13 +67,30 @@ export function createDesktopSecondaryBootstrapsReader( · 66 unmodified lines
67 const readResult = (): DesktopSecondaryBootstrapsRead => {67 const readResult = (): DesktopSecondaryBootstrapsRead => {
68 const bridge = resolveBridge();68 const bridge = resolveBridge();
69 if (bridge === undefined) {69 if (bridge === undefined) {
70 snapshot = [];70 if (snapshot.length > 0) snapshot = [];
71 return { _tag: "Success", bootstraps: snapshot };71 return { _tag: "Success", bootstraps: snapshot };
72 }72 }
73 try {73 try {
74 snapshot = bridge74 const next = bridge
75 .getLocalEnvironmentBootstraps()75 .getLocalEnvironmentBootstraps()
76 .filter((entry) => entry.id !== PRIMARY_LOCAL_ENVIRONMENT_ID);76 .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 }
77 return { _tag: "Success", bootstraps: snapshot };94 return { _tag: "Success", bootstraps: snapshot };
78 } catch (cause) {95 } catch (cause) {
79 return { _tag: "Failure", cause };96 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