- 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
Showing the changes to apps (53 of 167 files). Show all
Unified · Split
| @@ -0,0 +1,189 @@ | ||
| 1 | +import Foundation | |
| 2 | + | |
| 3 | +// The registry runs on macOS without starting a simulator. Only the OS-facing | |
| 4 | +// types are replaced; the tests compile the dependency's actual Swift source. | |
| 5 | +public enum UIBackgroundFetchResult { | |
| 6 | + case noData | |
| 7 | +} | |
| 8 | + | |
| 9 | +public struct UNNotificationPresentationOptions: OptionSet { | |
| 10 | + public let rawValue: Int | |
| 11 | + public init(rawValue: Int) { self.rawValue = rawValue } | |
| 12 | +} | |
| 13 | + | |
| 14 | +public final class UNNotification: NSObject {} | |
| 15 | + | |
| 16 | +public final class UNNotificationResponse: NSObject { | |
| 17 | + let identifier: String | |
| 18 | + init(_ identifier: String) { self.identifier = identifier } | |
| 19 | +} | |
| 20 | + | |
| 21 | +public protocol UNUserNotificationCenterDelegate: AnyObject {} | |
| 22 | + | |
| 23 | +public final class UNUserNotificationCenter: NSObject { | |
| 24 | + private static let instance = UNUserNotificationCenter() | |
| 25 | + public weak var delegate: UNUserNotificationCenterDelegate? | |
| 26 | + public static func current() -> UNUserNotificationCenter { instance } | |
| 27 | +} | |
| 28 | + | |
| 29 | +private final class TestDelegate: NotificationDelegate { | |
| 30 | + private let lock = NSLock() | |
| 31 | + private var recordedEvents: [String] = [] | |
| 32 | + var onEvent: ((String) -> Void)? | |
| 33 | + var onResponse: ((UNNotificationResponse) -> Bool)? | |
| 34 | + | |
| 35 | + var events: [String] { lock.withLock { recordedEvents } } | |
| 36 | + | |
| 37 | + private func record(_ event: String) { | |
| 38 | + lock.withLock { recordedEvents.append(event) } | |
| 39 | + onEvent?(event) | |
| 40 | + } | |
| 41 | + | |
| 42 | + func didRegister(_ deviceToken: String) { record("registered") } | |
| 43 | + func didFailRegistration(_ error: Error) { record("failed") } | |
| 44 | + func openSettings(_ notification: UNNotification?) { record("settings") } | |
| 45 | + | |
| 46 | + func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { | |
| 47 | + record("present") | |
| 48 | + return false | |
| 49 | + } | |
| 50 | + | |
| 51 | + func didReceive(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { | |
| 52 | + record("background") | |
| 53 | + return false | |
| 54 | + } | |
| 55 | + | |
| 56 | + func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { | |
| 57 | + record(response.identifier) | |
| 58 | + return onResponse?(response) ?? false | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +private func require(_ condition: @autoclosure () -> Bool, _ message: String) throws { | |
| 63 | + if !condition() { | |
| 64 | + throw NSError(domain: "NotificationCenterManagerRegression", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +private func deliver(_ identifier: String) { | |
| 69 | + NotificationCenterManager.shared.userNotificationCenter( | |
| 70 | + UNUserNotificationCenter.current(), | |
| 71 | + didReceive: UNNotificationResponse(identifier), | |
| 72 | + withCompletionHandler: {} | |
| 73 | + ) | |
| 74 | +} | |
| 75 | + | |
| 76 | +private func reentrantCallbacks() throws { | |
| 77 | + let manager = NotificationCenterManager.shared | |
| 78 | + let center = UNUserNotificationCenter.current() | |
| 79 | + let callbacks: [(String, () -> Void)] = [ | |
| 80 | + ("registered", { manager.didRegister("token") }), | |
| 81 | + ("failed", { manager.didFailRegistration(NSError(domain: "test", code: 1)) }), | |
| 82 | + ("settings", { manager.userNotificationCenter(center, openSettingsFor: nil) }), | |
| 83 | + ("present", { manager.userNotificationCenter(center, willPresent: UNNotification(), withCompletionHandler: { _ in }) }), | |
| 84 | + ("background", { manager.didReceive([:], completionHandler: { _ in }) }) | |
| 85 | + ] | |
| 86 | + | |
| 87 | + for (event, callback) in callbacks { | |
| 88 | + let original = TestDelegate() | |
| 89 | + let replacement = TestDelegate() | |
| 90 | + original.onEvent = { [weak original] _ in | |
| 91 | + guard let original else { return } | |
| 92 | + manager.removeDelegate(original) | |
| 93 | + manager.addDelegate(replacement) | |
| 94 | + } | |
| 95 | + manager.addDelegate(original) | |
| 96 | + callback() | |
| 97 | + try require(original.events == [event], "The original delegate must receive the callback once") | |
| 98 | + try require(replacement.events.isEmpty, "New delegates must not join an in-flight callback snapshot") | |
| 99 | + callback() | |
| 100 | + try require(replacement.events == [event], "Reentrant registration must take effect for the next callback") | |
| 101 | + manager.removeDelegate(replacement) | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +private func registrationDuringDelivery() throws { | |
| 106 | + let manager = NotificationCenterManager.shared | |
| 107 | + let original = TestDelegate() | |
| 108 | + let receiver = TestDelegate() | |
| 109 | + receiver.onResponse = { _ in true } | |
| 110 | + original.onResponse = { _ in | |
| 111 | + manager.addDelegate(receiver) | |
| 112 | + return false | |
| 113 | + } | |
| 114 | + manager.addDelegate(original) | |
| 115 | + deliver("handoff") | |
| 116 | + try require(receiver.events == ["handoff"], "A delegate registering during delivery must not miss the pending response") | |
| 117 | + manager.removeDelegate(original) | |
| 118 | + manager.removeDelegate(receiver) | |
| 119 | +} | |
| 120 | + | |
| 121 | +private func responseDuringReplay() throws { | |
| 122 | + let manager = NotificationCenterManager.shared | |
| 123 | + deliver("first") | |
| 124 | + let original = TestDelegate() | |
| 125 | + original.onResponse = { response in | |
| 126 | + if response.identifier == "first" { | |
| 127 | + deliver("second") | |
| 128 | + return true | |
| 129 | + } | |
| 130 | + return false | |
| 131 | + } | |
| 132 | + manager.addDelegate(original) | |
| 133 | + let receiver = TestDelegate() | |
| 134 | + receiver.onResponse = { _ in true } | |
| 135 | + manager.addDelegate(receiver) | |
| 136 | + try require(receiver.events == ["second"], "Finishing a replay must retain responses received during callbacks") | |
| 137 | + manager.removeDelegate(original) | |
| 138 | + manager.removeDelegate(receiver) | |
| 139 | +} | |
| 140 | + | |
| 141 | +private func concurrentRegistrations() throws { | |
| 142 | + let manager = NotificationCenterManager.shared | |
| 143 | + let delegates = (0..<512).map { _ in | |
| 144 | + let delegate = TestDelegate() | |
| 145 | + delegate.onResponse = { _ in true } | |
| 146 | + return delegate | |
| 147 | + } | |
| 148 | + DispatchQueue.concurrentPerform(iterations: delegates.count) { index in | |
| 149 | + manager.addDelegate(delegates[index]) | |
| 150 | + if index.isMultiple(of: 16) { | |
| 151 | + manager.didRegister("during-add") | |
| 152 | + deliver("during-add") | |
| 153 | + } | |
| 154 | + } | |
| 155 | + let before = delegates.map { $0.events.count } | |
| 156 | + manager.didRegister("after-add") | |
| 157 | + for (index, delegate) in delegates.enumerated() { | |
| 158 | + try require(delegate.events.count == before[index] + 1, "Concurrent registration must retain every delegate exactly once") | |
| 159 | + } | |
| 160 | + DispatchQueue.concurrentPerform(iterations: delegates.count) { index in | |
| 161 | + manager.removeDelegate(delegates[index]) | |
| 162 | + if index.isMultiple(of: 16) { | |
| 163 | + manager.didRegister("during-remove") | |
| 164 | + deliver("during-remove") | |
| 165 | + } | |
| 166 | + } | |
| 167 | + let removed = delegates.map { $0.events.count } | |
| 168 | + manager.didRegister("after-remove") | |
| 169 | + try require(delegates.map { $0.events.count } == removed, "Removed delegates must not receive new callbacks") | |
| 170 | +} | |
| 171 | + | |
| 172 | +@main | |
| 173 | +private enum RegressionTests { | |
| 174 | + static func main() { | |
| 175 | + do { | |
| 176 | + switch CommandLine.arguments.last { | |
| 177 | + case "reentrant": try reentrantCallbacks() | |
| 178 | + case "handoff": try registrationDuringDelivery() | |
| 179 | + case "pending": try responseDuringReplay() | |
| 180 | + case "concurrent": try concurrentRegistrations() | |
| 181 | + default: throw NSError(domain: "NotificationCenterManagerRegression", code: 2) | |
| 182 | + } | |
| 183 | + print("passed") | |
| 184 | + } catch { | |
| 185 | + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) | |
| 186 | + exit(1) | |
| 187 | + } | |
| 188 | + } | |
| 189 | +} | |
| @@ -0,0 +1,65 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. | |
| 2 | +import * as NodeChildProcess from "node:child_process"; | |
| 3 | +import * as NodeFS from "node:fs"; | |
| 4 | +import * as NodeOS from "node:os"; | |
| 5 | +import * as NodePath from "node:path"; | |
| 6 | +import * as NodeURL from "node:url"; | |
| 7 | +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; | |
| 8 | + | |
| 9 | +// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. | |
| 10 | +describe.skipIf(NodeOS.platform() !== "darwin")( | |
| 11 | + "NotificationCenterManager native concurrency", | |
| 12 | + () => { | |
| 13 | + let directory: string; | |
| 14 | + let executable: string; | |
| 15 | + | |
| 16 | + beforeAll(() => { | |
| 17 | + directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-notifications-test-")); | |
| 18 | + executable = NodePath.join(directory, "notification-regression"); | |
| 19 | + const source = NodeFS.readFileSync( | |
| 20 | + new URL( | |
| 21 | + "../node_modules/expo-notifications/ios/ExpoNotifications/Notifications/NotificationCenterManager.swift", | |
| 22 | + import.meta.url, | |
| 23 | + ), | |
| 24 | + "utf8", | |
| 25 | + ); | |
| 26 | + const manager = NodePath.join(directory, "NotificationCenterManager.swift"); | |
| 27 | + NodeFS.writeFileSync( | |
| 28 | + manager, | |
| 29 | + source.replace(/^import (ExpoModulesCore|UserNotifications)\n/gm, ""), | |
| 30 | + ); | |
| 31 | + NodeChildProcess.execFileSync( | |
| 32 | + "swiftc", | |
| 33 | + [ | |
| 34 | + "-swift-version", | |
| 35 | + "5", | |
| 36 | + "-sanitize=thread", | |
| 37 | + manager, | |
| 38 | + NodeURL.fileURLToPath( | |
| 39 | + new URL("./fixtures/NotificationCenterManagerRegression.swift", import.meta.url), | |
| 40 | + ), | |
| 41 | + "-o", | |
| 42 | + executable, | |
| 43 | + ], | |
| 44 | + { timeout: 30_000, encoding: "utf8" }, | |
| 45 | + ); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + afterAll(() => { | |
| 49 | + if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it.each([ | |
| 53 | + ["reentrant", "allows callbacks to replace delegates without deadlocking"], | |
| 54 | + ["handoff", "delivers responses to delegates registering during delivery"], | |
| 55 | + ["pending", "retains new responses received while replaying pending responses"], | |
| 56 | + ["concurrent", "registers, removes, and broadcasts concurrently without data races"], | |
| 57 | + ])("%s: %s", (name) => { | |
| 58 | + const output = NodeChildProcess.execFileSync(executable, [name], { | |
| 59 | + encoding: "utf8", | |
| 60 | + timeout: 15_000, | |
| 61 | + }); | |
| 62 | + expect(output.trim()).toBe("passed"); | |
| 63 | + }); | |
| 64 | + }, | |
| 65 | +); | |
| @@ -923,7 +923,7 @@ export const make = Effect.gen(function* () { · 922 unmodified lines | ||
| 923 | 923 | } satisfies GitVcsDriver.GitStatusDetails; |
| 924 | 924 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { |
| 925 | 925 | const details = yield* gitCore |
| 926 | − .statusDetailsLocal(cwd) | |
| 926 | + .statusDetailsLocal(cwd, { includeDivergence: false }) | |
| 927 | 927 | .pipe( |
| 928 | 928 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), |
| 929 | 929 | ); |
| @@ -1083,7 +1083,10 @@ function codexReplayPreamble(input: { · 1082 unmodified lines | ||
| 1083 | 1083 | method: "initialize", |
| 1084 | 1084 | params: { |
| 1085 | 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 | }, |
| @@ -173,6 +173,7 @@ const CODEX_CLIENT_INFO = { · 172 unmodified lines | ||
| 173 | 173 | } as const; |
| 174 | 174 | const CODEX_CLIENT_CAPABILITIES = { |
| 175 | 175 | experimentalApi: true, |
| 176 | + optOutNotificationMethods: ["turn/diff/updated"], | |
| 176 | 177 | } as const; |
| 177 | 178 | |
| 178 | 179 | export const CodexProviderCapabilitiesV2 = { |
| @@ -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 | 1432 | const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( |
| 1390 | 1433 | command: Extract< |
| 1391 | 1434 | OrchestrationV2Command, |
| @@ -1401,7 +1444,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 9 unmodified lines | ||
| 1401 | 1444 | | "thread.pin" |
| 1402 | 1445 | | "thread.unpin" |
| 1403 | 1446 | | "thread.pin.reorder" |
| 1404 | − | "thread.visit" | |
| 1405 | 1447 | | "thread.mark-unread" |
| 1406 | 1448 | | "thread.metadata.update" |
| 1407 | 1449 | | "thread.title.regeneration.complete" |
| @@ -1548,18 +1590,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 140 unmodified lines | ||
| 1548 | 1590 | } |
| 1549 | 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 | 1593 | let markUnreadVisitedAt: DateTime.Utc | null = null; |
| 1564 | 1594 | if (command.type === "thread.mark-unread") { |
| 1565 | 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 | 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 | 1711 | case "thread.mark-unread": |
| 1693 | 1712 | return { ...thread, lastVisitedAt: markUnreadVisitedAt }; |
| 1694 | 1713 | case "thread.metadata.update": |
| @@ -1751,8 +1770,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 56 unmodified lines | ||
| 1751 | 1770 | return "thread.unpinned" as const; |
| 1752 | 1771 | case "thread.pin.reorder": |
| 1753 | 1772 | return "thread.pin-reordered" as const; |
| 1754 | − case "thread.visit": | |
| 1755 | − return "thread.visited" as const; | |
| 1756 | 1773 | case "thread.mark-unread": |
| 1757 | 1774 | return "thread.marked-unread" as const; |
| 1758 | 1775 | case "thread.metadata.update": |
| @@ -6921,16 +6938,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 5162 unmodified lines | ||
| 6921 | 6938 | case "thread.create": |
| 6922 | 6939 | yield* dispatchThreadCreate(command, events); |
| 6923 | 6940 | break; |
| 6941 | + case "thread.visit": | |
| 6942 | + yield* dispatchThreadVisit(command, events); | |
| 6943 | + break; | |
| 6924 | 6944 | case "thread.auto-settle": { |
| 6925 | 6945 | // Automatic settlement (#8600): the sweep evaluated a shell snapshot, |
| 6926 | 6946 | // so re-check against the live thread before settling. Any change |
| 6927 | 6947 | // after the snapshot — or any explicit override, including the |
| 6928 | 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 | 6956 | if ( |
| 6931 | − projection.thread.settledOverride !== null || | |
| 6932 | − DateTime.toEpochMillis(projection.thread.updatedAt) > | |
| 6933 | − DateTime.toEpochMillis(command.snapshotAt) | |
| 6957 | + thread.settledOverride !== null || | |
| 6958 | + DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt) | |
| 6934 | 6959 | ) { |
| 6935 | 6960 | return yield* new OrchestratorDispatchError({ |
| 6936 | 6961 | commandId: command.commandId, |
| @@ -6960,7 +6985,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio · 23 unmodified lines | ||
| 6960 | 6985 | case "thread.pin": |
| 6961 | 6986 | case "thread.unpin": |
| 6962 | 6987 | case "thread.pin.reorder": |
| 6963 | − case "thread.visit": | |
| 6964 | 6988 | case "thread.mark-unread": |
| 6965 | 6989 | case "thread.metadata.update": |
| 6966 | 6990 | case "thread.title.regeneration.complete": |
| @@ -0,0 +1,363 @@ | ||
| 1 | +import { assert, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + EventId, | |
| 4 | + MessageId, | |
| 5 | + NodeId, | |
| 6 | + type OrchestrationV2AppThread, | |
| 7 | + type OrchestrationV2Run, | |
| 8 | + ProjectId, | |
| 9 | + ProviderDriverKind, | |
| 10 | + ProviderInstanceId, | |
| 11 | + ProviderThreadId, | |
| 12 | + RunId, | |
| 13 | + RuntimeRequestId, | |
| 14 | + ThreadId, | |
| 15 | + TurnItemId, | |
| 16 | +} from "@t3tools/contracts"; | |
| 17 | +import * as DateTime from "effect/DateTime"; | |
| 18 | +import * as Effect from "effect/Effect"; | |
| 19 | +import * as Layer from "effect/Layer"; | |
| 20 | +import * as Schema from "effect/Schema"; | |
| 21 | +import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 22 | +import * as Statement from "effect/unstable/sql/Statement"; | |
| 23 | + | |
| 24 | +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; | |
| 25 | +import { | |
| 26 | + ProjectionStoreV2, | |
| 27 | + layer as projectionStoreLayer, | |
| 28 | + layerMemory, | |
| 29 | +} from "./ProjectionStore.ts"; | |
| 30 | +import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; | |
| 31 | + | |
| 32 | +const SqlLayer = projectionStoreLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)); | |
| 33 | +const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); | |
| 34 | +const old = DateTime.subtract(now, { days: 10 }); | |
| 35 | +const providerInstanceId = ProviderInstanceId.make("codex"); | |
| 36 | +const modelSelection = { instanceId: providerInstanceId, model: "test-model" }; | |
| 37 | +const encodeNumbers = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.Number))); | |
| 38 | + | |
| 39 | +const createThread = Effect.fn(function* ( | |
| 40 | + name: string, | |
| 41 | + overrides: Partial<OrchestrationV2AppThread> = {}, | |
| 42 | +) { | |
| 43 | + const store = yield* ProjectionStoreV2; | |
| 44 | + const threadId = ThreadId.make(`thread:settlement:${name}`); | |
| 45 | + const thread: OrchestrationV2AppThread = { | |
| 46 | + createdBy: "user", | |
| 47 | + creationSource: "web", | |
| 48 | + id: threadId, | |
| 49 | + projectId: ProjectId.make("project:settlement"), | |
| 50 | + title: name, | |
| 51 | + providerInstanceId, | |
| 52 | + modelSelection, | |
| 53 | + runtimeMode: "full-access", | |
| 54 | + interactionMode: "default", | |
| 55 | + branch: "feature", | |
| 56 | + worktreePath: null, | |
| 57 | + activeProviderThreadId: null, | |
| 58 | + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 59 | + forkedFrom: null, | |
| 60 | + createdAt: old, | |
| 61 | + updatedAt: old, | |
| 62 | + archivedAt: null, | |
| 63 | + settledOverride: null, | |
| 64 | + settledAt: null, | |
| 65 | + lastVisitedAt: null, | |
| 66 | + deletedAt: null, | |
| 67 | + ...overrides, | |
| 68 | + }; | |
| 69 | + yield* store.apply({ | |
| 70 | + id: EventId.make(`event:${threadId}`), | |
| 71 | + type: "thread.created", | |
| 72 | + threadId, | |
| 73 | + occurredAt: old, | |
| 74 | + payload: thread, | |
| 75 | + }); | |
| 76 | + return threadId; | |
| 77 | +}); | |
| 78 | + | |
| 79 | +const createRun = Effect.fn(function* ( | |
| 80 | + threadId: ThreadId, | |
| 81 | + status: OrchestrationV2Run["status"] = "completed", | |
| 82 | + ordinal = 1, | |
| 83 | +) { | |
| 84 | + const store = yield* ProjectionStoreV2; | |
| 85 | + const runId = RunId.make(`run:${threadId}:${ordinal}`); | |
| 86 | + yield* store.apply({ | |
| 87 | + id: EventId.make(`event:${runId}`), | |
| 88 | + type: "run.created", | |
| 89 | + threadId, | |
| 90 | + occurredAt: old, | |
| 91 | + payload: { | |
| 92 | + id: runId, | |
| 93 | + threadId, | |
| 94 | + ordinal, | |
| 95 | + providerInstanceId, | |
| 96 | + modelSelection, | |
| 97 | + providerThreadId: null, | |
| 98 | + userMessageId: MessageId.make(`message:${runId}`), | |
| 99 | + rootNodeId: null, | |
| 100 | + activeAttemptId: null, | |
| 101 | + status, | |
| 102 | + requestedAt: old, | |
| 103 | + startedAt: status === "queued" ? null : old, | |
| 104 | + completedAt: ["completed", "rolled_back", "failed"].includes(status) ? old : null, | |
| 105 | + checkpointId: null, | |
| 106 | + contextHandoffId: null, | |
| 107 | + }, | |
| 108 | + }); | |
| 109 | + return runId; | |
| 110 | +}); | |
| 111 | + | |
| 112 | +const createItem = Effect.fn(function* ( | |
| 113 | + threadId: ThreadId, | |
| 114 | + runId: RunId, | |
| 115 | + status: "idle" | "running" | "completed", | |
| 116 | + persistent = false, | |
| 117 | +) { | |
| 118 | + const store = yield* ProjectionStoreV2; | |
| 119 | + yield* store.apply({ | |
| 120 | + id: EventId.make(`event:item:${runId}`), | |
| 121 | + type: "turn-item.updated", | |
| 122 | + threadId, | |
| 123 | + occurredAt: old, | |
| 124 | + payload: { | |
| 125 | + id: TurnItemId.make(`item:${runId}`), | |
| 126 | + threadId, | |
| 127 | + runId, | |
| 128 | + nodeId: null, | |
| 129 | + providerThreadId: null, | |
| 130 | + providerTurnId: null, | |
| 131 | + nativeItemRef: null, | |
| 132 | + parentItemId: null, | |
| 133 | + ordinal: 1, | |
| 134 | + type: "dynamic_tool", | |
| 135 | + status, | |
| 136 | + title: null, | |
| 137 | + startedAt: old, | |
| 138 | + completedAt: status === "completed" ? old : null, | |
| 139 | + updatedAt: old, | |
| 140 | + toolName: "background", | |
| 141 | + input: persistent ? { persistent: true } : null, | |
| 142 | + output: null, | |
| 143 | + }, | |
| 144 | + }); | |
| 145 | +}); | |
| 146 | + | |
| 147 | +for (const [name, testLayer] of [ | |
| 148 | + ["sql", SqlLayer], | |
| 149 | + ["memory", layerMemory], | |
| 150 | +] as const) { | |
| 151 | + it.effect( | |
| 152 | + `${name}: discovers settlement work with the same activity and background semantics as the shell`, | |
| 153 | + () => | |
| 154 | + Effect.gen(function* () { | |
| 155 | + const store = yield* ProjectionStoreV2; | |
| 156 | + const idle = yield* createThread("idle"); | |
| 157 | + const completed = yield* createThread("completed"); | |
| 158 | + yield* createRun(completed); | |
| 159 | + for (const [name, overrides] of [ | |
| 160 | + ["archived", { archivedAt: old }], | |
| 161 | + ["deleted", { deletedAt: old }], | |
| 162 | + ["settled", { settledOverride: "settled" }], | |
| 163 | + ["unsettled", { settledOverride: "active" }], | |
| 164 | + ["pinned", { pinnedAt: old }], | |
| 165 | + ] satisfies ReadonlyArray<readonly [string, Partial<OrchestrationV2AppThread>]>) { | |
| 166 | + yield* createRun(yield* createThread(name, overrides)); | |
| 167 | + } | |
| 168 | + for (const status of ["preparing", "starting", "running", "waiting"] as const) { | |
| 169 | + yield* createRun(yield* createThread(status), status); | |
| 170 | + } | |
| 171 | + const queued = yield* createThread("queued"); | |
| 172 | + yield* createRun(queued, "queued"); | |
| 173 | + const blocked = yield* createThread("blocked"); | |
| 174 | + yield* store.apply({ | |
| 175 | + id: EventId.make("event:settlement:request"), | |
| 176 | + type: "runtime-request.updated", | |
| 177 | + threadId: blocked, | |
| 178 | + occurredAt: old, | |
| 179 | + payload: { | |
| 180 | + id: RuntimeRequestId.make("request:settlement"), | |
| 181 | + nodeId: NodeId.make("node:settlement"), | |
| 182 | + providerTurnId: null, | |
| 183 | + nativeRequestRef: null, | |
| 184 | + kind: "user_input", | |
| 185 | + status: "pending", | |
| 186 | + responseCapability: { type: "not_resumable", reason: "Process stopped" }, | |
| 187 | + createdAt: old, | |
| 188 | + resolvedAt: null, | |
| 189 | + }, | |
| 190 | + }); | |
| 191 | + const recent = yield* createThread("recent-message"); | |
| 192 | + yield* createRun(recent); | |
| 193 | + yield* store.apply({ | |
| 194 | + id: EventId.make("event:settlement:message"), | |
| 195 | + type: "message.updated", | |
| 196 | + threadId: recent, | |
| 197 | + occurredAt: now, | |
| 198 | + payload: { | |
| 199 | + createdBy: "user", | |
| 200 | + creationSource: "web", | |
| 201 | + id: MessageId.make("message:settlement:recent"), | |
| 202 | + threadId: recent, | |
| 203 | + runId: null, | |
| 204 | + nodeId: null, | |
| 205 | + role: "user", | |
| 206 | + text: "Next turn", | |
| 207 | + attachments: [], | |
| 208 | + streaming: false, | |
| 209 | + createdAt: now, | |
| 210 | + updatedAt: now, | |
| 211 | + }, | |
| 212 | + }); | |
| 213 | + yield* createRun( | |
| 214 | + yield* createThread("snoozed", { | |
| 215 | + snoozedUntil: DateTime.add(now, { days: 1 }), | |
| 216 | + snoozedAt: now, | |
| 217 | + }), | |
| 218 | + ); | |
| 219 | + const woke = yield* createThread("woke", { | |
| 220 | + snoozedUntil: DateTime.add(now, { days: 1 }), | |
| 221 | + snoozedAt: DateTime.subtract(old, { days: 1 }), | |
| 222 | + }); | |
| 223 | + yield* createRun(woke); | |
| 224 | + const background = yield* createThread("idle-background"); | |
| 225 | + yield* createItem(background, yield* createRun(background), "idle"); | |
| 226 | + const persistent = yield* createThread("persistent-monitor"); | |
| 227 | + yield* createItem(persistent, yield* createRun(persistent), "running", true); | |
| 228 | + const rolledBack = yield* createThread("rolled-back-background"); | |
| 229 | + yield* createItem(rolledBack, yield* createRun(rolledBack, "rolled_back"), "running"); | |
| 230 | + yield* createRun(rolledBack, "completed", 2); | |
| 231 | + const roster = yield* createThread("provider-roster"); | |
| 232 | + yield* createRun(roster); | |
| 233 | + yield* store.apply({ | |
| 234 | + id: EventId.make("event:settlement:roster"), | |
| 235 | + type: "provider-thread.updated", | |
| 236 | + threadId: roster, | |
| 237 | + occurredAt: old, | |
| 238 | + payload: { | |
| 239 | + id: ProviderThreadId.make("provider-thread:settlement"), | |
| 240 | + appThreadId: roster, | |
| 241 | + ownerNodeId: null, | |
| 242 | + driver: ProviderDriverKind.make("codex"), | |
| 243 | + providerInstanceId, | |
| 244 | + providerSessionId: null, | |
| 245 | + nativeThreadRef: null, | |
| 246 | + nativeConversationHeadRef: null, | |
| 247 | + status: "idle", | |
| 248 | + firstRunOrdinal: null, | |
| 249 | + lastRunOrdinal: null, | |
| 250 | + handoffIds: [], | |
| 251 | + forkedFrom: null, | |
| 252 | + createdAt: old, | |
| 253 | + updatedAt: old, | |
| 254 | + pendingBackgroundTasks: [{ taskId: "running-task" }], | |
| 255 | + }, | |
| 256 | + }); | |
| 257 | + const candidates = yield* store.getSettlementCandidates(); | |
| 258 | + const shell = yield* store.getShellSnapshot({ location: "active" }); | |
| 259 | + const eligible = candidates.filter((thread) => | |
| 260 | + isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now)), | |
| 261 | + ); | |
| 262 | + assert.deepEqual( | |
| 263 | + new Set(eligible.map((thread) => thread.id)), | |
| 264 | + new Set([idle, completed, queued, woke, persistent, rolledBack]), | |
| 265 | + ); | |
| 266 | + assert.deepEqual( | |
| 267 | + new Set(eligible.map((thread) => thread.id)), | |
| 268 | + new Set( | |
| 269 | + shell.threads | |
| 270 | + .filter((thread) => isAutoSettlementCandidate(thread, DateTime.toEpochMillis(now))) | |
| 271 | + .map((thread) => thread.id), | |
| 272 | + ), | |
| 273 | + ); | |
| 274 | + for (const candidate of candidates) { | |
| 275 | + const expected = shell.threads.find((thread) => thread.id === candidate.id)!; | |
| 276 | + assert.deepEqual(candidate.pendingBackgroundTasks, expected.pendingBackgroundTasks); | |
| 277 | + const settings = { | |
| 278 | + pullRequest: null, | |
| 279 | + nowMs: DateTime.toEpochMillis(now), | |
| 280 | + autoSettleAfterDays: 7, | |
| 281 | + autoSettleOnMerge: false, | |
| 282 | + }; | |
| 283 | + assert.deepEqual( | |
| 284 | + resolveAutoSettlementAt({ ...settings, thread: candidate }), | |
| 285 | + resolveAutoSettlementAt({ ...settings, thread: expected }), | |
| 286 | + ); | |
| 287 | + } | |
| 288 | + assert.equal( | |
| 289 | + DateTime.formatIso((yield* store.getThread(completed)).createdAt), | |
| 290 | + DateTime.formatIso(old), | |
| 291 | + ); | |
| 292 | + assert.equal( | |
| 293 | + (yield* store.getThread(ThreadId.make("missing")).pipe(Effect.flip))._tag, | |
| 294 | + "ProjectionStoreThreadNotFoundError", | |
| 295 | + ); | |
| 296 | + }).pipe(Effect.provide(testLayer)), | |
| 297 | + ); | |
| 298 | +} | |
| 299 | + | |
| 300 | +it.effect( | |
| 301 | + "reads settlement candidates and thread metadata without loading historical or archived payloads", | |
| 302 | + () => | |
| 303 | + Effect.gen(function* () { | |
| 304 | + const store = yield* ProjectionStoreV2; | |
| 305 | + const sql = yield* SqlClient.SqlClient; | |
| 306 | + const source = yield* createThread("historical-source", { archivedAt: old }); | |
| 307 | + const sourceRun = yield* createRun(source); | |
| 308 | + const threadId = yield* createThread("fork", { | |
| 309 | + forkedFrom: { type: "run", threadId: source, runId: sourceRun }, | |
| 310 | + }); | |
| 311 | + const runId = yield* createRun(threadId); | |
| 312 | + yield* createItem(threadId, runId, "completed"); | |
| 313 | + yield* sql`UPDATE orchestration_v2_projection_turn_items SET payload_json = '{broken' WHERE thread_id = ${threadId}`; | |
| 314 | + yield* sql`UPDATE orchestration_v2_projection_runs SET payload_json = '{broken' WHERE thread_id = ${source}`; | |
| 315 | + yield* sql`INSERT INTO orchestration_v2_projection_turn_items | |
| 316 | + SELECT 'history:' || value, i.thread_id, i.run_id, i.node_id, i.provider_thread_id, i.provider_turn_id, | |
| 317 | + i.parent_item_id, i.ordinal + value, i.type, i.status, i.updated_at, i.payload_json | |
| 318 | + FROM orchestration_v2_projection_turn_items i, json_each(${encodeNumbers(Array.from({ length: 2000 }, (_, i) => i + 1))}) | |
| 319 | + WHERE i.thread_id = ${threadId}`; | |
| 320 | + const queries: Array<readonly [string, ReadonlyArray<unknown>]> = []; | |
| 321 | + const record: Statement.Transformer = (statement) => | |
| 322 | + Effect.sync(() => { | |
| 323 | + queries.push(statement.compile()); | |
| 324 | + return statement; | |
| 325 | + }); | |
| 326 | + const candidates = yield* store | |
| 327 | + .getSettlementCandidates() | |
| 328 | + .pipe(Effect.provideService(Statement.CurrentTransformer, record)); | |
| 329 | + assert.deepEqual( | |
| 330 | + candidates.map((thread) => thread.id), | |
| 331 | + [threadId], | |
| 332 | + ); | |
| 333 | + assert.isTrue(queries.every(([query]) => !query.includes("COUNT("))); | |
| 334 | + const pendingQuery = queries.find(([query]) => | |
| 335 | + query.includes("FROM orchestration_v2_projection_turn_items"), | |
| 336 | + ); | |
| 337 | + assert.isDefined(pendingQuery); | |
| 338 | + const metadata = yield* store.getThread(threadId); | |
| 339 | + assert.equal(metadata.id, threadId); | |
| 340 | + assert.equal((yield* store.getThreadProjection(threadId).pipe(Effect.exit))._tag, "Failure"); | |
| 341 | + const plan = yield* sql.unsafe<{ readonly detail: string }>( | |
| 342 | + `EXPLAIN QUERY PLAN ${pendingQuery![0]}`, | |
| 343 | + pendingQuery![1], | |
| 344 | + ); | |
| 345 | + assert.isTrue(plan.some((row) => row.detail.includes("turn_items_shell_pending_idx"))); | |
| 346 | + const countsPlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN | |
| 347 | + SELECT thread_id, run_id, COUNT(*) FROM orchestration_v2_projection_turn_items | |
| 348 | + WHERE thread_id = ${threadId} AND run_id IS NOT NULL GROUP BY thread_id, run_id`; | |
| 349 | + assert.isTrue( | |
| 350 | + countsPlan.some((row) => | |
| 351 | + row.detail.includes( | |
| 352 | + "COVERING INDEX orchestration_v2_projection_turn_items_thread_run_idx", | |
| 353 | + ), | |
| 354 | + ), | |
| 355 | + ); | |
| 356 | + assert.isFalse(countsPlan.some((row) => row.detail.includes("TEMP B-TREE"))); | |
| 357 | + const messagePlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN | |
| 358 | + SELECT updated_at FROM orchestration_v2_projection_messages | |
| 359 | + WHERE thread_id = ${threadId} AND role = 'user' ORDER BY updated_at DESC, message_id DESC LIMIT 1`; | |
| 360 | + assert.isTrue(messagePlan.some((row) => row.detail.includes("messages_latest_user_idx"))); | |
| 361 | + assert.isFalse(messagePlan.some((row) => row.detail.includes("TEMP B-TREE"))); | |
| 362 | + }).pipe(Effect.provide(SqlLayer)), | |
| 363 | +); | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | + OrchestrationV2AppThread, | |
| 2 | 3 | OrchestrationV2ConversationMessage, |
| 3 | 4 | OrchestrationV2DomainEvent, |
| 4 | 5 | OrchestrationV2ProjectedTurnItem, |
| @@ -106,6 +107,32 @@ export type ProjectionRecoveryKind = · 101 unmodified lines | ||
| 106 | 107 | | "subagent-results" |
| 107 | 108 | | "delegated-completions"; |
| 108 | 109 | |
| 110 | +/** Thread activity needed by settlement, without transcript or fork history. */ | |
| 111 | +export type ProjectionSettlementCandidate = Pick< | |
| 112 | + OrchestrationV2ThreadShell, | |
| 113 | + | "id" | |
| 114 | + | "projectId" | |
| 115 | + | "branch" | |
| 116 | + | "worktreePath" | |
| 117 | + | "linkedPullRequest" | |
| 118 | + | "createdAt" | |
| 119 | + | "updatedAt" | |
| 120 | + | "archivedAt" | |
| 121 | + | "settledOverride" | |
| 122 | + | "pinnedAt" | |
| 123 | + | "snoozedUntil" | |
| 124 | + | "snoozedAt" | |
| 125 | + | "latestRunId" | |
| 126 | + | "latestRunRequestedAt" | |
| 127 | + | "latestRunStartedAt" | |
| 128 | + | "latestRunCompletedAt" | |
| 129 | + | "latestUserMessageAt" | |
| 130 | + | "status" | |
| 131 | + | "activityRunStatus" | |
| 132 | + | "pendingRuntimeRequest" | |
| 133 | + | "pendingBackgroundTasks" | |
| 134 | +>; | |
| 135 | + | |
| 109 | 136 | const ProjectionCheckpointContext = Schema.Struct({ |
| 110 | 137 | runs: Schema.Array( |
| 111 | 138 | OrchestrationV2RunJsonSchema.mapFields(({ id, ordinal, status }) => ({ id, ordinal, status })), |
| @@ -143,6 +170,13 @@ export interface ProjectionStoreV2Shape { · 31 unmodified lines | ||
| 143 | 170 | readonly getThreadShell: ( |
| 144 | 171 | threadId: ThreadId, |
| 145 | 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 | 180 | readonly getThreadProjection: ( |
| 147 | 181 | threadId: ThreadId, |
| 148 | 182 | ) => Effect.Effect<OrchestrationV2ThreadProjection, ProjectionStoreV2Error>; |
| @@ -585,6 +619,18 @@ type ShellRunRow = { · 436 unmodified lines | ||
| 585 | 619 | readonly ordinal: number; |
| 586 | 620 | }; |
| 587 | 621 | |
| 622 | +type SettlementThreadRow = Pick< | |
| 623 | + ShellThreadRow, | |
| 624 | + | "thread_id" | |
| 625 | + | "payload_json" | |
| 626 | + | "latest_run_id" | |
| 627 | + | "latest_run_status" | |
| 628 | + | "latest_run_requested_at" | |
| 629 | + | "latest_run_started_at" | |
| 630 | + | "latest_run_completed_at" | |
| 631 | + | "latest_user_message_at" | |
| 632 | +>; | |
| 633 | + | |
| 588 | 634 | type ShellRunItemCountRow = { |
| 589 | 635 | readonly thread_id: string; |
| 590 | 636 | readonly run_id: string; |
| @@ -2954,6 +3000,24 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 2363 unmodified lines | ||
| 2954 | 3000 | const getThreadProjection: ProjectionStoreV2Shape["getThreadProjection"] = (threadId) => |
| 2955 | 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 | 3021 | const getCheckpointContext: ProjectionStoreV2Shape["getCheckpointContext"] = (threadId) => |
| 2958 | 3022 | sql |
| 2959 | 3023 | .withTransaction( |
| @@ -3320,6 +3384,104 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = · 360 unmodified lines | ||
| 3320 | 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 | 3485 | const shellThreadStateFromRow = (input: { |
| 3324 | 3486 | readonly row: ShellThreadRow; |
| 3325 | 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 | 3728 | apply, |
| 3567 | 3729 | getShellSnapshot, |
| 3568 | 3730 | getThreadShell, |
| 3731 | + getThread, | |
| 3732 | + getSettlementCandidates, | |
| 3569 | 3733 | getThreadProjection, |
| 3570 | 3734 | getCheckpointContext, |
| 3571 | 3735 | getRecoveryThreadIds, |
| @@ -3638,6 +3802,34 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( · 66 unmodified lines | ||
| 3638 | 3802 | .pipe(Effect.map(threadShellFromProjection)); |
| 3639 | 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 | 3833 | getRecoveryThreadIds: (kind) => |
| 3642 | 3834 | Effect.gen(function* () { |
| 3643 | 3835 | const projections = (yield* Ref.get(replayState)).projections; |
| @@ -216,6 +216,8 @@ it.effect( · 215 unmodified lines | ||
| 216 | 216 | apply: () => Effect.void, |
| 217 | 217 | getShellSnapshot: () => Effect.die("unused getShellSnapshot"), |
| 218 | 218 | getThreadShell: () => Effect.die("unused getThreadShell"), |
| 219 | + getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), | |
| 220 | + getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), | |
| 219 | 221 | getThreadProjection: () => Ref.get(projection), |
| 220 | 222 | getCheckpointContext: () => Effect.die("not used"), |
| 221 | 223 | getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"), |
| @@ -34,6 +34,7 @@ import { ServerActivation } from "../serverActivation.ts"; · 33 unmodified lines | ||
| 34 | 34 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 35 | 35 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 36 | 36 | import { OrchestratorV2, type OrchestratorV2Shape } from "./Orchestrator.ts"; |
| 37 | +import { ProjectionStoreV2 } from "./ProjectionStore.ts"; | |
| 37 | 38 | import * as ThreadSettlementService from "./ThreadSettlementService.ts"; |
| 38 | 39 | |
| 39 | 40 | import { |
| @@ -417,12 +418,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: · 377 unmodified lines | ||
| 417 | 418 | getProjectShellsWithoutEnrichment: () => |
| 418 | 419 | Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)), |
| 419 | 420 | }), |
| 420 | − Layer.mock(OrchestratorV2)({ | |
| 421 | − getShellSnapshot: () => | |
| 421 | + Layer.mock(ProjectionStoreV2)({ | |
| 422 | + getSettlementCandidates: () => | |
| 422 | 423 | Ref.updateAndGet(snapshotReadCount, (count) => count + 1).pipe( |
| 423 | 424 | Effect.tap((count) => Queue.offer(snapshotReads, count)), |
| 424 | 425 | Effect.andThen(Ref.get(snapshots)), |
| 426 | + Effect.map((snapshot) => snapshot.threads), | |
| 425 | 427 | ), |
| 428 | + }), | |
| 429 | + Layer.mock(OrchestratorV2)({ | |
| 426 | 430 | dispatch, |
| 427 | 431 | }), |
| 428 | 432 | Layer.mock(GitManager)({ |
| @@ -17,6 +17,7 @@ import * as ServerSettings from "../serverSettings.ts"; · 16 unmodified lines | ||
| 17 | 17 | import { forkParked } from "../serverActivation.ts"; |
| 18 | 18 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 19 | 19 | import { OrchestratorV2 } from "./Orchestrator.ts"; |
| 20 | +import { ProjectionStoreV2, type ProjectionSettlementCandidate } from "./ProjectionStore.ts"; | |
| 20 | 21 | |
| 21 | 22 | export interface SettlementPullRequest { |
| 22 | 23 | readonly state: "open" | "closed" | "merged"; |
| @@ -94,7 +95,7 @@ function pullRequestSettles( · 71 unmodified lines | ||
| 94 | 95 | |
| 95 | 96 | /** Cheap checks that run before any source control lookup. */ |
| 96 | 97 | export function isAutoSettlementCandidate( |
| 97 | − thread: OrchestrationV2ThreadShell, | |
| 98 | + thread: ProjectionSettlementCandidate, | |
| 98 | 99 | nowMs: number, |
| 99 | 100 | ): boolean { |
| 100 | 101 | if (thread.archivedAt !== null || thread.settledOverride !== null) return false; |
| @@ -118,7 +119,7 @@ export function isAutoSettlementCandidate( · 17 unmodified lines | ||
| 118 | 119 | } |
| 119 | 120 | |
| 120 | 121 | export function resolveAutoSettlementAt(input: { |
| 121 | − readonly thread: OrchestrationV2ThreadShell; | |
| 122 | + readonly thread: ProjectionSettlementCandidate; | |
| 122 | 123 | readonly pullRequest: SettlementPullRequest | null; |
| 123 | 124 | readonly nowMs: number; |
| 124 | 125 | readonly autoSettleAfterDays: number | null; |
| @@ -151,6 +152,7 @@ export class ThreadSettlementServiceV2 extends Context.Service< · 26 unmodified lines | ||
| 151 | 152 | |
| 152 | 153 | export const make = Effect.gen(function* () { |
| 153 | 154 | const orchestrator = yield* OrchestratorV2; |
| 155 | + const projections = yield* ProjectionStoreV2; | |
| 154 | 156 | const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 155 | 157 | const settingsService = yield* ServerSettings.ServerSettingsService; |
| 156 | 158 | const git = yield* GitManager.GitManager; |
| @@ -161,7 +163,7 @@ export const make = Effect.gen(function* () { · 4 unmodified lines | ||
| 161 | 163 | const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( |
| 162 | 164 | mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, |
| 163 | 165 | ) { |
| 164 | − const snapshot = yield* orchestrator.getShellSnapshot(); | |
| 166 | + const threads = yield* projections.getSettlementCandidates(); | |
| 165 | 167 | const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment(); |
| 166 | 168 | const nowMs = DateTime.toEpochMillis(yield* DateTime.now); |
| 167 | 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 | 171 | // the merged pull request: most threads carry no link and settle from |
| 170 | 172 | // their branch lookup, which would otherwise wait for the next minute's |
| 171 | 173 | // sweep on a possibly stale cached answer. |
| 172 | − const candidates = snapshot.threads.filter((thread) => | |
| 173 | − isAutoSettlementCandidate(thread, nowMs), | |
| 174 | − ); | |
| 174 | + const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); | |
| 175 | 175 | // Use the same cwd as the sidebar so both paths share GitManager's PR cache. |
| 176 | 176 | const lookupCwdByThreadId = new Map<string, string>(); |
| 177 | 177 | yield* Effect.forEach( |
| @@ -1634,6 +1634,38 @@ it.layer(SharedApplicationDataPlaneTestLayer)("visited projection", (it) => { · 1633 unmodified lines | ||
| 1634 | 1634 | }) |
| 1635 | 1635 | .pipe(Effect.flip); |
| 1636 | 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 | }); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -33,7 +33,7 @@ · 27 unmodified lines | ||
| 33 | 33 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","status":{"type":"idle"}}}} |
| 34 | 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 | 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 | 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 | 38 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 39 | 39 | {"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"threadId":"019db117-b50d-7a01-a6b6-65e47d749c3e","excludeTurns":true}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -1,5 +1,5 @@ | ||
| 1 | 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 | 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 | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{}}} |
| @@ -70,6 +70,7 @@ import Migration0055 from "./Migrations/055_ScheduledTasks.ts"; · 69 unmodified lines | ||
| 70 | 70 | import Migration0056 from "./Migrations/056_LegacyV1ImportState.ts"; |
| 71 | 71 | import Migration0057 from "./Migrations/057_ApplicationEventSequenceIndexes.ts"; |
| 72 | 72 | import Migration0058 from "./Migrations/058_OrchestrationV2RecoveryIndexes.ts"; |
| 73 | +import Migration0059 from "./Migrations/059_OrchestrationV2ShellIndexes.ts"; | |
| 73 | 74 | |
| 74 | 75 | /** |
| 75 | 76 | * Migration loader with all migrations defined inline. |
| @@ -140,6 +141,7 @@ export const migrationEntries = [ · 64 unmodified lines | ||
| 140 | 141 | [56, "LegacyV1ImportState", Migration0056], |
| 141 | 142 | [57, "ApplicationEventSequenceIndexes", Migration0057], |
| 142 | 143 | [58, "OrchestrationV2RecoveryIndexes", Migration0058], |
| 144 | + [59, "OrchestrationV2ShellIndexes", Migration0059], | |
| 143 | 145 | ] as const; |
| 144 | 146 | |
| 145 | 147 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
| @@ -0,0 +1,24 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | +import * as SqlClient from "effect/unstable/sql/SqlClient"; | |
| 3 | + | |
| 4 | +export default Effect.gen(function* () { | |
| 5 | + const sql = yield* SqlClient.SqlClient; | |
| 6 | + | |
| 7 | + yield* sql` | |
| 8 | + CREATE INDEX orchestration_v2_projection_turn_items_thread_run_idx | |
| 9 | + ON orchestration_v2_projection_turn_items(thread_id, run_id) | |
| 10 | + `; | |
| 11 | + // Shell background work includes idle items as well as pending/running/waiting. | |
| 12 | + // Keep this predicate aligned with ProjectionStore's pending-item query. | |
| 13 | + yield* sql` | |
| 14 | + CREATE INDEX orchestration_v2_projection_turn_items_shell_pending_idx | |
| 15 | + ON orchestration_v2_projection_turn_items(thread_id, run_id) | |
| 16 | + WHERE type IN ('command_execution', 'dynamic_tool', 'subagent') | |
| 17 | + AND status NOT IN ('completed', 'interrupted', 'failed', 'cancelled') | |
| 18 | + `; | |
| 19 | + yield* sql` | |
| 20 | + CREATE INDEX orchestration_v2_projection_messages_latest_user_idx | |
| 21 | + ON orchestration_v2_projection_messages(thread_id, updated_at DESC, message_id DESC) | |
| 22 | + WHERE role = 'user' | |
| 23 | + `; | |
| 24 | +}); | |
| @@ -319,6 +319,22 @@ describe("EventNdjsonLogger", () => { · 318 unmodified lines | ||
| 319 | 319 | { method: "thread/realtime/transcript/delta", payload: circularDelta }, |
| 320 | 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 | 338 | yield* native.write( |
| 323 | 339 | { |
| 324 | 340 | event: { |
| @@ -57,6 +57,7 @@ const transientNativeMethods = new Set([ · 56 unmodified lines | ||
| 57 | 57 | "item/reasoning/textDelta", |
| 58 | 58 | "thread/realtime/outputAudio/delta", |
| 59 | 59 | "thread/realtime/transcript/delta", |
| 60 | + "turn/diff/updated", | |
| 60 | 61 | ]); |
| 61 | 62 | const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); |
| 62 | 63 | |
| @@ -0,0 +1,573 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; | |
| 3 | +import { | |
| 4 | + EnvironmentId, | |
| 5 | + type OrchestrationV2ThreadShell, | |
| 6 | + ProjectId, | |
| 7 | + ProviderInstanceId, | |
| 8 | + ThreadId, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import { RelayAgentActivityState } from "@t3tools/contracts/relay"; | |
| 11 | +import * as DateTime from "effect/DateTime"; | |
| 12 | +import * as Deferred from "effect/Deferred"; | |
| 13 | +import * as Effect from "effect/Effect"; | |
| 14 | +import * as Fiber from "effect/Fiber"; | |
| 15 | +import * as Option from "effect/Option"; | |
| 16 | +import * as Ref from "effect/Ref"; | |
| 17 | +import * as Schema from "effect/Schema"; | |
| 18 | +import * as Stream from "effect/Stream"; | |
| 19 | +import * as TestClock from "effect/testing/TestClock"; | |
| 20 | +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; | |
| 21 | + | |
| 22 | +import { SecretStoreReadError, ServerSecretStore } from "../auth/ServerSecretStore.ts"; | |
| 23 | +import { | |
| 24 | + PUBLISH_AGENT_ACTIVITY_SECRET, | |
| 25 | + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | |
| 26 | + RELAY_ISSUER_SECRET, | |
| 27 | + RELAY_URL_SECRET, | |
| 28 | +} from "../cloud/config.ts"; | |
| 29 | +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; | |
| 30 | +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; | |
| 31 | +import { ProjectService } from "../project/ProjectService.ts"; | |
| 32 | +import { | |
| 33 | + make, | |
| 34 | + makeAgentAwarenessPublishWorker, | |
| 35 | + shouldPublishAgentAwarenessEvent, | |
| 36 | +} from "./AgentAwarenessRelay.ts"; | |
| 37 | + | |
| 38 | +const THREAD_ID = ThreadId.make("relay-thread"); | |
| 39 | +const SECOND_THREAD_ID = ThreadId.make("relay-thread-2"); | |
| 40 | +const PROJECT_ID = ProjectId.make("relay-project"); | |
| 41 | +const NOW = "2026-09-04T12:00:00.000Z"; | |
| 42 | + | |
| 43 | +function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell { | |
| 44 | + return { | |
| 45 | + id: THREAD_ID, | |
| 46 | + projectId: PROJECT_ID, | |
| 47 | + title: "Thread", | |
| 48 | + providerInstanceId: ProviderInstanceId.make("codex"), | |
| 49 | + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, | |
| 50 | + runtimeMode: "full-access", | |
| 51 | + interactionMode: "default", | |
| 52 | + worktreePath: null, | |
| 53 | + activeProviderThreadId: null, | |
| 54 | + lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null }, | |
| 55 | + forkedFrom: null, | |
| 56 | + createdBy: "user", | |
| 57 | + creationSource: "web", | |
| 58 | + activeRunId: null, | |
| 59 | + latestVisibleMessage: null, | |
| 60 | + hasActionableProposedPlan: false, | |
| 61 | + itemCount: 0, | |
| 62 | + visibleItemCount: 0, | |
| 63 | + lastVisitedAt: null, | |
| 64 | + deletedAt: null, | |
| 65 | + branch: null, | |
| 66 | + linkedPullRequest: null, | |
| 67 | + status: "running", | |
| 68 | + activityRunStatus: null, | |
| 69 | + pendingRuntimeRequest: null, | |
| 70 | + pendingBackgroundTasks: [], | |
| 71 | + latestRunId: null, | |
| 72 | + latestRunRequestedAt: null, | |
| 73 | + latestRunStartedAt: null, | |
| 74 | + latestRunCompletedAt: null, | |
| 75 | + latestUserMessageAt: null, | |
| 76 | + createdAt: DateTime.makeUnsafe(NOW), | |
| 77 | + updatedAt: DateTime.makeUnsafe(NOW), | |
| 78 | + archivedAt: null, | |
| 79 | + settledOverride: null, | |
| 80 | + settledAt: null, | |
| 81 | + snoozedUntil: null, | |
| 82 | + snoozedAt: null, | |
| 83 | + pinnedAt: null, | |
| 84 | + ...overrides, | |
| 85 | + }; | |
| 86 | +} | |
| 87 | + | |
| 88 | +const PublishPayload = Schema.Struct({ state: Schema.NullOr(RelayAgentActivityState) }); | |
| 89 | +const decodePublishPayload = Schema.decodeUnknownSync(Schema.fromJsonString(PublishPayload)); | |
| 90 | +const unused = () => Effect.die("Unexpected test dependency call"); | |
| 91 | + | |
| 92 | +const makeTestRelay = Effect.fnUntraced(function* ( | |
| 93 | + options: { | |
| 94 | + readonly respond?: (attempt: number) => Response; | |
| 95 | + readonly failSecretRead?: (name: string) => boolean; | |
| 96 | + } = {}, | |
| 97 | +) { | |
| 98 | + const values = new Map<string, Uint8Array>([ | |
| 99 | + [PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")], | |
| 100 | + [RELAY_URL_SECRET, new TextEncoder().encode("https://relay.example.test")], | |
| 101 | + [RELAY_ISSUER_SECRET, new TextEncoder().encode("https://relay.example.test")], | |
| 102 | + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, new TextEncoder().encode("credential-1")], | |
| 103 | + ]); | |
| 104 | + const secretReads: string[] = []; | |
| 105 | + const secrets = ServerSecretStore.of({ | |
| 106 | + get: (name) => | |
| 107 | + Effect.suspend(() => { | |
| 108 | + secretReads.push(name); | |
| 109 | + if (options.failSecretRead?.(name)) { | |
| 110 | + return Effect.fail( | |
| 111 | + new SecretStoreReadError({ resource: name, cause: "temporary read failure" }), | |
| 112 | + ); | |
| 113 | + } | |
| 114 | + return Effect.succeed(Option.fromUndefinedOr(values.get(name))); | |
| 115 | + }), | |
| 116 | + create: (name, value) => Effect.sync(() => void values.set(name, value)), | |
| 117 | + set: (name, value) => Effect.sync(() => void values.set(name, value)), | |
| 118 | + remove: (name) => Effect.sync(() => void values.delete(name)), | |
| 119 | + getOrCreateRandom: unused, | |
| 120 | + }); | |
| 121 | + const currentShell = yield* Ref.make<OrchestrationV2ThreadShell | null>(shell()); | |
| 122 | + const shellReads: ThreadId[] = []; | |
| 123 | + const threads = ThreadManagementService.of({ | |
| 124 | + getThreadShell: (threadId) => | |
| 125 | + Effect.sync(() => shellReads.push(threadId)).pipe(Effect.andThen(Ref.get(currentShell))), | |
| 126 | + getShellSnapshot: unused, | |
| 127 | + ensureLegacyTranscript: unused, | |
| 128 | + dispatch: unused, | |
| 129 | + getThreadProjection: unused, | |
| 130 | + getCheckpointContext: unused, | |
| 131 | + getThreadSnapshot: unused, | |
| 132 | + getThreadSnapshotWindow: unused, | |
| 133 | + getProjectThread: unused, | |
| 134 | + listProjectThreads: unused, | |
| 135 | + sendToThread: unused, | |
| 136 | + waitForThread: unused, | |
| 137 | + interruptThread: unused, | |
| 138 | + getThreadEventSequence: unused, | |
| 139 | + streamStoredEvents: Stream.empty, | |
| 140 | + streamStoredEventsFrom: () => Stream.empty, | |
| 141 | + streamDomainEvents: Stream.empty, | |
| 142 | + }); | |
| 143 | + const publications: Array<{ | |
| 144 | + readonly url: string; | |
| 145 | + readonly authorization: string | null; | |
| 146 | + readonly state: RelayAgentActivityState | null; | |
| 147 | + }> = []; | |
| 148 | + const fetch: typeof globalThis.fetch = Object.assign( | |
| 149 | + ( | |
| 150 | + input: Parameters<typeof globalThis.fetch>[0], | |
| 151 | + init?: Parameters<typeof globalThis.fetch>[1], | |
| 152 | + ) => { | |
| 153 | + const body = init?.body; | |
| 154 | + if (typeof body !== "string" && !(body instanceof Uint8Array)) { | |
| 155 | + return Promise.reject(new Error("Expected a serialized activity publish payload")); | |
| 156 | + } | |
| 157 | + const payload = decodePublishPayload( | |
| 158 | + typeof body === "string" ? body : new TextDecoder().decode(body), | |
| 159 | + ); | |
| 160 | + publications.push({ | |
| 161 | + url: String(input), | |
| 162 | + authorization: new Headers(init?.headers).get("authorization"), | |
| 163 | + state: payload.state, | |
| 164 | + }); | |
| 165 | + return Promise.resolve( | |
| 166 | + options.respond?.(publications.length) ?? Response.json({ ok: true, deliveries: [] }), | |
| 167 | + ); | |
| 168 | + }, | |
| 169 | + { preconnect: () => {} }, | |
| 170 | + ); | |
| 171 | + const relay = yield* make.pipe( | |
| 172 | + Effect.provideService(ServerSecretStore, secrets), | |
| 173 | + Effect.provideService(ThreadManagementService, threads), | |
| 174 | + Effect.provideService(ServerEnvironment, { | |
| 175 | + getEnvironmentId: Effect.succeed(EnvironmentId.make("relay-environment")), | |
| 176 | + getDescriptor: unused(), | |
| 177 | + }), | |
| 178 | + Effect.provideService(ProjectService, { | |
| 179 | + create: unused, | |
| 180 | + bootstrap: unused, | |
| 181 | + update: unused, | |
| 182 | + delete: unused, | |
| 183 | + getByWorkspaceRoot: unused, | |
| 184 | + snapshot: unused(), | |
| 185 | + getById: () => | |
| 186 | + Effect.succeed( | |
| 187 | + Option.some({ | |
| 188 | + id: PROJECT_ID, | |
| 189 | + title: "Project", | |
| 190 | + workspaceRoot: "/workspace", | |
| 191 | + defaultModelSelection: null, | |
| 192 | + scripts: [], | |
| 193 | + createdAt: NOW, | |
| 194 | + updatedAt: NOW, | |
| 195 | + deletedAt: null, | |
| 196 | + }), | |
| 197 | + ), | |
| 198 | + }), | |
| 199 | + Effect.provideService(FetchHttpClient.Fetch, fetch), | |
| 200 | + Effect.provide(NodeCrypto.layer), | |
| 201 | + ); | |
| 202 | + return { relay, secrets, secretReads, currentShell, shellReads, publications }; | |
| 203 | +}); | |
| 204 | + | |
| 205 | +describe("AgentAwarenessRelay", () => { | |
| 206 | + it("ignores transcript and tool updates but retains activity and metadata changes", () => { | |
| 207 | + for (const type of [ | |
| 208 | + "message.updated", | |
| 209 | + "turn-item.updated", | |
| 210 | + "provider-turn.updated", | |
| 211 | + "thread.visited", | |
| 212 | + "thread.pinned", | |
| 213 | + ] as const) { | |
| 214 | + assert.isFalse(shouldPublishAgentAwarenessEvent({ type })); | |
| 215 | + } | |
| 216 | + for (const type of [ | |
| 217 | + "run.created", | |
| 218 | + "run.updated", | |
| 219 | + "runtime-request.updated", | |
| 220 | + "thread.metadata-updated", | |
| 221 | + "thread.model-selection-updated", | |
| 222 | + "thread.provider-switched", | |
| 223 | + "thread.archived", | |
| 224 | + "thread.unarchived", | |
| 225 | + "thread.deleted", | |
| 226 | + ] as const) { | |
| 227 | + assert.isTrue(shouldPublishAgentAwarenessEvent({ type })); | |
| 228 | + } | |
| 229 | + }); | |
| 230 | + | |
| 231 | + it.effect("coalesces queued updates and reruns a thread dirtied during publishing", () => | |
| 232 | + Effect.gen(function* () { | |
| 233 | + const started = yield* Deferred.make<void>(); | |
| 234 | + const release = yield* Deferred.make<void>(); | |
| 235 | + const rerunStarted = yield* Deferred.make<void>(); | |
| 236 | + const releaseRerun = yield* Deferred.make<void>(); | |
| 237 | + const processed: Array<{ threadId: ThreadId; revision: number }> = []; | |
| 238 | + let revision = 1; | |
| 239 | + const worker = yield* makeAgentAwarenessPublishWorker((threadId) => | |
| 240 | + Effect.gen(function* () { | |
| 241 | + const currentRevision = revision; | |
| 242 | + const index = processed.length; | |
| 243 | + processed.push({ threadId, revision: currentRevision }); | |
| 244 | + if (index === 0) { | |
| 245 | + yield* Deferred.succeed(started, undefined); | |
| 246 | + yield* Deferred.await(release); | |
| 247 | + } else if (threadId === THREAD_ID) { | |
| 248 | + yield* Deferred.succeed(rerunStarted, undefined); | |
| 249 | + yield* Deferred.await(releaseRerun); | |
| 250 | + } | |
| 251 | + }), | |
| 252 | + ); | |
| 253 | + yield* worker.enqueue(THREAD_ID); | |
| 254 | + yield* Deferred.await(started); | |
| 255 | + revision = 2; | |
| 256 | + for (let i = 0; i < 100; i++) { | |
| 257 | + yield* worker.enqueue(SECOND_THREAD_ID); | |
| 258 | + yield* worker.enqueue(THREAD_ID); | |
| 259 | + } | |
| 260 | + const drained = yield* Deferred.make<void>(); | |
| 261 | + const draining = yield* worker.drain.pipe( | |
| 262 | + Effect.andThen(Deferred.succeed(drained, undefined)), | |
| 263 | + Effect.forkChild, | |
| 264 | + ); | |
| 265 | + yield* Deferred.succeed(release, undefined); | |
| 266 | + yield* Deferred.await(rerunStarted); | |
| 267 | + assert.isFalse(yield* Deferred.isDone(drained)); | |
| 268 | + yield* Deferred.succeed(releaseRerun, undefined); | |
| 269 | + yield* Fiber.join(draining); | |
| 270 | + assert.deepEqual(processed, [ | |
| 271 | + { threadId: THREAD_ID, revision: 1 }, | |
| 272 | + { threadId: SECOND_THREAD_ID, revision: 2 }, | |
| 273 | + { threadId: THREAD_ID, revision: 2 }, | |
| 274 | + ]); | |
| 275 | + }), | |
| 276 | + ); | |
| 277 | + | |
| 278 | + it.effect("deduplicates state and republishes title changes", () => | |
| 279 | + Effect.gen(function* () { | |
| 280 | + const { relay, currentShell, publications } = yield* makeTestRelay(); | |
| 281 | + yield* relay.publishThread(THREAD_ID); | |
| 282 | + yield* Ref.set( | |
| 283 | + currentShell, | |
| 284 | + shell({ updatedAt: DateTime.makeUnsafe("2026-09-04T13:00:00Z") }), | |
| 285 | + ); | |
| 286 | + yield* relay.publishThread(THREAD_ID); | |
| 287 | + assert.equal(publications.length, 1); | |
| 288 | + yield* Ref.set(currentShell, shell({ title: "Renamed thread" })); | |
| 289 | + yield* relay.publishThread(THREAD_ID); | |
| 290 | + assert.equal(publications.length, 2); | |
| 291 | + assert.equal(publications[1]?.state?.threadTitle, "Renamed thread"); | |
| 292 | + }), | |
| 293 | + ); | |
| 294 | + | |
| 295 | + it.effect("stops before shell reads when disabled and republishes after re-enabling", () => | |
| 296 | + Effect.gen(function* () { | |
| 297 | + const { relay, secrets, secretReads, shellReads, publications } = yield* makeTestRelay(); | |
| 298 | + yield* relay.publishThread(THREAD_ID); | |
| 299 | + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); | |
| 300 | + const previousReads = secretReads.length; | |
| 301 | + yield* relay.publishThread(THREAD_ID); | |
| 302 | + assert.deepEqual(secretReads.slice(previousReads), [PUBLISH_AGENT_ACTIVITY_SECRET]); | |
| 303 | + assert.equal(shellReads.length, 1); | |
| 304 | + assert.equal(publications.length, 1); | |
| 305 | + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("true")); | |
| 306 | + yield* relay.publishThread(THREAD_ID); | |
| 307 | + assert.equal(publications.length, 2); | |
| 308 | + }), | |
| 309 | + ); | |
| 310 | + | |
| 311 | + it.effect("republishes unchanged state with fresh credentials after relinking", () => | |
| 312 | + Effect.gen(function* () { | |
| 313 | + const { relay, secrets, publications } = yield* makeTestRelay(); | |
| 314 | + yield* relay.publishThread(THREAD_ID); | |
| 315 | + yield* secrets.set( | |
| 316 | + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | |
| 317 | + new TextEncoder().encode("credential-2"), | |
| 318 | + ); | |
| 319 | + yield* relay.publishThread(THREAD_ID); | |
| 320 | + assert.equal(publications.length, 2); | |
| 321 | + assert.equal(publications[0]?.authorization, "Bearer credential-1"); | |
| 322 | + assert.equal(publications[1]?.authorization, "Bearer credential-2"); | |
| 323 | + yield* secrets.set( | |
| 324 | + RELAY_URL_SECRET, | |
| 325 | + new TextEncoder().encode("https://new-relay.example.test"), | |
| 326 | + ); | |
| 327 | + yield* relay.publishThread(THREAD_ID); | |
| 328 | + assert.equal(publications.length, 3); | |
| 329 | + assert.isTrue(publications[2]?.url.startsWith("https://new-relay.example.test/") ?? false); | |
| 330 | + }), | |
| 331 | + ); | |
| 332 | + | |
| 333 | + it.effect("retries a failed final state without another thread event", () => | |
| 334 | + Effect.gen(function* () { | |
| 335 | + const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | |
| 336 | + respond: (attempt) => | |
| 337 | + attempt === 2 | |
| 338 | + ? new Response("relay unavailable", { status: 503 }) | |
| 339 | + : Response.json({ ok: true, deliveries: [] }), | |
| 340 | + }); | |
| 341 | + yield* relay.publishThread(THREAD_ID); | |
| 342 | + yield* Ref.set(currentShell, shell({ status: "completed" })); | |
| 343 | + yield* relay.publishThread(THREAD_ID); | |
| 344 | + assert.equal(publications.length, 2); | |
| 345 | + yield* TestClock.adjust("1 second"); | |
| 346 | + yield* relay.drain; | |
| 347 | + assert.equal(publications.length, 3); | |
| 348 | + assert.equal(publications[2]?.state?.phase, "completed"); | |
| 349 | + yield* TestClock.adjust("1 minute"); | |
| 350 | + yield* relay.drain; | |
| 351 | + assert.equal(shellReads.length, 3); | |
| 352 | + }), | |
| 353 | + ); | |
| 354 | + | |
| 355 | + it.effect("retries the latest shell and credentials with first-completion confirmation", () => | |
| 356 | + Effect.gen(function* () { | |
| 357 | + const { relay, secrets, currentShell, publications } = yield* makeTestRelay({ | |
| 358 | + respond: (attempt) => | |
| 359 | + attempt === 1 | |
| 360 | + ? new Response("relay unavailable", { status: 503 }) | |
| 361 | + : Response.json({ ok: true, deliveries: [] }), | |
| 362 | + }); | |
| 363 | + yield* relay.publishThread(THREAD_ID); | |
| 364 | + yield* Ref.set(currentShell, shell({ status: "completed", title: "Final title" })); | |
| 365 | + yield* secrets.set( | |
| 366 | + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | |
| 367 | + new TextEncoder().encode("credential-2"), | |
| 368 | + ); | |
| 369 | + yield* TestClock.adjust("1 second"); | |
| 370 | + yield* relay.drain; | |
| 371 | + assert.equal(publications.length, 1); | |
| 372 | + yield* TestClock.adjust("5 seconds"); | |
| 373 | + yield* relay.drain; | |
| 374 | + assert.equal(publications.length, 2); | |
| 375 | + assert.equal(publications[1]?.authorization, "Bearer credential-2"); | |
| 376 | + assert.equal(publications[1]?.state?.phase, "completed"); | |
| 377 | + assert.equal(publications[1]?.state?.threadTitle, "Final title"); | |
| 378 | + }), | |
| 379 | + ); | |
| 380 | + | |
| 381 | + it.effect("cancels a stale retry when a newer update publishes successfully", () => | |
| 382 | + Effect.gen(function* () { | |
| 383 | + const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | |
| 384 | + respond: (attempt) => | |
| 385 | + attempt === 1 | |
| 386 | + ? new Response("relay unavailable", { status: 503 }) | |
| 387 | + : Response.json({ ok: true, deliveries: [] }), | |
| 388 | + }); | |
| 389 | + yield* relay.publishThread(THREAD_ID); | |
| 390 | + yield* Ref.set(currentShell, shell({ title: "New title" })); | |
| 391 | + yield* relay.publishThread(THREAD_ID); | |
| 392 | + assert.equal(publications.length, 2); | |
| 393 | + assert.equal(publications[1]?.state?.threadTitle, "New title"); | |
| 394 | + yield* TestClock.adjust("1 minute"); | |
| 395 | + yield* relay.drain; | |
| 396 | + assert.equal(shellReads.length, 2); | |
| 397 | + }), | |
| 398 | + ); | |
| 399 | + | |
| 400 | + it.effect.each(["disable", "unlink"] as const)( | |
| 401 | + "stops retries before shell reads after %s", | |
| 402 | + (change) => | |
| 403 | + Effect.gen(function* () { | |
| 404 | + const { relay, secrets, shellReads, publications } = yield* makeTestRelay({ | |
| 405 | + respond: () => new Response("relay unavailable", { status: 503 }), | |
| 406 | + }); | |
| 407 | + yield* relay.publishThread(THREAD_ID); | |
| 408 | + if (change === "disable") { | |
| 409 | + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); | |
| 410 | + } else { | |
| 411 | + yield* secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET); | |
| 412 | + } | |
| 413 | + yield* TestClock.adjust("1 second"); | |
| 414 | + yield* relay.drain; | |
| 415 | + yield* TestClock.adjust("1 minute"); | |
| 416 | + yield* relay.drain; | |
| 417 | + assert.equal(publications.length, 1); | |
| 418 | + assert.equal(shellReads.length, 1); | |
| 419 | + }), | |
| 420 | + ); | |
| 421 | + | |
| 422 | + it.effect("recovers from a transient configuration read failure", () => | |
| 423 | + Effect.gen(function* () { | |
| 424 | + let failRead = false; | |
| 425 | + const { relay, shellReads, publications } = yield* makeTestRelay({ | |
| 426 | + failSecretRead: (name) => failRead && name === PUBLISH_AGENT_ACTIVITY_SECRET, | |
| 427 | + }); | |
| 428 | + failRead = true; | |
| 429 | + yield* relay.publishThread(THREAD_ID); | |
| 430 | + assert.equal(shellReads.length, 0); | |
| 431 | + failRead = false; | |
| 432 | + yield* TestClock.adjust("1 second"); | |
| 433 | + yield* relay.drain; | |
| 434 | + assert.equal(publications.length, 1); | |
| 435 | + assert.equal(publications[0]?.state?.phase, "running"); | |
| 436 | + }), | |
| 437 | + ); | |
| 438 | + | |
| 439 | + it.effect("bounds retry attempts and resets the budget for a newer update", () => | |
| 440 | + Effect.gen(function* () { | |
| 441 | + const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | |
| 442 | + respond: () => new Response("relay unavailable", { status: 503 }), | |
| 443 | + }); | |
| 444 | + yield* relay.publishThread(THREAD_ID); | |
| 445 | + let attempts = 1; | |
| 446 | + for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000]) { | |
| 447 | + yield* TestClock.adjust(delay - 1); | |
| 448 | + yield* relay.drain; | |
| 449 | + assert.equal(publications.length, attempts); | |
| 450 | + yield* TestClock.adjust(1); | |
| 451 | + yield* relay.drain; | |
| 452 | + attempts += 1; | |
| 453 | + assert.equal(publications.length, attempts); | |
| 454 | + } | |
| 455 | + yield* TestClock.adjust("1 minute"); | |
| 456 | + yield* relay.drain; | |
| 457 | + assert.equal(publications.length, 6); | |
| 458 | + assert.equal(shellReads.length, 6); | |
| 459 | + yield* Ref.set(currentShell, shell({ title: "New update" })); | |
| 460 | + yield* relay.publishThread(THREAD_ID); | |
| 461 | + yield* TestClock.adjust("1 second"); | |
| 462 | + yield* relay.drain; | |
| 463 | + assert.equal(publications.length, 8); | |
| 464 | + assert.equal(publications[7]?.state?.threadTitle, "New update"); | |
| 465 | + }), | |
| 466 | + ); | |
| 467 | + | |
| 468 | + it.effect("retries a confirmed completion without restarting its confirmation window", () => | |
| 469 | + Effect.gen(function* () { | |
| 470 | + const { relay, currentShell, publications } = yield* makeTestRelay({ | |
| 471 | + respond: (attempt) => | |
| 472 | + attempt <= 2 | |
| 473 | + ? new Response("relay unavailable", { status: 503 }) | |
| 474 | + : Response.json({ ok: true, deliveries: [] }), | |
| 475 | + }); | |
| 476 | + yield* Ref.set(currentShell, shell({ status: "completed" })); | |
| 477 | + yield* relay.publishThread(THREAD_ID); | |
| 478 | + yield* TestClock.adjust("5 seconds"); | |
| 479 | + yield* relay.drain; | |
| 480 | + assert.equal(publications.length, 1); | |
| 481 | + yield* TestClock.adjust("1 second"); | |
| 482 | + yield* relay.drain; | |
| 483 | + assert.equal(publications.length, 2); | |
| 484 | + yield* TestClock.adjust("2 seconds"); | |
| 485 | + yield* relay.drain; | |
| 486 | + assert.equal(publications.length, 3); | |
| 487 | + assert.isTrue(publications.every((publication) => publication.state?.phase === "completed")); | |
| 488 | + yield* TestClock.adjust("1 minute"); | |
| 489 | + yield* relay.drain; | |
| 490 | + assert.equal(publications.length, 3); | |
| 491 | + }), | |
| 492 | + ); | |
| 493 | + | |
| 494 | + it.effect("does not reset an exhausted retry budget for old confirmation timers", () => | |
| 495 | + Effect.gen(function* () { | |
| 496 | + let unavailable = false; | |
| 497 | + const { relay, currentShell, shellReads, publications } = yield* makeTestRelay({ | |
| 498 | + respond: () => | |
| 499 | + unavailable | |
| 500 | + ? new Response("relay unavailable", { status: 503 }) | |
| 501 | + : Response.json({ ok: true, deliveries: [] }), | |
| 502 | + }); | |
| 503 | + yield* relay.publishThread(THREAD_ID); | |
| 504 | + // Leave six obsolete confirmations at 5,000 through 5,005 milliseconds. | |
| 505 | + for (let i = 0; i < 6; i++) { | |
| 506 | + yield* Ref.set(currentShell, null); | |
| 507 | + yield* relay.publishThread(THREAD_ID); | |
| 508 | + yield* Ref.set(currentShell, shell()); | |
| 509 | + yield* relay.publishThread(THREAD_ID); | |
| 510 | + yield* TestClock.adjust(1); | |
| 511 | + } | |
| 512 | + unavailable = true; | |
| 513 | + yield* Ref.set(currentShell, shell({ status: "failed" })); | |
| 514 | + yield* relay.publishThread(THREAD_ID); | |
| 515 | + yield* TestClock.adjust(1_000); | |
| 516 | + yield* relay.drain; | |
| 517 | + yield* TestClock.adjust(2_000); | |
| 518 | + yield* relay.drain; | |
| 519 | + yield* TestClock.adjust(1_994); | |
| 520 | + yield* relay.drain; | |
| 521 | + for (let i = 0; i < 5; i++) { | |
| 522 | + yield* TestClock.adjust(1); | |
| 523 | + yield* relay.drain; | |
| 524 | + } | |
| 525 | + const attempts = publications.length; | |
| 526 | + const reads = shellReads.length; | |
| 527 | + yield* TestClock.adjust("1 minute"); | |
| 528 | + yield* relay.drain; | |
| 529 | + assert.equal(publications.length, attempts); | |
| 530 | + assert.equal(shellReads.length, reads); | |
| 531 | + }), | |
| 532 | + ); | |
| 533 | + | |
| 534 | + it.effect("confirms tombstones and cancels stale confirmations when activity recovers", () => | |
| 535 | + Effect.gen(function* () { | |
| 536 | + const { relay, currentShell, publications } = yield* makeTestRelay(); | |
| 537 | + yield* relay.publishThread(THREAD_ID); | |
| 538 | + yield* Ref.set(currentShell, null); | |
| 539 | + yield* relay.publishThread(THREAD_ID); | |
| 540 | + assert.equal(publications.length, 1); | |
| 541 | + yield* Ref.set(currentShell, shell()); | |
| 542 | + yield* relay.publishThread(THREAD_ID); | |
| 543 | + yield* TestClock.adjust("5 seconds"); | |
| 544 | + yield* relay.drain; | |
| 545 | + assert.equal(publications.length, 1); | |
| 546 | + yield* Ref.set(currentShell, null); | |
| 547 | + yield* relay.publishThread(THREAD_ID); | |
| 548 | + assert.equal(publications.length, 1); | |
| 549 | + yield* TestClock.adjust("5 seconds"); | |
| 550 | + yield* relay.drain; | |
| 551 | + assert.equal(publications.length, 2); | |
| 552 | + assert.equal(publications[1]?.state, null); | |
| 553 | + }), | |
| 554 | + ); | |
| 555 | + | |
| 556 | + it.effect("confirms a first completed state and respects disabling during confirmation", () => | |
| 557 | + Effect.gen(function* () { | |
| 558 | + const { relay, secrets, currentShell, publications } = yield* makeTestRelay(); | |
| 559 | + yield* Ref.set(currentShell, shell({ status: "completed" })); | |
| 560 | + yield* relay.publishThread(THREAD_ID); | |
| 561 | + assert.equal(publications.length, 0); | |
| 562 | + yield* TestClock.adjust("5 seconds"); | |
| 563 | + yield* relay.drain; | |
| 564 | + assert.equal(publications[0]?.state?.phase, "completed"); | |
| 565 | + yield* Ref.set(currentShell, null); | |
| 566 | + yield* relay.publishThread(THREAD_ID); | |
| 567 | + yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, new TextEncoder().encode("false")); | |
| 568 | + yield* TestClock.adjust("5 seconds"); | |
| 569 | + yield* relay.drain; | |
| 570 | + assert.equal(publications.length, 1); | |
| 571 | + }), | |
| 572 | + ); | |
| 573 | +}); | |
| @@ -23,6 +23,7 @@ import * as Context from "effect/Context"; · 22 unmodified lines | ||
| 23 | 23 | import * as Crypto from "effect/Crypto"; |
| 24 | 24 | import * as DateTime from "effect/DateTime"; |
| 25 | 25 | import * as Effect from "effect/Effect"; |
| 26 | +import * as Fiber from "effect/Fiber"; | |
| 26 | 27 | import * as Layer from "effect/Layer"; |
| 27 | 28 | import * as Option from "effect/Option"; |
| 28 | 29 | import * as Ref from "effect/Ref"; |
| @@ -51,6 +52,7 @@ export class AgentAwarenessRelay extends Context.Service< · 22 unmodified lines | ||
| 51 | 52 | AgentAwarenessRelay, |
| 52 | 53 | { |
| 53 | 54 | readonly publishThread: (threadId: ThreadId) => Effect.Effect<void>; |
| 55 | + readonly drain: Effect.Effect<void>; | |
| 54 | 56 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; |
| 55 | 57 | } |
| 56 | 58 | >()("t3/relay/AgentAwarenessRelay") {} |
| @@ -59,13 +61,74 @@ export function eventThreadId(event: OrchestrationV2DomainEvent): ThreadId { · 2 unmodified lines | ||
| 59 | 61 | return event.threadId; |
| 60 | 62 | } |
| 61 | 63 | |
| 62 | −export function shouldPublishAgentAwarenessEvent(_event: OrchestrationV2DomainEvent): boolean { | |
| 63 | − // Publishing is identity-deduplicated below. Watching every V2 event keeps | |
| 64 | − // this consumer correct as the shell projection evolves without duplicating | |
| 65 | − // projection-specific relevance rules here. | |
| 66 | − return true; | |
| 64 | +export function shouldPublishAgentAwarenessEvent( | |
| 65 | + event: Pick<OrchestrationV2DomainEvent, "type">, | |
| 66 | +): boolean { | |
| 67 | + // projectThreadAwarenessV2 reads thread metadata, run status, and pending requests. | |
| 68 | + // Message bodies and tool progress cannot change the published activity. | |
| 69 | + switch (event.type) { | |
| 70 | + case "thread.created": | |
| 71 | + case "thread.archived": | |
| 72 | + case "thread.unarchived": | |
| 73 | + case "thread.deleted": | |
| 74 | + case "thread.metadata-updated": | |
| 75 | + case "thread.model-selection-updated": | |
| 76 | + case "thread.provider-switched": | |
| 77 | + case "run.created": | |
| 78 | + case "run.updated": | |
| 79 | + case "runtime-request.updated": | |
| 80 | + return true; | |
| 81 | + case "thread.settled": | |
| 82 | + case "thread.unsettled": | |
| 83 | + case "thread.snoozed": | |
| 84 | + case "thread.unsnoozed": | |
| 85 | + case "thread.pinned": | |
| 86 | + case "thread.unpinned": | |
| 87 | + case "thread.pin-reordered": | |
| 88 | + case "thread.visited": | |
| 89 | + case "thread.marked-unread": | |
| 90 | + case "thread.runtime-mode-updated": | |
| 91 | + case "thread.interaction-mode-updated": | |
| 92 | + case "run-attempt.created": | |
| 93 | + case "run-attempt.updated": | |
| 94 | + case "node.updated": | |
| 95 | + case "subagent.updated": | |
| 96 | + case "provider-session.attached": | |
| 97 | + case "provider-session.updated": | |
| 98 | + case "provider-session.detached": | |
| 99 | + case "provider-thread.updated": | |
| 100 | + case "provider-turn.updated": | |
| 101 | + case "message.updated": | |
| 102 | + case "turn-item.updated": | |
| 103 | + case "plan.updated": | |
| 104 | + case "checkpoint-scope.created": | |
| 105 | + case "checkpoint.captured": | |
| 106 | + case "checkpoint.rollback-requested": | |
| 107 | + case "context-handoff.updated": | |
| 108 | + case "context-transfer.created": | |
| 109 | + case "context-transfer.updated": | |
| 110 | + return false; | |
| 111 | + } | |
| 67 | 112 | } |
| 68 | 113 | |
| 114 | +export const makeAgentAwarenessPublishWorker = Effect.fnUntraced(function* <R>( | |
| 115 | + publish: (threadId: ThreadId) => Effect.Effect<void, never, R>, | |
| 116 | +) { | |
| 117 | + const queued = new Set<ThreadId>(); | |
| 118 | + const worker = yield* makeDrainableWorker((threadId: ThreadId) => | |
| 119 | + Effect.sync(() => queued.delete(threadId)).pipe(Effect.andThen(publish(threadId))), | |
| 120 | + ); | |
| 121 | + const enqueue = (threadId: ThreadId) => | |
| 122 | + Effect.suspend(() => { | |
| 123 | + if (queued.has(threadId)) return Effect.void; | |
| 124 | + // Removing the ID when processing starts allows one new queue entry | |
| 125 | + // for updates received while the current snapshot is being published. | |
| 126 | + queued.add(threadId); | |
| 127 | + return worker.enqueue(threadId); | |
| 128 | + }).pipe(Effect.uninterruptible); | |
| 129 | + return { enqueue, drain: worker.drain }; | |
| 130 | +}); | |
| 131 | + | |
| 69 | 132 | export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | null): string { |
| 70 | 133 | if (state === null) { |
| 71 | 134 | return "null"; |
| @@ -90,6 +153,7 @@ export function resolveAgentActivityPublishingStartupState(input: { · 18 unmodified lines | ||
| 90 | 153 | |
| 91 | 154 | const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; |
| 92 | 155 | const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; |
| 156 | +const RELAY_AGENT_ACTIVITY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; | |
| 93 | 157 | |
| 94 | 158 | export function sanitizeRelayAgentActivityState( |
| 95 | 159 | state: RelayAgentActivityState | null, |
| @@ -268,6 +332,7 @@ export const make = Effect.gen(function* () { · 172 unmodified lines | ||
| 268 | 332 | const threads = yield* ThreadManagement.ThreadManagementService; |
| 269 | 333 | const projects = yield* ProjectService.ProjectService; |
| 270 | 334 | const crypto = yield* Crypto.Crypto; |
| 335 | + const scope = yield* Effect.scope; | |
| 271 | 336 | const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); |
| 272 | 337 | const activeSnapshotPublishedRef = yield* Ref.make(false); |
| 273 | 338 | const publishedStateByThreadRef = yield* Ref.make(new Map<ThreadId, string>()); |
| @@ -305,6 +370,9 @@ export const make = Effect.gen(function* () { · 31 unmodified lines | ||
| 305 | 370 | transformClient: relayEnvironmentClient(relayConfig.environmentCredential), |
| 306 | 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 | 376 | // Deadlines for publishes that need confirmation (tombstones and |
| 309 | 377 | // first-state completions). The confirming publish is re-enqueued through |
| 310 | 378 | // 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 | 380 | // clears the deadline. Assigned after the worker exists. |
| 313 | 381 | const publishConfirmDeadlines = new Map<ThreadId, number>(); |
| 314 | 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 | 403 | const publishThreadUnsafe = Effect.fn("publishThreadUnsafe")(function* (threadId: ThreadId) { |
| 317 | − const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( | |
| 318 | − Effect.orElseSucceed(() => false), | |
| 319 | − ); | |
| 404 | + const publishAgentActivity = yield* readPublishAgentActivityEnabled; | |
| 320 | 405 | if (!publishAgentActivity) { |
| 406 | + yield* cancelPublishRetries; | |
| 407 | + yield* resetPublishedConnection; | |
| 321 | 408 | yield* Effect.logDebug("agent activity publish skipped; publication disabled", { |
| 322 | 409 | threadId, |
| 323 | 410 | }); |
| 324 | 411 | return; |
| 325 | 412 | } |
| 326 | − const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); | |
| 413 | + const relayConfig = yield* readRelayConfig; | |
| 327 | 414 | if (!relayConfig) { |
| 415 | + yield* cancelPublishRetries; | |
| 416 | + yield* resetPublishedConnection; | |
| 328 | 417 | yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { |
| 329 | 418 | threadId, |
| 330 | 419 | }); |
| 331 | 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 | 430 | const environmentId = yield* serverEnvironment.getEnvironmentId; |
| 335 | 431 | |
| 336 | 432 | const publishState = (input: { |
| @@ -339,6 +435,10 @@ export const make = Effect.gen(function* () { · 2 unmodified lines | ||
| 339 | 435 | readonly reason: string; |
| 340 | 436 | }) => |
| 341 | 437 | Effect.gen(function* () { |
| 438 | + if (cachedRelayClient === undefined) { | |
| 439 | + cachedRelayClient = yield* makeRelayClient(relayConfig); | |
| 440 | + } | |
| 441 | + const relayClient = cachedRelayClient; | |
| 342 | 442 | const proof = yield* makePublishProof({ |
| 343 | 443 | privateKey: cloudLinkKeyPair.privateKey, |
| 344 | 444 | relayIssuer: relayConfig.issuer, |
| @@ -441,7 +541,6 @@ export const make = Effect.gen(function* () { · 96 unmodified lines | ||
| 441 | 541 | if (nowMs < deadline) { |
| 442 | 542 | return; |
| 443 | 543 | } |
| 444 | − publishConfirmDeadlines.delete(threadId); | |
| 445 | 544 | yield* Effect.logInfo("agent activity deferred publish confirmed", { |
| 446 | 545 | environmentId, |
| 447 | 546 | threadId, |
| @@ -471,6 +570,7 @@ export const make = Effect.gen(function* () { · 23 unmodified lines | ||
| 471 | 570 | state: snapshot.state, |
| 472 | 571 | reason: snapshot.reason, |
| 473 | 572 | }); |
| 573 | + publishConfirmDeadlines.delete(threadId); | |
| 474 | 574 | yield* Ref.update(publishedStateByThreadRef, (publishedStates) => { |
| 475 | 575 | const nextPublishedStates = new Map(publishedStates); |
| 476 | 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) => | |
| 482 | − publishThreadUnsafe(threadId).pipe( | |
| 581 | + const processThreadPublish = Effect.fnUntraced(function* (threadId: ThreadId) { | |
| 582 | + const retry = publishRetries.get(threadId); | |
| 583 | + if (retry?.timer !== undefined) { | |
| 584 | + const timer = retry.timer; | |
| 585 | + retry.timer = undefined; | |
| 586 | + yield* Fiber.interrupt(timer); | |
| 587 | + } | |
| 588 | + yield* publishThreadUnsafe(threadId).pipe( | |
| 589 | + Effect.tap(() => cancelPublishRetry(threadId)), | |
| 483 | 590 | Effect.catchCause((cause) => { |
| 591 | + if (Cause.hasInterruptsOnly(cause)) return Effect.void; | |
| 484 | 592 | return Effect.logWarning("agent activity publish failed", { |
| 485 | 593 | threadId, |
| 486 | 594 | cause: Cause.pretty(cause), |
| 487 | − }); | |
| 595 | + }).pipe(Effect.andThen(schedulePublishRetry(threadId))); | |
| 488 | 596 | }), |
| 489 | 597 | Effect.withSpan("AgentAwarenessRelay.publishThread"), |
| 490 | 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 | 639 | const publishActiveThreadsUnsafe = Effect.gen(function* () { |
| 494 | 640 | const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( |
| @@ -520,7 +666,8 @@ export const make = Effect.gen(function* () { · 25 unmodified lines | ||
| 520 | 666 | yield* Effect.logInfo("publishing active agent activity snapshot", { |
| 521 | 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 | 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 | 692 | schedulePublishConfirm = (threadId) => |
| 548 | − Effect.forkDetach( | |
| 693 | + Effect.forkIn( | |
| 549 | 694 | Effect.sleep("5 seconds").pipe( |
| 550 | 695 | Effect.andThen(worker.enqueue(threadId)), |
| 551 | 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 | 704 | ).pipe(Effect.asVoid); |
| 559 | 705 | |
| 560 | 706 | const start: AgentAwarenessRelay["Service"]["start"] = Effect.fn("AgentAwarenessRelay.start")( |
| @@ -591,18 +737,12 @@ export const make = Effect.gen(function* () { · 30 unmodified lines | ||
| 591 | 737 | Stream.runForEach(threads.streamDomainEvents, (event) => { |
| 592 | 738 | const threadId = eventThreadId(event); |
| 593 | 739 | if (!shouldPublishAgentAwarenessEvent(event)) { |
| 594 | − return Effect.logDebug( | |
| 595 | − "agent activity publishing ignored event without activity changes", | |
| 596 | − { | |
| 597 | − eventType: event.type, | |
| 598 | − threadId, | |
| 599 | − }, | |
| 600 | − ); | |
| 740 | + return Effect.void; | |
| 601 | 741 | } |
| 602 | 742 | return Effect.logDebug("agent activity publishing queued thread publish", { |
| 603 | 743 | eventType: event.type, |
| 604 | 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 | 751 | return AgentAwarenessRelay.of({ |
| 612 | 752 | publishThread, |
| 753 | + drain: worker.drain, | |
| 613 | 754 | start, |
| 614 | 755 | }); |
| 615 | 756 | }); |
| @@ -412,7 +412,7 @@ const OrchestrationApplicationLayerLive = CheckpointDiffQuery.layer.pipe( · 411 unmodified lines | ||
| 412 | 412 | // so every client sees the same shelf. |
| 413 | 413 | const ThreadSettlementWorkerLive = Layer.effectDiscard( |
| 414 | 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 | |
| 417 | 417 | const AntigravityInstallationRefreshLive = Layer.effectDiscard( |
| 418 | 418 | Effect.gen(function* () { |
| @@ -74,6 +74,11 @@ export interface GitStatusDetails { · 73 unmodified lines | ||
| 74 | 74 | aheadOfDefaultCount: number; |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | +export interface GitLocalStatusOptions { | |
| 78 | + /** Skip revision walks and return zero divergence counts for local-only consumers. */ | |
| 79 | + readonly includeDivergence?: boolean; | |
| 80 | +} | |
| 81 | + | |
| 77 | 82 | export interface GitRemoteStatusDetails { |
| 78 | 83 | isRepo: boolean; |
| 79 | 84 | defaultBranch: string | null; |
| @@ -246,7 +251,10 @@ export class GitVcsDriver extends Context.Service< · 166 unmodified lines | ||
| 246 | 251 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; |
| 247 | 252 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; |
| 248 | 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 | 258 | readonly statusDetailsRemote: ( |
| 251 | 259 | cwd: string, |
| 252 | 260 | options?: GitRemoteStatusOptions, |
| @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | ||
| 8 | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | 9 | import * as Fiber from "effect/Fiber"; |
| 10 | 10 | import * as Layer from "effect/Layer"; |
| 11 | +import * as Logger from "effect/Logger"; | |
| 11 | 12 | import * as Path from "effect/Path"; |
| 12 | 13 | import * as PlatformError from "effect/PlatformError"; |
| 13 | 14 | import * as Ref from "effect/Ref"; |
| @@ -553,7 +554,7 @@ it.effect("refreshes the current branch after an external checkout", () => · 539 unmodified lines | ||
| 553 | 554 | ).pipe(Effect.provide(TestLayer)), |
| 554 | 555 | ); |
| 555 | 556 | |
| 556 | −it.effect("backs off failed upstream refreshes across linked worktrees", () => | |
| 557 | +it.effect("backs off and logs failed fetch attempts across linked worktrees", () => | |
| 557 | 558 | Effect.scoped( |
| 558 | 559 | Effect.gen(function* () { |
| 559 | 560 | const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; |
| @@ -573,6 +574,15 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => · 13 unmodified lines | ||
| 573 | 574 | const driver = yield* makeGitVcsDriverCore().pipe( |
| 574 | 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 | 586 | const cwd = yield* makeTmpDir(); |
| 577 | 587 | const remote = yield* makeTmpDir("git-vcs-driver-remote-"); |
| 578 | 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 | 627 | yield* Ref.set(fetchAttempts, 0); |
| 618 | 628 | |
| 619 | − yield* driver.statusDetailsRemote(cwd); | |
| 620 | − yield* driver.statusDetailsRemote(worktreePath); | |
| 629 | + yield* readRemoteStatus(cwd); | |
| 630 | + yield* readRemoteStatus(worktreePath); | |
| 621 | 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 | 635 | yield* TestClock.adjust("29 seconds"); |
| 624 | − yield* driver.statusDetailsRemote(worktreePath); | |
| 636 | + yield* readRemoteStatus(worktreePath); | |
| 625 | 637 | assert.equal(yield* Ref.get(fetchAttempts), 1); |
| 638 | + assert.lengthOf(warnings, 1); | |
| 626 | 639 | |
| 627 | 640 | yield* TestClock.adjust("1 second"); |
| 628 | − yield* driver.statusDetailsRemote(cwd); | |
| 641 | + yield* readRemoteStatus(cwd); | |
| 629 | 642 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| 643 | + assert.lengthOf(warnings, 2); | |
| 630 | 644 | |
| 631 | 645 | yield* TestClock.adjust("59 seconds"); |
| 632 | − yield* driver.statusDetailsRemote(worktreePath); | |
| 646 | + yield* readRemoteStatus(worktreePath); | |
| 633 | 647 | assert.equal(yield* Ref.get(fetchAttempts), 2); |
| 648 | + assert.lengthOf(warnings, 2); | |
| 634 | 649 | |
| 635 | 650 | yield* TestClock.adjust("1 second"); |
| 636 | − yield* driver.statusDetailsRemote(cwd); | |
| 651 | + yield* readRemoteStatus(cwd); | |
| 637 | 652 | assert.equal(yield* Ref.get(fetchAttempts), 3); |
| 653 | + assert.lengthOf(warnings, 3); | |
| 638 | 654 | }), |
| 639 | 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 | 1107 | it.effect("reports remote divergence without reading working-tree details", () => |
| 1031 | 1108 | Effect.gen(function* () { |
| 1032 | 1109 | const cwd = yield* makeTmpDir(); |
| @@ -1246,6 +1246,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 1245 unmodified lines | ||
| 1246 | 1246 | return yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName).pipe( |
| 1247 | 1247 | Effect.tap(() => Effect.sync(() => clearStatusRemoteRefreshFailures(cacheKey))), |
| 1248 | 1248 | Effect.tapError(() => Effect.sync(() => recordStatusRemoteRefreshFailure(cacheKey))), |
| 1249 | + Effect.tapCause((cause) => Effect.logWarning("Background Git fetch failed", cause)), | |
| 1249 | 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 | 1271 | const upstream = yield* resolveCurrentUpstream(cwd); |
| 1271 | 1272 | if (!upstream) return; |
| 1272 | 1273 | const gitCommonDir = yield* resolveGitCommonDir(cwd); |
| 1274 | + // The cache loader logs failed attempts; cache hits keep using the last fetched refs. | |
| 1273 | 1275 | yield* Cache.get( |
| 1274 | 1276 | statusRemoteRefreshCache, |
| 1275 | 1277 | new StatusRemoteRefreshCacheKey({ |
| 1276 | 1278 | gitCommonDir, |
| 1277 | 1279 | remoteName: upstream.remoteName, |
| 1278 | 1280 | }), |
| 1279 | − ); | |
| 1281 | + ).pipe(Effect.ignore); | |
| 1280 | 1282 | }); |
| 1281 | 1283 | |
| 1282 | 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 | 1599 | const statusResult = yield* executeGitWithStableDiagnostics( |
| 1588 | 1600 | "GitVcsDriver.statusDetails.status", |
| 1589 | 1601 | cwd, |
| 1590 | − ["status", "--porcelain=2", "--branch"], | |
| 1602 | + statusArgs, | |
| 1591 | 1603 | { |
| 1592 | 1604 | allowNonZeroExit: true, |
| 1593 | 1605 | }, |
| @@ -1610,7 +1622,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 16 unmodified lines | ||
| 1610 | 1622 | ...gitCommandContext({ |
| 1611 | 1623 | operation: "GitVcsDriver.statusDetails.status", |
| 1612 | 1624 | cwd, |
| 1613 | − args: ["status", "--porcelain=2", "--branch"], | |
| 1625 | + args: statusArgs, | |
| 1614 | 1626 | }), |
| 1615 | 1627 | detail: "Git status failed.", |
| 1616 | 1628 | exitCode: statusResult.exitCode, |
| @@ -1710,7 +1722,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 93 unmodified lines | ||
| 1710 | 1722 | upstreamRef = value.length > 0 ? value : null; |
| 1711 | 1723 | continue; |
| 1712 | 1724 | } |
| 1713 | − if (line.startsWith("# branch.ab ")) { | |
| 1725 | + if (includeDivergence && line.startsWith("# branch.ab ")) { | |
| 1714 | 1726 | const value = line.slice("# branch.ab ".length).trim(); |
| 1715 | 1727 | const parsed = parseBranchAb(value); |
| 1716 | 1728 | aheadCount = parsed.ahead; |
| @@ -1725,7 +1737,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 8 unmodified lines | ||
| 1725 | 1737 | } |
| 1726 | 1738 | |
| 1727 | 1739 | const fallbackAheadCount = |
| 1728 | − !upstreamRef && refName | |
| 1740 | + includeDivergence && !upstreamRef && refName | |
| 1729 | 1741 | ? yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)) |
| 1730 | 1742 | : null; |
| 1731 | 1743 | |
| @@ -1738,7 +1750,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 6 unmodified lines | ||
| 1738 | 1750 | refName !== null && |
| 1739 | 1751 | (refName === defaultBranch || |
| 1740 | 1752 | (defaultBranch === null && (refName === "main" || refName === "master"))); |
| 1741 | − if (refName && !isDefaultBranch) { | |
| 1753 | + if (includeDivergence && refName && !isDefaultBranch) { | |
| 1742 | 1754 | aheadOfDefaultCount = |
| 1743 | 1755 | fallbackAheadCount !== null |
| 1744 | 1756 | ? fallbackAheadCount |
| @@ -1788,8 +1800,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* · 43 unmodified lines | ||
| 1788 | 1800 | |
| 1789 | 1801 | const statusDetailsLocal: GitVcsDriver.GitVcsDriver["Service"]["statusDetailsLocal"] = Effect.fn( |
| 1790 | 1802 | "statusDetailsLocal", |
| 1791 | − )(function* (cwd) { | |
| 1792 | − return yield* readStatusDetailsLocal(cwd); | |
| 1803 | + )(function* (cwd, options) { | |
| 1804 | + return yield* readStatusDetailsLocal(cwd, options); | |
| 1793 | 1805 | }); |
| 1794 | 1806 | |
| 1795 | 1807 | const statusDetails: GitVcsDriver.GitVcsDriver["Service"]["statusDetails"] = Effect.fn( |
| @@ -1186,7 +1186,14 @@ function OpenCommandPaletteDialog(props: { · 1185 unmodified lines | ||
| 1186 | 1186 | projectTitleById, |
| 1187 | 1187 | sortOrder: clientSettings.sidebarThreadSortOrder, |
| 1188 | 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 | 1197 | renderTrailingContent: (thread) => <ThreadRowTrailingStatus thread={thread} />, |
| 1191 | 1198 | renderDescription: (thread, { projectTitle }) => { |
| 1192 | 1199 | const modelInstanceId = |
| @@ -1238,6 +1245,7 @@ function OpenCommandPaletteDialog(props: { · 45 unmodified lines | ||
| 1238 | 1245 | }), |
| 1239 | 1246 | [ |
| 1240 | 1247 | activeThreadId, |
| 1248 | + changeRequestSnapshotByKey, | |
| 1241 | 1249 | clientSettings.sidebarThreadSortOrder, |
| 1242 | 1250 | navigate, |
| 1243 | 1251 | projectCwdById, |
| @@ -0,0 +1,180 @@ | ||
| 1 | +import { ProjectId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | +import { act, type ComponentProps, type ReactNode } from "react"; | |
| 3 | +import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 4 | +import { afterEach, expect, it, vi } from "vite-plus/test"; | |
| 5 | + | |
| 6 | +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | |
| 7 | +import { makeThreadFixture } from "../test-fixtures"; | |
| 8 | + | |
| 9 | +const state = vi.hoisted(() => ({ queries: [] as string[] })); | |
| 10 | + | |
| 11 | +vi.mock("../state/entities", () => ({ useProject: () => null })); | |
| 12 | +vi.mock("../uiStateStore", () => ({ useUiStateStore: () => undefined })); | |
| 13 | +vi.mock("../state/vcs", async () => { | |
| 14 | + const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | |
| 15 | + const statuses = Atom.family((cwd: string) => | |
| 16 | + Atom.make(() => { | |
| 17 | + state.queries.push(`vcs:${cwd}`); | |
| 18 | + return AsyncResult.success({ | |
| 19 | + isRepo: true, | |
| 20 | + hasPrimaryRemote: true, | |
| 21 | + isDefaultRef: false, | |
| 22 | + refName: "feature/test", | |
| 23 | + hasWorkingTreeChanges: false, | |
| 24 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 25 | + hasUpstream: true, | |
| 26 | + aheadCount: 0, | |
| 27 | + behindCount: 0, | |
| 28 | + pr: { | |
| 29 | + number: 42, | |
| 30 | + title: "Observed worktree PR", | |
| 31 | + url: "https://github.com/example/repo/pull/42", | |
| 32 | + baseRef: "main", | |
| 33 | + headRef: "feature/test", | |
| 34 | + state: "open", | |
| 35 | + }, | |
| 36 | + } satisfies VcsStatusResult); | |
| 37 | + }), | |
| 38 | + ); | |
| 39 | + return { | |
| 40 | + vcsEnvironment: { status: ({ input }: { input: { cwd: string } }) => statuses(input.cwd) }, | |
| 41 | + }; | |
| 42 | +}); | |
| 43 | +vi.mock("../state/pullRequests", async (importOriginal) => { | |
| 44 | + const original = await importOriginal<typeof import("../state/pullRequests")>(); | |
| 45 | + const { AsyncResult, Atom } = await import("effect/unstable/reactivity"); | |
| 46 | + const { ProjectId } = await import("@t3tools/contracts"); | |
| 47 | + const summaries = Atom.family((number: number) => | |
| 48 | + Atom.make(() => { | |
| 49 | + state.queries.push(`pr:${number}`); | |
| 50 | + return AsyncResult.success({ | |
| 51 | + provider: "github" as const, | |
| 52 | + projectId: ProjectId.make("project-test"), | |
| 53 | + repository: "example/repo", | |
| 54 | + number, | |
| 55 | + title: "Observed linked PR", | |
| 56 | + url: `https://github.com/example/repo/pull/${number}`, | |
| 57 | + state: "open" as const, | |
| 58 | + headBranch: "feature/test", | |
| 59 | + baseBranch: "main", | |
| 60 | + updatedAt: "2026-09-04T00:00:00.000Z", | |
| 61 | + }); | |
| 62 | + }), | |
| 63 | + ); | |
| 64 | + return { | |
| 65 | + ...original, | |
| 66 | + linkedPullRequestDetailAtom: ({ input }: { input: { number: number } }) => | |
| 67 | + summaries(input.number), | |
| 68 | + }; | |
| 69 | +}); | |
| 70 | +vi.mock("./ui/tooltip", async () => { | |
| 71 | + const { cloneElement, isValidElement } = await import("react"); | |
| 72 | + return { | |
| 73 | + Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>, | |
| 74 | + TooltipTrigger({ | |
| 75 | + render, | |
| 76 | + children, | |
| 77 | + }: ComponentProps<typeof import("./ui/tooltip").TooltipTrigger>) { | |
| 78 | + return isValidElement(render) ? cloneElement(render, undefined, children) : children; | |
| 79 | + }, | |
| 80 | + TooltipPopup: () => null, | |
| 81 | + }; | |
| 82 | +}); | |
| 83 | + | |
| 84 | +import { ThreadRowLeadingStatus } from "./ThreadStatusIndicators"; | |
| 85 | + | |
| 86 | +afterEach(() => { | |
| 87 | + vi.unstubAllGlobals(); | |
| 88 | +}); | |
| 89 | + | |
| 90 | +it("leases only visible palette results while retaining observed and cached PR badges", async () => { | |
| 91 | + const observers: Array<{ setVisible: (visible: boolean) => void }> = []; | |
| 92 | + const scrollRoot = {}; | |
| 93 | + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 94 | + vi.stubGlobal( | |
| 95 | + "IntersectionObserver", | |
| 96 | + class { | |
| 97 | + constructor(callback: (entries: ReadonlyArray<{ isIntersecting: boolean }>) => void) { | |
| 98 | + observers.push({ setVisible: (visible) => callback([{ isIntersecting: visible }]) }); | |
| 99 | + } | |
| 100 | + observe() {} | |
| 101 | + disconnect() {} | |
| 102 | + }, | |
| 103 | + ); | |
| 104 | + const threads = Array.from({ length: 128 }, (_, index) => | |
| 105 | + makeThreadFixture({ | |
| 106 | + id: ThreadId.make(`palette-thread-${index}`), | |
| 107 | + branch: "feature/test", | |
| 108 | + worktreePath: `/test/worktree-${index}`, | |
| 109 | + linkedPullRequest: | |
| 110 | + index % 2 === 0 | |
| 111 | + ? null | |
| 112 | + : { | |
| 113 | + projectId: ProjectId.make("project-test"), | |
| 114 | + repository: "example/repo", | |
| 115 | + number: index, | |
| 116 | + url: `https://github.com/example/repo/pull/${index}`, | |
| 117 | + }, | |
| 118 | + }), | |
| 119 | + ); | |
| 120 | + let renderer: ReactTestRenderer | undefined; | |
| 121 | + state.queries.length = 0; | |
| 122 | + try { | |
| 123 | + await act(async () => { | |
| 124 | + renderer = create( | |
| 125 | + <AppAtomRegistryProvider> | |
| 126 | + {threads.map((thread, index) => ( | |
| 127 | + <span key={thread.id}> | |
| 128 | + <ThreadRowLeadingStatus | |
| 129 | + thread={thread} | |
| 130 | + snapshot={ | |
| 131 | + index === 126 | |
| 132 | + ? { | |
| 133 | + branch: "feature/test", | |
| 134 | + sourceControlProvider: undefined, | |
| 135 | + pr: { | |
| 136 | + number: 126, | |
| 137 | + title: "Cached badge", | |
| 138 | + url: "https://github.com/example/repo/pull/126", | |
| 139 | + baseRef: "main", | |
| 140 | + headRef: "feature/test", | |
| 141 | + state: "open", | |
| 142 | + }, | |
| 143 | + } | |
| 144 | + : undefined | |
| 145 | + } | |
| 146 | + /> | |
| 147 | + {thread.title} | |
| 148 | + </span> | |
| 149 | + ))} | |
| 150 | + </AppAtomRegistryProvider>, | |
| 151 | + { createNodeMock: () => ({ parentElement: { closest: () => scrollRoot } }) }, | |
| 152 | + ); | |
| 153 | + }); | |
| 154 | + const mounted = renderer!; | |
| 155 | + const badgeCount = () => | |
| 156 | + mounted.root.findAll( | |
| 157 | + (node) => node.type === "span" && String(node.props["aria-label"] ?? "").startsWith("PR #"), | |
| 158 | + ).length; | |
| 159 | + expect(observers).toHaveLength(128); | |
| 160 | + expect(state.queries).toEqual([]); | |
| 161 | + expect(badgeCount()).toBe(1); | |
| 162 | + | |
| 163 | + await act(async () => { | |
| 164 | + for (const observer of observers.slice(0, 8)) observer.setVisible(true); | |
| 165 | + }); | |
| 166 | + expect(state.queries).toHaveLength(8); | |
| 167 | + expect(state.queries.filter((key) => key.startsWith("vcs:"))).toHaveLength(4); | |
| 168 | + expect(state.queries.filter((key) => key.startsWith("pr:"))).toHaveLength(4); | |
| 169 | + expect(badgeCount()).toBe(9); | |
| 170 | + | |
| 171 | + await act(async () => { | |
| 172 | + for (const observer of observers.slice(0, 8)) observer.setVisible(false); | |
| 173 | + for (const observer of observers.slice(8, 16)) observer.setVisible(true); | |
| 174 | + }); | |
| 175 | + expect(state.queries).toHaveLength(16); | |
| 176 | + expect(badgeCount()).toBe(17); | |
| 177 | + } finally { | |
| 178 | + await act(async () => renderer?.unmount()); | |
| 179 | + } | |
| 180 | +}); | |
| @@ -554,6 +554,25 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { · 553 unmodified lines | ||
| 554 | 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 | 576 | it("retains a merged PR after a main checkout", () => { |
| 558 | 577 | const matchingStatus = status({ |
| 559 | 578 | refName: featureBranch, |
| @@ -12,7 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "@t3tools/contracts"; |
| 13 | 13 | import { Atom } from "effect/unstable/reactivity"; |
| 14 | 14 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 15 | −import { useMemo } from "react"; | |
| 15 | +import { useCallback, useMemo } from "react"; | |
| 16 | 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 17 | 17 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 18 | 18 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| @@ -27,6 +27,8 @@ import { · 8 unmodified lines | ||
| 27 | 27 | resolveThreadLastVisitedAt, |
| 28 | 28 | resolveThreadStatusPill, |
| 29 | 29 | type ThreadStatusPill, |
| 30 | + useRetainedValue, | |
| 31 | + useSidebarRowSubscriptionLease, | |
| 30 | 32 | } from "./Sidebar.logic"; |
| 31 | 33 | import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; |
| 32 | 34 | import type { SidebarThreadSummary } from "../types"; |
| @@ -58,9 +60,10 @@ export interface LinkedThreadPullRequestStatus { · 25 unmodified lines | ||
| 58 | 60 | export function useLinkedThreadPullRequest( |
| 59 | 61 | environmentId: EnvironmentId | null, |
| 60 | 62 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 63 | + enabled = true, | |
| 61 | 64 | ): LinkedThreadPullRequestStatus | null { |
| 62 | 65 | const queried = useEnvironmentQuery( |
| 63 | − environmentId === null || linkedPullRequest == null | |
| 66 | + !enabled || environmentId === null || linkedPullRequest == null | |
| 64 | 67 | ? null |
| 65 | 68 | : linkedPullRequestDetailAtom({ |
| 66 | 69 | environmentId, |
| @@ -353,7 +356,7 @@ export function nextThreadChangeRequestSnapshot(input: { · 286 unmodified lines | ||
| 353 | 356 | * checkouts only, a cached merged/closed PR for the thread. Local thread |
| 354 | 357 | * metadata follows the shared checkout, so the cached branch intentionally |
| 355 | 358 | * 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 | */ |
| 358 | 361 | export function resolveDisplayedThreadPr(input: { |
| 359 | 362 | threadBranch: string | null; |
| @@ -388,6 +391,15 @@ export function resolveDisplayedThreadPr(input: { · 28 unmodified lines | ||
| 388 | 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 | 403 | if ( |
| 392 | 404 | threadBranch !== null && |
| 393 | 405 | retainTerminalOnBranchMismatch && |
| @@ -434,6 +446,15 @@ export function resolveDisplayedThreadPrProvider(input: { · 40 unmodified lines | ||
| 434 | 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 | 458 | if ( |
| 438 | 459 | threadBranch !== null && |
| 439 | 460 | retainTerminalOnBranchMismatch && |
| @@ -550,7 +571,19 @@ export function ThreadStatusLabel({ · 110 unmodified lines | ||
| 550 | 571 | * like the command palette. Shows the change request state icon (if present) and the |
| 551 | 572 | * thread status dot, matching the sidebar's leading indicators. |
| 552 | 573 | */ |
| 553 | −export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { | |
| 574 | +export function ThreadRowLeadingStatus({ | |
| 575 | + thread, | |
| 576 | + snapshot, | |
| 577 | +}: { | |
| 578 | + thread: SidebarThreadSummary; | |
| 579 | + snapshot?: ThreadChangeRequestSnapshot | undefined; | |
| 580 | +}) { | |
| 581 | + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); | |
| 582 | + // Observe the containing title even when this thread has no badge yet. | |
| 583 | + const statusRef = useCallback( | |
| 584 | + (node: HTMLSpanElement | null) => rowRef(node?.parentElement ?? null), | |
| 585 | + [rowRef], | |
| 586 | + ); | |
| 554 | 587 | const threadRef = scopeThreadRef(thread.environmentId, thread.id); |
| 555 | 588 | const localLastVisitedAt = useUiStateStore( |
| 556 | 589 | (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], |
| @@ -567,9 +600,11 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 10 unmodified lines | ||
| 567 | 600 | const linkedPullRequest = useLinkedThreadPullRequest( |
| 568 | 601 | thread.environmentId, |
| 569 | 602 | thread.linkedPullRequest, |
| 603 | + leaseLiveStatus, | |
| 570 | 604 | ); |
| 571 | 605 | const gitStatus = useEnvironmentQuery( |
| 572 | − thread.linkedPullRequest == null && | |
| 606 | + leaseLiveStatus && | |
| 607 | + thread.linkedPullRequest == null && | |
| 573 | 608 | (thread.branch != null || thread.worktreePath !== null) && |
| 574 | 609 | gitCwd !== null |
| 575 | 610 | ? vcsEnvironment.status({ |
| @@ -578,14 +613,20 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar · 2 unmodified lines | ||
| 578 | 613 | }) |
| 579 | 614 | : null, |
| 580 | 615 | ); |
| 581 | − const pr = | |
| 582 | − thread.linkedPullRequest == null | |
| 583 | − ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) | |
| 584 | − : (linkedPullRequest?.pr ?? null); | |
| 585 | − const prStatus = prStatusIndicator( | |
| 586 | − pr, | |
| 587 | − linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, | |
| 616 | + const visibleGitStatus = useRetainedValue( | |
| 617 | + JSON.stringify([thread.environmentId, gitCwd]), | |
| 618 | + gitStatus.data, | |
| 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 | 630 | const threadStatus = resolveThreadStatusPill({ |
| 590 | 631 | thread: { |
| 591 | 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 | 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 | 644 | {prStatus && pr ? ( |
| 603 | 645 | <Tooltip> |
| 604 | 646 | <TooltipTrigger |
| @@ -2,7 +2,11 @@ import { · 1 unmodified line | ||
| 2 | 2 | BearerConnectionTarget, |
| 3 | 3 | PrimaryConnectionTarget, |
| 4 | 4 | } from "@t3tools/client-runtime/connection"; |
| 5 | −import { EnvironmentId, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; | |
| 5 | +import { | |
| 6 | + type DesktopEnvironmentBootstrap, | |
| 7 | + EnvironmentId, | |
| 8 | + PRIMARY_LOCAL_ENVIRONMENT_ID, | |
| 9 | +} from "@t3tools/contracts"; | |
| 6 | 10 | import { describe, expect, it } from "vite-plus/test"; |
| 7 | 11 | |
| 8 | 12 | import { |
| @@ -38,6 +42,63 @@ describe("desktop local connection identity", () => { · 29 unmodified lines | ||
| 38 | 42 | }); |
| 39 | 43 | |
| 40 | 44 | describe("desktop local topology reads", () => { |
| 45 | + it("reuses snapshots when polling returns fresh objects for the same topology", () => { | |
| 46 | + const secondary: DesktopEnvironmentBootstrap = { | |
| 47 | + id: "wsl:default", | |
| 48 | + label: "WSL", | |
| 49 | + runningDistro: "Ubuntu", | |
| 50 | + httpBaseUrl: "http://127.0.0.1:4000", | |
| 51 | + wsBaseUrl: "ws://127.0.0.1:4000", | |
| 52 | + bootstrapToken: "bootstrap-1", | |
| 53 | + }; | |
| 54 | + let entries = [secondary]; | |
| 55 | + const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 56 | + getLocalEnvironmentBootstraps: () => entries.map((entry) => ({ ...entry })), | |
| 57 | + })); | |
| 58 | + | |
| 59 | + const connected = reader.readSnapshot(); | |
| 60 | + expect(reader.readSnapshot()).toBe(connected); | |
| 61 | + expect(reader.readResult()).toEqual({ _tag: "Success", bootstraps: connected }); | |
| 62 | + expect(reader.readSnapshot()).toBe(connected); | |
| 63 | + | |
| 64 | + entries = []; | |
| 65 | + const empty = reader.readSnapshot(); | |
| 66 | + expect(empty).toEqual([]); | |
| 67 | + expect(reader.readSnapshot()).toBe(empty); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it.each<Partial<DesktopEnvironmentBootstrap>>([ | |
| 71 | + { id: "wsl:Debian" }, | |
| 72 | + { label: "Renamed backend" }, | |
| 73 | + { runningDistro: "Debian" }, | |
| 74 | + { httpBaseUrl: "http://127.0.0.1:4001" }, | |
| 75 | + { wsBaseUrl: "ws://127.0.0.1:4001" }, | |
| 76 | + { bootstrapToken: "bootstrap-2" }, | |
| 77 | + ])("publishes bootstrap changes: %j", (change) => { | |
| 78 | + let secondary: DesktopEnvironmentBootstrap = { | |
| 79 | + id: "wsl:default", | |
| 80 | + label: "WSL", | |
| 81 | + runningDistro: "Ubuntu", | |
| 82 | + httpBaseUrl: "http://127.0.0.1:4000", | |
| 83 | + wsBaseUrl: "ws://127.0.0.1:4000", | |
| 84 | + bootstrapToken: "bootstrap-1", | |
| 85 | + }; | |
| 86 | + const reader = createDesktopSecondaryBootstrapsReader(() => ({ | |
| 87 | + getLocalEnvironmentBootstraps: () => [{ ...secondary }], | |
| 88 | + })); | |
| 89 | + const before = reader.readSnapshot(); | |
| 90 | + secondary = { ...secondary, ...change }; | |
| 91 | + const after = reader.readSnapshot(); | |
| 92 | + expect(after).not.toBe(before); | |
| 93 | + expect(after).toEqual([secondary]); | |
| 94 | + expect(reader.readSnapshot()).toBe(after); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + it("keeps the empty snapshot stable without a desktop bridge", () => { | |
| 98 | + const reader = createDesktopSecondaryBootstrapsReader(() => undefined); | |
| 99 | + expect(reader.readSnapshot()).toBe(reader.readSnapshot()); | |
| 100 | + }); | |
| 101 | + | |
| 41 | 102 | it("distinguishes a successful empty topology from a read failure", () => { |
| 42 | 103 | let readBootstraps = () => []; |
| 43 | 104 | const reader = createDesktopSecondaryBootstrapsReader(() => ({ |
| @@ -67,13 +67,30 @@ export function createDesktopSecondaryBootstrapsReader( · 66 unmodified lines | ||
| 67 | 67 | const readResult = (): DesktopSecondaryBootstrapsRead => { |
| 68 | 68 | const bridge = resolveBridge(); |
| 69 | 69 | if (bridge === undefined) { |
| 70 | − snapshot = []; | |
| 70 | + if (snapshot.length > 0) snapshot = []; | |
| 71 | 71 | return { _tag: "Success", bootstraps: snapshot }; |
| 72 | 72 | } |
| 73 | 73 | try { |
| 74 | − snapshot = bridge | |
| 74 | + const next = bridge | |
| 75 | 75 | .getLocalEnvironmentBootstraps() |
| 76 | 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 | 94 | return { _tag: "Success", bootstraps: snapshot }; |
| 78 | 95 | } catch (cause) { |
| 79 | 96 | return { _tag: "Failure", cause }; |