diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index cc881413c80e8..200a2323d63ed 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -214,6 +214,10 @@ export function ensureElectronRuntime() { return electronPath; } +export function setup() { + ensureElectronRuntime(); +} + // `file://${argv[1]}` never matches on Windows (drive letters need `file:///C:/`). if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { const electronPath = ensureElectronRuntime(); diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index 9eb41ed4e2402..eac944722e73b 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -148,6 +148,34 @@ describe("ElectronWindow", () => { }).pipe(Effect.provide(layerTestFor("win32"))), ); + it.effect("reports which windows were created with a title bar overlay", () => + Effect.gen(function* () { + const framed = { isDestroyed: () => false }; + const overlay = { isDestroyed: () => false }; + browserWindowMock + .mockImplementationOnce(function FramedWindowMock() { + return framed; + }) + .mockImplementationOnce(function OverlayWindowMock() { + return overlay; + }); + const electronWindow = yield* ElectronWindow.ElectronWindow; + yield* electronWindow.create({}); + yield* electronWindow.create({ + titleBarStyle: "hidden", + titleBarOverlay: { color: "#01000000", height: 40 }, + }); + getAllWindowsMock.mockReturnValue([framed, overlay, { isDestroyed: () => false }]); + + const seen: Array = []; + yield* electronWindow.syncAllAppearance((_window, { titleBarOverlay }) => + Effect.sync(() => seen.push(titleBarOverlay)), + ); + + assert.deepEqual(seen, [false, true, false]); + }).pipe(Effect.provide(layerTestFor("win32"))), + ); + it.effect("preserves destroy failures and continues with later windows", () => Effect.gen(function* () { const cause = new Error("window destroy failed"); diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 15a117a52b050..b5f89969e66c7 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -125,7 +125,10 @@ export class ElectronWindow extends Context.Service< readonly sendAll: (channel: string, ...args: readonly unknown[]) => Effect.Effect; readonly destroyAll: Effect.Effect; readonly syncAllAppearance: ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, + sync: ( + window: Electron.BrowserWindow, + appearance: { readonly titleBarOverlay: boolean }, + ) => Effect.Effect, ) => Effect.Effect; } >()("@t3tools/desktop/electron/ElectronWindow") {} @@ -145,6 +148,7 @@ export const make = Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => windowsForegroundFocus?.close())); const mainWindowRef = yield* Ref.make>(Option.none()); const auxiliaryWindows = new WeakSet(); + const titleBarOverlayWindows = new WeakSet(); const listWindows = Effect.try({ try: () => Electron.BrowserWindow.getAllWindows(), @@ -241,6 +245,9 @@ export const make = Effect.gen(function* () { try: () => { const window = new Electron.BrowserWindow(options); if (behavior?.auxiliary === true) auxiliaryWindows.add(window); + if (options.titleBarOverlay !== undefined && options.titleBarOverlay !== false) { + titleBarOverlayWindows.add(window); + } return window; }, catch: (cause) => new ElectronWindowCreateError({ options: diagnosticOptions, cause }), @@ -383,14 +390,17 @@ export const make = Effect.gen(function* () { } }), syncAllAppearance: Effect.fn("desktop.electron.window.syncAllAppearance")(function* ( - sync: (window: Electron.BrowserWindow) => Effect.Effect, + sync: ( + window: Electron.BrowserWindow, + appearance: { readonly titleBarOverlay: boolean }, + ) => Effect.Effect, ) { const windows = yield* listWindows; for (const window of windows) { if (yield* isWindowDestroyed(window)) { continue; } - yield* sync(window); + yield* sync(window, { titleBarOverlay: titleBarOverlayWindows.has(window) }); } }), }); diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index ccc2b868720b4..564b4dc57d6f4 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -9,6 +9,7 @@ import { const makeTarget = ( send: CdpRelayTarget["send"], setDownloadDirectory: CdpRelayTarget["setDownloadDirectory"] = () => {}, + ackScreencastFrame: CdpRelayTarget["ackScreencastFrame"] = () => {}, ): CdpRelayTarget => ({ send, targetId: async () => "GUEST-TARGET", @@ -17,6 +18,7 @@ const makeTarget = ( userAgent: () => "Electron", setDownloadDirectory, receivesScreencast: () => true, + ackScreencastFrame, }); const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => @@ -138,9 +140,13 @@ describe("CDP relay", () => { describe("CDP relay extra sessions", () => { it("gives a second page session the page's commands and events", async () => { - const send = vi.fn(async () => ({})); + const send = vi.fn(async () => ({})); const written: Array> = []; - const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw))); + const nativeAcks: Array = []; + const relay = makeRelay( + makeTarget(send, undefined, (frameId) => nativeAcks.push(frameId)), + (raw) => written.push(JSON.parse(raw)), + ); relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} })); relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" })); await settle(); @@ -170,8 +176,63 @@ describe("CDP relay extra sessions", () => { await settle(); expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra); written.length = 0; - relay.event("Page.screencastFrame", { data: "x" }, undefined); - expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]); + const frame = (frameId: number) => + relay.event("Page.screencastFrame", { data: "x", sessionId: frameId }, undefined); + const ack = (sessionId: string, frameId: number) => + relay.receive( + JSON.stringify({ + id: 6, + method: "Page.screencastFrameAck", + params: { sessionId: frameId }, + sessionId, + }), + ); + const delivered = () => { + const frames = written + .filter((m) => m["method"]) + .map((m) => [m["sessionId"], (m["params"] as { sessionId?: number }).sessionId]); + written.length = 0; + return frames; + }; + frame(1); + expect(delivered()).toEqual([ + ["t3-preview-page", 1], + [extra, 1], + ]); + // A session that has not acknowledged its frame gets only the newest one, on its ack. + for (let i = 2; i < 100; i++) frame(i); + expect(delivered()).toEqual([]); + // Frames every session is busy for stay unacknowledged, so Chromium pauses at its in-flight limit. + expect(nativeAcks).toEqual([1]); + ack(extra, 1); + await settle(); + expect(delivered()).toEqual([[extra, 99]]); + // Each consumer ack lets Chromium send one more frame. + expect(nativeAcks).toEqual([1, 2]); + ack(extra, 99); + await settle(); + expect(delivered()).toEqual([]); + expect(nativeAcks).toEqual([1, 2, 3]); + frame(100); + expect(delivered()).toEqual([[extra, 100]]); + ack(extra, 99); + await settle(); + frame(101); + expect(delivered()).toEqual([]); + expect(send.mock.calls.some(([method]) => method === "Page.screencastFrameAck")).toBe(false); + // A restart keeps the unacknowledged frame's slot until its own acknowledgement. + relay.receive(JSON.stringify({ id: 7, method: "Page.stopScreencast", sessionId: extra })); + relay.receive(JSON.stringify({ id: 8, method: "Page.startScreencast", sessionId: extra })); + await settle(); + delivered(); + frame(102); + expect(delivered()).toEqual([]); + ack(extra, 100); + await settle(); + expect(delivered()).toEqual([[extra, 102]]); + // Other events still reach a session while its frame is unacknowledged. + relay.event("Page.loadEventFired", { timestamp: 1 }, undefined); + expect(delivered().map(([session]) => session)).toEqual(["t3-preview-page", extra]); }); }); diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 658e1cf70623d..e9f3cf509b446 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -11,6 +11,8 @@ export interface CdpRelayTarget { readonly sessionDetached?: (owner: string) => void; /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */ readonly receivesScreencast: (owner: string | undefined) => boolean; + /** Acknowledges a native screencast frame, letting Chromium send the next one. */ + readonly ackScreencastFrame: (frameId: number) => void; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -103,6 +105,20 @@ export function createCdpRelayConnection( let downloadEventsEnabled = false; /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */ const sessions = new Map([[PAGE_SESSION_ID, "page"]]); + // Each page session holds one unacknowledged frame and keeps the newest one skipped meanwhile. + const unackedFrames = new Map(); + const skippedFrames = new Map>(); + // Chromium keeps a few frames in flight and reuses one frame id per run, so pacing it means + // leaving frames every session is busy for unacknowledged and releasing one per consumer ack. + const heldNativeFrames: Array = []; + const releaseHeldFramesWithoutHolders = () => { + if (skippedFrames.size === 0) + for (const frameId of heldNativeFrames.splice(0)) target.ackScreencastFrame(frameId); + }; + const sendFrame = (sessionId: string, params: Record) => { + unackedFrames.set(sessionId, params["sessionId"] as number); + send({ method: "Page.screencastFrame", params, sessionId }); + }; let sessionSequence = 0; const newSession = (kind: "browser" | "page") => { const sessionId = `t3-preview-${kind}-${++sessionSequence}`; @@ -170,6 +186,9 @@ export function createCdpRelayConnection( const sessionId = command.params?.["sessionId"]; if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) { sessions.delete(sessionId); + unackedFrames.delete(sessionId); + skippedFrames.delete(sessionId); + releaseHeldFramesWithoutHolders(); target.sessionDetached?.(sessionId); } return {}; @@ -202,6 +221,27 @@ export function createCdpRelayConnection( } // Playwright asks for this on every page; the host emulates focus only while it is in use. if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({}); + if (command.method === "Page.screencastFrameAck" && child === undefined) { + // A restart keeps the slot too, or restarting would let frames past an unacknowledged one. + const sessionId = command.sessionId!; + if (unackedFrames.get(sessionId) === command.params?.["sessionId"]) { + unackedFrames.delete(sessionId); + const skipped = skippedFrames.get(sessionId); + skippedFrames.delete(sessionId); + if ( + skipped !== undefined && + target.receivesScreencast(sessionId === PAGE_SESSION_ID ? undefined : sessionId) + ) + sendFrame(sessionId, skipped); + const released = heldNativeFrames.shift(); + if (released !== undefined) target.ackScreencastFrame(released); + } + return Promise.resolve({}); + } + if (command.method === "Page.stopScreencast" && child === undefined) { + skippedFrames.delete(command.sessionId!); + releaseHeldFramesWithoutHolders(); + } // The shared debugger keeps Runtime enabled after an earlier connection, and a repeated // enable reports no existing contexts; Playwright waits for them, so reset once per connection. if (command.method === "Runtime.enable" && child === undefined) { @@ -262,15 +302,29 @@ export function createCdpRelayConnection( send({ method, params, sessionId }); return; } - for (const [id, kind] of sessions) { - if (kind !== "page") continue; - if ( - method === "Page.screencastFrame" && - !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id) - ) - continue; - send({ method, params, sessionId: id }); + if (method === "Page.screencastFrame") { + const frame = (params ?? {}) as Record; + const frameId = frame["sessionId"]; + if (typeof frameId !== "number") return; + let held = false; + let delivered = false; + for (const [id, kind] of sessions) { + if (kind !== "page") continue; + if (!target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id)) continue; + if (unackedFrames.has(id)) { + skippedFrames.set(id, frame); + held = true; + } else { + sendFrame(id, frame); + delivered = true; + } + } + if (held && !delivered) heldNativeFrames.push(frameId); + else target.ackScreencastFrame(frameId); + return; } + for (const [id, kind] of sessions) + if (kind === "page") send({ method, params, sessionId: id }); }, }; } diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index d6f25d66aa810..cf6c7736f81d3 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1157,6 +1157,13 @@ describe("DesktopBrowserHost", () => { debuggee.refuse(6); debuggee.release(); expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]); + yield* raw({ + id: 7, + method: "Page.screencastFrameAck", + params: { sessionId: 1 }, + sessionId: "t3-preview-page-1", + }); + expect(yield* Queue.take(replies)).toBe(7); yield* settle; frames.length = 0; debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 069a9852ca47c..da0f9820a5f89 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -424,6 +424,10 @@ export const make = Effect.gen(function* () { dropSessionHolds(tab, owner); }, receivesScreencast: (owner) => tab.screencasts.has(owner), + ackScreencastFrame: (frameId) => + void debuggee + .sendCommand("Page.screencastFrameAck", { sessionId: frameId }) + .catch(() => {}), targetId: () => debuggee .sendCommand("Target.getTargetInfo") diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6bc85a9eef153..849753d740d63 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -350,6 +350,47 @@ const makeTestPreviewWebContents = ( } as unknown as TestPreviewWebContents; }; +const makeStillImage = (pixels = "fresh-png") => ({ + toPNG: () => Buffer.from(pixels), + toJPEG: () => Buffer.from(pixels), + getSize: () => ({ width: 100, height: 80 }), +}); + +const makeTestStillImageWebContents = (...args: Parameters) => { + const wc = makeTestPreviewWebContents(...args); + Object.assign(wc, { isDevToolsOpened: () => false }); + holdStillImagePaint(wc, Promise.resolve()); + return wc; +}; + +const holdStillImagePaint = ( + wc: TestPreviewWebContents, + paint: Promise | Array>, +) => { + vi.mocked(wc.debugger.sendCommand).mockImplementation(async (method) => { + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 7 }; + if (method === "Runtime.evaluate") { + await (Array.isArray(paint) ? paint.shift() : paint); + return { result: { value: true } }; + } + return undefined; + }); +}; + +const setupStill = (manager: PreviewManager.PreviewManager["Service"], paint = Promise.resolve()) => + Effect.gen(function* () { + const image = makeStillImage(); + const capturePage = vi.fn<() => Promise>().mockResolvedValue(image); + const wc = makeTestStillImageWebContents(capturePage); + holdStillImagePaint(wc, paint); + fromId.mockReturnValue(wc); + yield* manager.createTab("tab_still"); + yield* manager.registerWebview("tab_still", wc.id); + yield* manager.setColorScheme("tab_still", "system"); + return { image, capturePage, wc, capture: manager.captureScreenshot("tab_still") }; + }); + const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) => Effect.gen(function* () { const capturePage = vi.fn(async () => ({ @@ -1829,6 +1870,30 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("captures final pixels after warmup fails and paint readiness settles", () => + withManager((manager) => + Effect.gen(function* () { + const paint = Promise.withResolvers(); + const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise); + capturePage + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockRejectedValueOnce(new Error("UnknownVizError")) + .mockResolvedValue(makeStillImage("final-png")); + const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(300); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(writeFile).not.toHaveBeenCalled(); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + paint.resolve(); + const artifact = yield* Fiber.join(fiber); + expect(capturePage).toHaveBeenCalledTimes(4); + expect(writeFile.mock.calls).toEqual([[artifact.path, Buffer.from("final-png")]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + effectIt.effect("preserves both screenshots of the same site in the same millisecond", () => withManager((manager) => Effect.gen(function* () { @@ -1842,6 +1907,8 @@ describe("PreviewManager", () => { const capturePage = vi .fn<() => Promise>>() .mockResolvedValueOnce(pngImage(firstPng)) + .mockResolvedValueOnce(pngImage(firstPng)) + .mockResolvedValueOnce(pngImage(secondPng)) .mockResolvedValueOnce(pngImage(secondPng)); fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); yield* manager.createTab("tab_1"); @@ -1862,6 +1929,115 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "waits for paint before capturing and restores rendering after overlapping requests", + () => + withManager((manager) => + Effect.gen(function* () { + const paints = [Promise.withResolvers(), Promise.withResolvers()]; + const pendingPaints = paints.map((paint) => paint.promise); + const { image, capturePage, wc, capture } = yield* setupStill(manager); + holdStillImagePaint(wc, pendingPaints); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + capturePage + .mockResolvedValueOnce(makeStillImage("discarded-warmup")) + .mockResolvedValueOnce(makeStillImage("discarded-warmup")) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const hostThrottling = vi.fn(); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { setBackgroundThrottling: hostThrottling }, + } as never); + const one = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + const two = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(pendingPaints).toHaveLength(0); + expect(writeFile).not.toHaveBeenCalled(); + expect(hostThrottling.mock.calls).toEqual([[false]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + paints.forEach((paint) => paint.resolve()); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledTimes(4); + first.resolve(image); + yield* Fiber.join(one); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + second.resolve(image); + yield* Fiber.join(two); + const saved = writeFile.mock.calls.map(([, bytes]) => bytes); + expect(saved).toEqual([image.toPNG(), image.toPNG()]); + expect(hostThrottling.mock.calls).toEqual([[false], [true]]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + + effectIt.effect("keeps picture-in-picture rendering after a still image completes", () => + withManager((manager) => + Effect.gen(function* () { + const { image, capturePage, wc, capture } = yield* setupStill(manager); + const pending = Promise.withResolvers(); + capturePage + .mockResolvedValueOnce(image) + .mockResolvedValueOnce(makeStillImage("discarded-warmup")) + .mockReturnValueOnce(pending.promise); + const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pictureInPictureWindow; + }); + yield* manager.openPictureInPicture("tab_still"); + const still = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(0); + expect(capturePage).toHaveBeenCalledTimes(3); + expect(writeFile).not.toHaveBeenCalled(); + pending.resolve(image); + yield* Fiber.join(still); + expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + yield* manager.closePictureInPicture("tab_still"); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + + effectIt.effect("keeps a pending still image rendering when recording ends", () => + withManager((manager) => + Effect.gen(function* () { + const { image, capturePage, wc, capture } = yield* setupStill(manager); + const pending = Promise.withResolvers(); + capturePage.mockResolvedValueOnce(image).mockReturnValue(pending.promise); + yield* manager.startRecording("tab_still"); + const fiber = yield* capture.pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust(0); + yield* manager.stopRecording("tab_still"); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]); + pending.resolve(image); + yield* Fiber.join(fiber); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + + effectIt.effect("saves available pixels when paint readiness never settles", () => + withManager((manager) => + Effect.gen(function* () { + const paint = Promise.withResolvers(); + const { capturePage, wc, capture } = yield* setupStill(manager, paint.promise); + const first = yield* Effect.exit(capture).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(1_001); + expect(Exit.isSuccess(yield* Fiber.join(first))).toBe(true); + expect(capturePage).toHaveBeenCalledTimes(2); + expect(writeFile.mock.calls.map((call) => call[1])).toEqual([Buffer.from("fresh-png")]); + expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + }), + ), + ); + effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () => withManager((manager) => Effect.gen(function* () { @@ -2585,6 +2761,116 @@ describe("PreviewManager", () => { ), ); + effectIt.effect( + "publishes human ownership only on transitions while retaining every recording press", + () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((input: unknown) => void) | undefined; + const contents = makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })); + contents.ipc.on = (channel, callback) => { + if (channel === "preview:human-input") + humanInput = (input) => callback({} as Electron.IpcMainEvent, input); + return contents.ipc; + }; + fromId.mockReturnValue(contents); + yield* manager.createTab("tab_human"); + yield* manager.registerWebview("tab_human", 42); + yield* manager.startRecording("tab_human", { + showKeyPresses: true, + showMousePresses: true, + }); + const controllers: string[] = []; + const feedback = yield* Queue.unbounded(); + webviewSend.mockImplementation((channel) => { + if (channel === "preview:recording-controller") Queue.offerUnsafe(feedback, undefined); + }); + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + for (const press of [ + { kind: "pointer", x: 10, y: 20, button: 0 }, + { kind: "pointer", x: 30, y: 40, button: 0 }, + { kind: "key", key: "a", code: "KeyA" }, + ]) { + humanInput?.(press); + yield* Queue.take(feedback); + } + expect(controllers).toEqual(["human"]); + expect( + webviewSend.mock.calls.filter( + ([channel]) => channel === "preview:recording-controller", + ), + ).toEqual([ + ["preview:recording-controller", "human", { x: 10, y: 20 }], + ["preview:recording-controller", "human", { x: 30, y: 40 }], + ["preview:recording-controller", "human", undefined], + ]); + yield* TestClock.adjust(750); + expect(controllers).toEqual(["human", "none"]); + }), + ), + ); + + effectIt.effect("keeps human ownership until the last input's idle deadline", () => + withManager((manager) => + Effect.gen(function* () { + let humanInput: ((input: unknown) => void) | undefined; + const contents = makeTestPreviewWebContents(async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + })); + contents.ipc.on = (channel, callback) => { + if (channel === "preview:human-input") + humanInput = (input) => callback({} as Electron.IpcMainEvent, input); + return contents.ipc; + }; + fromId.mockReturnValue(contents); + yield* manager.createTab("tab_idle"); + yield* manager.registerWebview("tab_idle", 42); + const controllers: string[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + controllers.push(state.controller); + }), + ); + const press = () => humanInput?.({ kind: "key", key: "a", code: "KeyA" }); + press(); + yield* TestClock.adjust(500); + press(); + yield* TestClock.adjust(749); + expect(controllers).toEqual(["human"]); + yield* TestClock.adjust(1); + expect(controllers).toEqual(["human", "none"]); + + press(); + yield* TestClock.adjust(500); + const staleInput = humanInput; + const replacement = makeTestPreviewWebContents( + async () => ({ + toJPEG: () => Buffer.from("frame"), + getSize: () => ({ width: 800, height: 600 }), + }), + 43, + ); + replacement.ipc.on = contents.ipc.on; + contents.isDestroyed = () => true; + fromId.mockImplementation((id) => (id === 43 ? replacement : null)); + yield* manager.registerWebview("tab_idle", 43); + expect(controllers.at(-1)).toBe("none"); + const count = controllers.length; + staleInput?.({ kind: "key", key: "b", code: "KeyB" }); + yield* TestClock.adjust(750); + expect(controllers).toHaveLength(count); + }), + ), + ); + effectIt.effect("gates recording decorations and isolates failed subscribers", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index eba18ab631dd0..957a00d6a4b6b 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -387,7 +387,8 @@ interface ManagedListeners { readonly webContents: Electron.WebContents; } -type FrameCaptureConsumer = "picture-in-picture" | "recording"; +// Each still-image request owns a separate, short-lived rendering lease. +type FrameCaptureConsumer = "picture-in-picture" | "recording" | symbol; interface FrameCaptureSession { readonly recordingInputOptions?: RecordingInputOptions; @@ -888,7 +889,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const update = Effect.fn("PreviewManager.update")(function* ( tabId: string, patch: Partial, - humanPoint?: { readonly x: number; readonly y: number }, ) { const updatedAt = yield* currentIso; const next = yield* SynchronizedRef.modify(tabsRef, (tabs) => { @@ -904,17 +904,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); // syncTabAudible can commit after this update; emitIfCurrent prevents publishing a stale snapshot. if (Option.isSome(next)) { - if (patch.controller !== undefined && next.value.webContentsId != null) { - const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); - const webContentsId = next.value.webContentsId; - if (capture?.consumers.has("recording")) { - yield* attempt({ operation: "recording.controller", tabId }, () => { - const contents = webContents.fromId(webContentsId); - if (contents && !contents.isDestroyed()) - contents.send(RECORDING_CONTROLLER_CHANNEL, patch.controller, humanPoint); - }).pipe(Effect.ignore); - } - } yield* emitIfCurrent(tabId, next.value); } }); @@ -1107,28 +1096,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ), scope, ); - const handleDebuggerMessage = Effect.fnUntraced(function* ( - method: string, - params: Record, - ) { - if (method === "Page.screencastFrame") { - const sessionId = params["sessionId"]; - if (typeof sessionId === "number") { - yield* attemptPromise( - { - operation: "ackScreencastFrame", - webContentsId: wc.id, - }, - () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), - ).pipe(Effect.ignore); - } - } + const handleDebuggerMessage = Effect.fnUntraced(function* (method: string) { if (method === "Runtime.consoleAPICalled" || method === "Runtime.exceptionThrown") { yield* Queue.offer(consoleReleases, undefined); } }); - const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => { - runFork(handleDebuggerMessage(method, params)); + const onMessage: BrowserControlSession["onMessage"] = (_event, method) => { + runFork(handleDebuggerMessage(method)); }; const control: BrowserControlSession = { webContentsId: wc.id, @@ -1443,21 +1417,75 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; + let humanInputWorker = false; + let humanInputDeadline = 0; + let listening = true; + const publishHumanController = Effect.fn("PreviewManager.publishHumanController")(function* ( + controller: "human" | "none", + humanPoint?: { readonly x: number; readonly y: number }, + ) { + const now = yield* currentMillis; + const updatedAt = yield* currentIso; + const result = yield* SynchronizedRef.modify(tabsRef, (tabs) => { + const current = tabs.get(tabId); + if ( + !current || + current.webContentsId !== wc.id || + (controller === "human" + ? !listening || webContents.fromId(wc.id) !== wc + : current.controller !== "human" || humanInputDeadline > now) + ) + return [undefined, tabs] as const; + const changed = current.controller !== controller; + const state = changed ? { ...current, controller, updatedAt } : current; + return [ + { state, changed }, + changed + ? replaceMap(tabs, (copy) => { + copy.set(tabId, state); + }) + : tabs, + ] as const; + }); + if (!result) return; + const capture = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId); + if (capture?.consumers.has("recording")) { + yield* attempt({ operation: "recording.controller", tabId }, () => { + if (!wc.isDestroyed()) wc.send(RECORDING_CONTROLLER_CHANNEL, controller, humanPoint); + }).pipe(Effect.ignore); + } + if (result.changed) yield* emitIfCurrent(tabId, result.state); + }); + yield* Scope.addFinalizer( + scope, + Effect.gen(function* () { + listening = false; + humanInputDeadline = 0; + yield* publishHumanController("none"); + }), + ); const handleHumanInput = Effect.fn("PreviewManager.handleHumanInput")(function* ( rawSignal?: unknown, ) { - yield* update( - tabId, - { controller: "human" }, + if (!listening) return; + humanInputDeadline = (yield* currentMillis) + 750; + if (!humanInputWorker) { + humanInputWorker = true; + yield* Effect.gen(function* () { + let remaining: number; + while ((remaining = humanInputDeadline - (yield* currentMillis)) > 0) { + yield* Effect.sleep(remaining); + } + humanInputWorker = false; + yield* publishHumanController("none"); + }).pipe(Effect.forkIn(scope)); + } + yield* publishHumanController( + "human", isPreviewInputSignal(rawSignal) && rawSignal.kind === "pointer" ? { x: rawSignal.x, y: rawSignal.y } : undefined, ); - yield* Effect.sleep(750); - const tabs = yield* SynchronizedRef.get(tabsRef); - if (tabs.get(tabId)?.controller === "human") { - yield* update(tabId, { controller: "none" }); - } }); const recordingInput = (_event: unknown, input: unknown) => { if (!isRecordingInput(input)) return; @@ -2651,7 +2679,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const [createdAt, millis, image] = yield* Effect.all([ currentIso, currentMillis, - capturePageWithRetry( + captureStillImage( { operation: "captureScreenshot.capturePage", tabId, @@ -2699,6 +2727,100 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; }); + const captureStillImage = Effect.fn("PreviewManager.captureStillImage")(function* ( + errorContext: PreviewOperationContext, + tabId: string, + wc: Electron.WebContents, + ) { + const consumer = Symbol("still-image"); + return yield* Effect.acquireUseRelease( + startFrameCapture(tabId, consumer), + () => + Effect.gen(function* () { + // A DOM mutation can precede its compositor frame even in a shown + // window. Keep the original guest rendering through the next paint; + // a hidden guest otherwise returns old pixels or never completes. + const probe = Effect.fn("PreviewManager.probeStillImagePaint")(function* ( + send: ( + method: string, + params?: Record, + ) => Effect.Effect, + ) { + // CDP executes against the current document without Electron's + // did-stop-loading gate. Isolate scheduling globals from page code. + const tree = (yield* send("Page.getFrameTree")) as + | { frameTree?: { frame?: { id?: string } } } + | undefined; + const frameId = tree?.frameTree?.frame?.id; + if (typeof frameId !== "string") + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint frame is unavailable."), + }); + const world = (yield* send("Page.createIsolatedWorld", { + frameId, + worldName: "t3-preview-still-image", + })) as { executionContextId?: number } | undefined; + if (typeof world?.executionContextId !== "number") + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint context is unavailable."), + }); + const response = (yield* send("Runtime.evaluate", { + // Settle and release scheduled work when only timers keep running. + // The outer deadline still bounds a frozen renderer. + expression: `new Promise(resolve => { let frame; const timer = setTimeout(() => { cancelAnimationFrame(frame); resolve(false); }, ${CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS}); frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { clearTimeout(timer); resolve(true); }); }); })`, + contextId: world.executionContextId, + awaitPromise: true, + returnByValue: true, + })) as { result?: { value?: unknown }; exceptionDetails?: unknown } | undefined; + if (response?.exceptionDetails || response?.result?.value !== true) + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint boundary was not confirmed."), + }); + }); + const paintReady = Effect.gen(function* () { + // Only use an existing owned session; never attach or displace + // another debugger to obtain a screenshot. + const control = (yield* SynchronizedRef.get(controlSessionsRef)).get(wc.id); + if (!control) + return yield* new PreviewOperationError({ + ...errorContext, + cause: new Error("The preview paint session is unavailable."), + }); + return yield* probe((method, params) => + attemptPromise(errorContext, () => control.debugger.sendCommand(method, params)), + ); + }).pipe( + Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS), + Effect.catch((error) => + Effect.logWarning( + "Preview paint readiness was not confirmed; capturing available pixels with mutation freshness unconfirmed.", + { tabId, webContentsId: wc.id, error }, + ), + ), + ); + // Minimized guests can suspend frames even with throttling disabled. + // A native capture can resume them; discard its pixels and read again + // after the bounded paint probe. + const warmup = capturePageWithRetry(errorContext, tabId, wc).pipe( + Effect.catchTags({ + PreviewOperationError: (error) => + Effect.logWarning("Preview warmup capture failed; continuing with final capture.", { + tabId, + webContentsId: wc.id, + error, + }), + }), + ); + yield* Effect.all([paintReady, warmup], { concurrency: 2, discard: true }); + return yield* capturePageWithRetry(errorContext, tabId, wc); + }), + () => stopFrameCapture(tabId, consumer), + ); + }); + const capturePreviewFrame = Effect.fn("PreviewManager.capturePreviewFrame")(function* ( tabId: string, ) { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 1e0fef50f3aac..345d67be68a6d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -209,17 +209,20 @@ const layerElectronTheme = Layer.succeed(ElectronTheme.ElectronTheme, { onUpdated: () => Effect.void, } satisfies ElectronTheme.ElectronTheme["Service"]); -const layerDesktopEnvironment = DesktopEnvironment.layer(environmentInput).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_PORT: "3773", - VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", - }), +const layerDesktopEnvironmentFor = (platform: NodeJS.Platform) => + DesktopEnvironment.layer({ ...environmentInput, platform }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_PORT: "3773", + VITE_DEV_SERVER_URL: "http://127.0.0.1:5733", + }), + ), ), - ), -); + ); + +const layerDesktopEnvironment = layerDesktopEnvironmentFor(environmentInput.platform); const desktopWindowBoundsEquivalence = Schema.toEquivalence( DesktopAppSettings.DesktopWindowBoundsSchema, @@ -245,6 +248,12 @@ function layerTest(input: { readonly previewZoomReapplies?: number[]; readonly onReveal?: (window: Electron.BrowserWindow) => void; readonly focusedWindow?: Electron.BrowserWindow; + readonly fallbackWindow?: Electron.BrowserWindow; + readonly platform?: NodeJS.Platform; + readonly appearanceWindows?: ReadonlyArray<{ + readonly window: Electron.BrowserWindow; + readonly titleBarOverlay: boolean; + }>; readonly primaryPasteEnabled?: boolean; readonly previewPasskeyBridgeEnabled?: boolean; }) { @@ -294,7 +303,9 @@ function layerTest(input: { Effect.as(input.window), ), main: Ref.get(input.mainWindow), - currentMainOrFirst: Ref.get(input.mainWindow), + currentMainOrFirst: Ref.get(input.mainWindow).pipe( + Effect.map(Option.orElse(() => Option.fromNullishOr(input.fallbackWindow ?? null))), + ), focusedMainOrFirst: input.focusedWindow === undefined ? Ref.get(input.mainWindow) @@ -305,7 +316,14 @@ function layerTest(input: { reveal: (window) => Effect.sync(() => input.onReveal?.(window)), sendAll: () => Effect.void, destroyAll: input.destroyAll ?? Effect.void, - syncAllAppearance: (sync) => sync(input.window), + syncAllAppearance: (sync) => + input.appearanceWindows === undefined + ? sync(input.window, { titleBarOverlay: true }) + : Effect.forEach( + input.appearanceWindows, + ({ window, titleBarOverlay }) => sync(window, { titleBarOverlay }), + { discard: true }, + ), } satisfies ElectronWindow.ElectronWindow["Service"]); return DesktopWindow.layer.pipe( @@ -317,7 +335,9 @@ function layerTest(input: { recordMetrics: () => Effect.void, shutdown: Effect.void, }), - layerDesktopEnvironment, + input.platform === undefined + ? layerDesktopEnvironment + : layerDesktopEnvironmentFor(input.platform), layerDesktopAppSettings, layerDesktopClientSettings, layerDesktopServerExposure, @@ -425,7 +445,8 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n reveal: (window) => Ref.update(revealedWindows, (windows) => [...windows, window]), sendAll: () => Effect.void, destroyAll: Effect.void, - syncAllAppearance: (sync) => (fallbackWindow ? sync(fallbackWindow) : Effect.void), + syncAllAppearance: (sync) => + fallbackWindow ? sync(fallbackWindow, { titleBarOverlay: true }) : Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]; const layer = DesktopWindow.layer.pipe( @@ -828,7 +849,7 @@ describe("DesktopWindow", () => { }), ); - it.effect("reloads and inspects the main window even when a popup has focus", () => + it.effect("routes menu commands to the main window even when a popup has focus", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); const popup = makeFakeBrowserWindow(); @@ -848,7 +869,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.runMainContentsCommand("reload"); yield* desktopWindow.runMainContentsCommand("forceReload"); yield* desktopWindow.runMainContentsCommand("toggleDevTools"); + yield* desktopWindow.dispatchMenuAction("open-settings"); + assert.deepEqual(fakeWindow.send.mock.calls, [["desktop:menu-action", "open-settings"]]); + assert.equal(popup.send.mock.calls.length, 0); assert.equal(fakeWindow.reload.mock.calls.length, 1); assert.equal(fakeWindow.reloadIgnoringCache.mock.calls.length, 1); assert.equal(fakeWindow.toggleDevTools.mock.calls.length, 1); @@ -859,6 +883,38 @@ describe("DesktopWindow", () => { }), ); + it.effect("recreates main for Settings when only an unregistered popup survives", () => + Effect.gen(function* () { + const main = makeFakeBrowserWindow(); + const auxiliary = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.dispatchMenuAction("open-settings"); + assert.deepEqual(main.send.mock.calls, [["desktop:menu-action", "open-settings"]]); + assert.equal(auxiliary.send.mock.calls.length, 0); + assert.equal(yield* Ref.get(createCount), 1); + assert.strictEqual(yield* windows.ensureMain, main.window); + assert.equal(yield* Ref.get(createCount), 1); + }).pipe( + Effect.provide( + layerTest({ + window: main.window, + createCount, + mainWindow, + fallbackWindow: auxiliary.window, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + }), + ), + ); + }), + ); + it.effect("does not open main or splash windows once quitting has started", () => Effect.gen(function* () { const fake = makeFakeBrowserWindow(); @@ -1172,6 +1228,36 @@ describe("DesktopWindow", () => { }), ); + it.effect("updates title bar overlays only on windows created with one", () => + Effect.gen(function* () { + const framed = makeFakeBrowserWindow(); + vi.mocked(framed.window.setTitleBarOverlay).mockImplementation(() => { + throw new TypeError("Titlebar overlay is not enabled"); + }); + const overlay = makeFakeBrowserWindow(); + const layer = layerTest({ + window: overlay.window, + createCount: yield* Ref.make(0), + mainWindow: yield* Ref.make>(Option.none()), + platform: "win32", + appearanceWindows: [ + { window: framed.window, titleBarOverlay: false }, + { window: overlay.window, titleBarOverlay: true }, + ], + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.syncAppearance; + }).pipe(Effect.provide(layer)); + + assert.deepEqual(vi.mocked(framed.window.setBackgroundColor).mock.calls, [["#ffffff"]]); + assert.lengthOf(vi.mocked(framed.window.setTitleBarOverlay).mock.calls, 0); + assert.deepEqual(vi.mocked(overlay.window.setBackgroundColor).mock.calls, [["#ffffff"]]); + assert.lengthOf(vi.mocked(overlay.window.setTitleBarOverlay).mock.calls, 1); + }), + ); + it.effect.each([ { window: "normal", write: "flush" }, { window: "normal", write: "debounce" }, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 43404e2d6cfb9..7be14bc720838 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -343,6 +343,7 @@ function getWindowTitleBarOptions( function syncWindowAppearance( window: Electron.BrowserWindow, + hasTitleBarOverlay: boolean, shouldUseDarkColors: boolean, platform: NodeJS.Platform, ): Effect.Effect { @@ -353,7 +354,7 @@ function syncWindowAppearance( window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); - if (typeof titleBarOverlay === "object") { + if (hasTitleBarOverlay && typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); } }); @@ -424,7 +425,6 @@ export const make = Effect.gen(function* () { ); const currentMainWindow = electronWindow.currentMainOrFirst.pipe(Effect.flatMap(withoutSplash)); - const focusedMainWindow = electronWindow.focusedMainOrFirst.pipe(Effect.flatMap(withoutSplash)); const zoomWindow = Effect.fn("desktop.window.zoomWindow")(function* ( window: Electron.BrowserWindow, @@ -1018,7 +1018,7 @@ export const make = Effect.gen(function* () { const ensureMain = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return Option.none(); - const existingWindow = yield* currentMainWindow; + const existingWindow = yield* electronWindow.main; if (Option.isSome(existingWindow)) { return existingWindow; } @@ -1105,7 +1105,8 @@ export const make = Effect.gen(function* () { { reveal = true }: { readonly reveal?: boolean } = {}, ) { if (yield* Ref.get(desktopState.quitting)) return; - const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; + // Only the main renderer loads the app preload that listens on these channels. + const existingWindow = yield* electronWindow.main; if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; if (Option.isNone(window) || window.value.isDestroyed()) return; @@ -1202,8 +1203,8 @@ export const make = Effect.gen(function* () { }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; - yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + yield* electronWindow.syncAllAppearance((window, { titleBarOverlay }) => + syncWindowAppearance(window, titleBarOverlay, shouldUseDarkColors, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 9d22361486f00..911be0f768900 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -166,6 +166,9 @@ export default defineConfig({ // The Windows lane runs workspace suites concurrently; filesystem-heavy // desktop integration tests can exceed Vitest's 5 second default there. testTimeout: 15_000, + // `require("electron")` downloads a missing binary, so concurrent workers race to + // extract it into the same dist/. Install it once before any worker starts. + globalSetup: ["./scripts/ensure-electron-runtime.mjs"], projects: [ { extends: true, diff --git a/apps/marketing/src/lib/releases.test.ts b/apps/marketing/src/lib/releases.test.ts new file mode 100644 index 0000000000000..6527342d674e5 --- /dev/null +++ b/apps/marketing/src/lib/releases.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { fetchLatestRelease } from "./releases"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchLatestRelease", () => { + it("rejects a GitHub API error response instead of returning it as a release", async () => { + vi.stubGlobal("sessionStorage", { getItem: () => null }); + vi.stubGlobal("fetch", async () => + Response.json({ message: "API rate limit exceeded" }, { status: 403 }), + ); + + await expect(fetchLatestRelease("stable")).rejects.toThrow("403"); + }); +}); diff --git a/apps/marketing/src/lib/releases.ts b/apps/marketing/src/lib/releases.ts index 7dc63bdaed859..f50103972cdd8 100644 --- a/apps/marketing/src/lib/releases.ts +++ b/apps/marketing/src/lib/releases.ts @@ -25,12 +25,18 @@ function cacheKey(channel: ReleaseChannel) { return `t3code-${channel}-release`; } +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`GitHub API responded ${response.status}`); + return response.json(); +} + async function fetchStable(): Promise { - return fetch(LATEST_API_URL).then((r) => r.json()); + return fetchJson(LATEST_API_URL); } async function fetchNightly(): Promise { - const list: Release[] = await fetch(LIST_API_URL).then((r) => r.json()); + const list = await fetchJson(LIST_API_URL); const nightly = Array.isArray(list) ? list.find((release) => release.tag_name?.includes("-nightly.")) : undefined; diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 0f8a18d50cbd0..91e74e697bab0 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -21,7 +21,7 @@ The Android terminal renderer vendors upstream `libghostty-vt` shared libraries The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI. - Upstream project: https://github.com/ghostty-org/ghostty -- Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` +- Vendored revision: `3ff6d08fad56f2d6b81420a27bdc56ee707b453e` - License: MIT Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so index e086ca8315565..9e3ed70cf88ad 100755 Binary files a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so index 5592142ffce8c..9174c0be80f44 100755 Binary files a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so index bacab7908336a..db7667ea3559b 100755 Binary files a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so index f6e0b646a2fb2..0a1f011f8c133 100755 Binary files a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh index 55d994d3b363c..b9e225eff4bcd 100755 --- a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh @@ -7,7 +7,7 @@ MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" VENDOR_DIR="${MODULE_DIR}/../../../../native/libghostty-vt" PATCH_DIR="${SCRIPT_DIR}/libghostty-android-patches" -GHOSTTY_REVISION="${GHOSTTY_REVISION:-9f62873bf195e4d8a762d768a1405a5f2f7b1697}" +GHOSTTY_REVISION="${GHOSTTY_REVISION:-3ff6d08fad56f2d6b81420a27bdc56ee707b453e}" GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index f7118b95b425f..bae452bad7c48 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -76,7 +76,7 @@ export function ComposerEditor({ props.onChangeText(text); if (draftKey) { setComposerDraftContext(draftKey, restored.context); - appendComposerDraftAttachments(draftKey, restored.attachments, { allowOverflow: true }); + appendComposerDraftAttachments(draftKey, restored.attachments, { restore: true }); } }; const [selected, setSelected] = useState<{ source: string; start: number; end: number } | null>( diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index 4fd54085c45f1..fdbf27b6f2c25 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -285,6 +285,7 @@ export function ComposerActionButton(props: { }) { const { scale, smallIconSize } = useAndroidControlSizing(); const circleSize = Math.round((props.size === "large" ? 44 : 30) * scale); + const fadeIcon = props.disabled === true && props.variant !== "danger"; return ( - + {/* Fade the icon as one layer: a translucent tint, or opacity on a Tabler icon (which + copies it onto every path), stacks where the SVG strokes overlap. */} + + + ); diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts new file mode 100644 index 0000000000000..efe489b0fde35 --- /dev/null +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts @@ -0,0 +1,74 @@ +import { Persistence } from "@t3tools/client-runtime/platform"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import { vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + clearOutbox: vi.fn<(environmentId: string) => Promise>(), + clearDrafts: vi.fn<(environmentId: string) => Promise>(), + clearComposerErrors: vi.fn(), +})); + +vi.mock("../state/thread-outbox-removal", () => ({ + clearThreadOutboxEnvironment: harness.clearOutbox, +})); +vi.mock("../state/use-composer-drafts", () => ({ + clearComposerDraftsEnvironment: harness.clearDrafts, +})); +vi.mock("../state/thread-composer-error", () => ({ + clearThreadComposerErrorsForEnvironment: harness.clearComposerErrors, +})); + +import * as EnvironmentOwnedDataCleanup from "./environment-owned-data-cleanup"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +describe("EnvironmentOwnedDataCleanup", () => { + it.effect("waits for the other clears and logs a bounded warning when one rejects", () => { + const warnings: Array = []; + let releaseDrafts = () => {}; + let draftsCleared = false; + // The drafts clear finishes only after the outbox failure is logged. + const logger = Logger.make(({ message }) => { + warnings.push(message); + releaseDrafts(); + }); + return Effect.gen(function* () { + harness.clearOutbox.mockRejectedValue(new Error("Some queued messages could not be read.")); + harness.clearDrafts.mockImplementation(() => + new Promise((resolve) => { + releaseDrafts = resolve; + }).then(() => { + draftsCleared = true; + }), + ); + + const cleanup = yield* Persistence.EnvironmentOwnedDataCleanup; + yield* cleanup.clear(ENVIRONMENT_ID); + + expect(draftsCleared).toBe(true); + expect(warnings).toEqual([ + [ + "Could not clear mobile environment-owned data.", + { + environmentId: ENVIRONMENT_ID, + operation: "outbox", + errorType: "error", + errorName: "Error", + stack: expect.any(String), + }, + ], + ]); + }).pipe( + Effect.provide( + Layer.merge( + EnvironmentOwnedDataCleanup.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); +}); diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.ts new file mode 100644 index 0000000000000..28c6cda29a4d3 --- /dev/null +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.ts @@ -0,0 +1,48 @@ +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { Persistence } from "@t3tools/client-runtime/platform"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; +import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; +import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; + +class EnvironmentOwnedDataClearError extends Schema.TaggedError()( + "EnvironmentOwnedDataClearError", + { cause: Schema.Defect() }, +) {} + +export const layer = Layer.succeed( + Persistence.EnvironmentOwnedDataCleanup, + Persistence.EnvironmentOwnedDataCleanup.of({ + clear: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")(function (environmentId) { + // Each clear settles on its own: a failure must not interrupt a clear that is still running. + const settle = ( + operation: "outbox" | "drafts", + clear: (environmentId: EnvironmentId) => Promise, + ) => + Effect.tryPromise({ + try: () => clear(environmentId), + catch: (cause) => new EnvironmentOwnedDataClearError({ cause }), + }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + operation, + ...safeErrorLogAttributes(error.cause), + }), + ), + ); + return Effect.all( + [ + settle("outbox", clearThreadOutboxEnvironment), + settle("drafts", clearComposerDraftsEnvironment), + Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), + ], + { concurrency: "unbounded", discard: true }, + ); + }), + }), +); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index b0d31593b0fa3..dbba73cb6a073 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -1,8 +1,4 @@ -import { - ClientCapabilities, - PlatformConnectionSource, - Persistence, -} from "@t3tools/client-runtime/platform"; +import { ClientCapabilities, PlatformConnectionSource } from "@t3tools/client-runtime/platform"; import { ConnectionBlockedError, ConnectionTransientError, @@ -25,11 +21,9 @@ import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; -import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; -import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; -import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; import { syncTimings } from "@t3tools/client-runtime/fork/syncTimings"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; +import * as EnvironmentOwnedDataCleanup from "./environment-owned-data-cleanup"; import * as ConnectionStorage from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { @@ -257,22 +251,6 @@ const layerPlatformConnectionSource = Layer.succeed( const layerProvidedConnectionStorage = ConnectionStorage.layer.pipe(Layer.provide(Runtime.layer)); const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.layer)); -const layerEnvironmentOwnedDataCleanup = Layer.succeed( - Persistence.EnvironmentOwnedDataCleanup, - Persistence.EnvironmentOwnedDataCleanup.of({ - clear: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")((environmentId) => - Effect.all( - [ - Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), - Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), - Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), - ], - { concurrency: "unbounded", discard: true }, - ), - ), - }), -); - type ConnectionPlatformLayerSource = | typeof layerProvidedConnectionStorage | typeof Runtime.layer @@ -280,7 +258,7 @@ type ConnectionPlatformLayerSource = | typeof layerWakeups | typeof layerProvidedCapabilities | typeof layerPlatformConnectionSource - | typeof layerEnvironmentOwnedDataCleanup; + | typeof EnvironmentOwnedDataCleanup.layer; export const layer: Layer.Layer< Layer.Success, @@ -293,5 +271,5 @@ export const layer: Layer.Layer< layerWakeups, layerProvidedCapabilities, layerPlatformConnectionSource, - layerEnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.layer, ); diff --git a/apps/mobile/src/features/assistant/AssistantPanel.tsx b/apps/mobile/src/features/assistant/AssistantPanel.tsx index dac8ccec82195..566bdf52c3d30 100644 --- a/apps/mobile/src/features/assistant/AssistantPanel.tsx +++ b/apps/mobile/src/features/assistant/AssistantPanel.tsx @@ -796,11 +796,9 @@ function AssistantPanel(props: { readonly capture: AssistantCapture | null }) { existing.trim() ? `\n\n${prepared.text}` : prepared.text, ); } - // Overflow is kept: the panel closes, so a rejected attachment would be lost. + // Restore keeps overflow: the panel closes, so a rejected attachment would be lost. if (prepared) { - appendComposerDraftAttachments(threadDraftKey, prepared.attachments, { - allowOverflow: true, - }); + appendComposerDraftAttachments(threadDraftKey, prepared.attachments, { restore: true }); } url = threadUrl({ environmentId: targetThread.environmentId, threadId: targetThread.id }); // The new thread draft stays unused, as when the panel closes. diff --git a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.test.tsx b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.test.tsx new file mode 100644 index 0000000000000..964d499fd30c4 --- /dev/null +++ b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.test.tsx @@ -0,0 +1,169 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + NavigationContext, + type NavigationProp, + type ParamListBase, +} from "@react-navigation/native"; +import { EnvironmentId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/reactivity"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +// Each listing probes every linked machine, so the test counts the fetches it starts and cuts. +const fetches = vi.hoisted(() => ({ + started: 0, + interrupted: 0, + gate: Promise.resolve(), + open: () => {}, +})); + +vi.mock("@react-navigation/native", async () => { + const { createRequire } = await import("node:module"); + const { realpathSync } = await import("node:fs"); + const require = createRequire(import.meta.url); + const nativeRequire = createRequire( + realpathSync(require.resolve("@react-navigation/native/package.json")), + ); + // Use the shipped focus hook without importing the React Native view layer. + const { NavigationContext, useFocusEffect }: typeof import("@react-navigation/native") = + nativeRequire("@react-navigation/core"); + return { NavigationContext, useFocusEffect }; +}); +const appState = vi.hoisted(() => ({ listeners: new Set<(state: string) => void>() })); + +vi.mock("react-native", () => ({ + AppState: { + addEventListener: (_event: "change", listener: (state: string) => void) => { + appState.listeners.add(listener); + return { remove: () => appState.listeners.delete(listener) }; + }, + }, + View: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../../components/ui/AppText", () => ({ + AppText: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../settings/components/SettingsSection", () => ({ + SettingsSection: ({ children }: { children?: ReactNode }) => children, +})); +vi.mock("../../state/entities", () => ({ + useEnvironmentServerConfig: () => ({ + environment: { capabilities: { peerLinks: true } }, + settings: { linkedEnvironmentsEnabled: true }, + }), +})); +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../state/peerLinks", async () => { + const { Atom } = await import("effect/reactivity"); + const EffectModule = await import("effect/Effect"); + // Waits for the gate, as a list waits on its slowest linked machine. + const list = Atom.make( + EffectModule.gen(function* () { + fetches.started += 1; + yield* EffectModule.promise(() => fetches.gate); + return { links: [] }; + }).pipe( + EffectModule.onInterrupt(() => + EffectModule.sync(() => { + fetches.interrupted += 1; + }), + ), + ), + ); + return { peerLinkEnvironment: { list: () => list } }; +}); + +import { LinkedEnvironmentsSection } from "./LinkedEnvironmentsSection"; + +function createNavigation() { + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + return { + value: { + isFocused: () => focused, + addListener: (event: keyof typeof listeners, listener: () => void) => { + listeners[event].add(listener); + return () => listeners[event].delete(listener); + }, + } as unknown as NavigationProp, + focus(next: boolean) { + focused = next; + act(() => { + for (const listener of listeners[next ? "focus" : "blur"]) listener(); + }); + }, + }; +} + +function setup() { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + fetches.started = 0; + fetches.interrupted = 0; + fetches.gate = new Promise((resolve) => { + fetches.open = resolve; + }); + const registry = AtomRegistry.make(); + const root = createRoot(document.createElement("div")); + const navigation = createNavigation(); + act(() => + root.render( + + + + + , + ), + ); + return { + focus: navigation.focus, + moveApp: (state: "active" | "background") => + act(() => { + for (const listener of appState.listeners) listener(state); + }), + settle: () => + act(async () => { + fetches.open(); + await fetches.gate; + }), + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; +} + +it("asks again on focus only once the list it is waiting for has answered", async () => { + using screen = setup(); + // The first focus comes while the first fetch runs, which must not be cut and restarted. + expect(fetches).toMatchObject({ started: 1, interrupted: 0 }); + screen.focus(false); + screen.focus(true); + expect(fetches).toMatchObject({ started: 1, interrupted: 0 }); + + await screen.settle(); + screen.focus(false); + screen.focus(true); + expect(fetches).toMatchObject({ started: 2, interrupted: 0 }); +}); + +it("asks again when the app comes back to the front on this screen", async () => { + using screen = setup(); + await screen.settle(); + expect(fetches).toMatchObject({ started: 1, interrupted: 0 }); + fetches.gate = new Promise((resolve) => { + fetches.open = resolve; + }); + + screen.moveApp("background"); + screen.moveApp("active"); + expect(fetches).toMatchObject({ started: 2, interrupted: 0 }); + + // Coming back while that answer is still out must not cut it and start again. + screen.moveApp("background"); + screen.moveApp("active"); + expect(fetches).toMatchObject({ started: 2, interrupted: 0 }); +}); diff --git a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx index 86138603c501f..0d0cdb5470ad2 100644 --- a/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx +++ b/apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx @@ -1,10 +1,11 @@ +import { RegistryContext } from "@effect/atom-react"; import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request"; import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks"; import { arrowLinkStatus } from "@t3tools/client-runtime/fork/arrowLinks"; import { useFocusEffect } from "@react-navigation/native"; import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import { useCallback, useState } from "react"; +import { useCallback, useContext, useState } from "react"; import { AppState, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; @@ -21,8 +22,25 @@ export function LinkedEnvironmentsSection(props: { const config = useEnvironmentServerConfig(props.environmentId); const canRead = useEnvironmentScope(props.environmentId, AuthAccessReadScope); const supported = canRead && offersLinkedEnvironments(config); - const links = useEnvironmentQuery( - supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null, + const listAtom = supported + ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) + : null; + const links = useEnvironmentQuery(listAtom); + const registry = useContext(RegistryContext); + // Links change outside this client, and the shared list only revalidates when built, so + // showing the screen and bringing the app back ask again. + useFocusEffect( + useCallback(() => { + if (listAtom === null) return; + const askAgain = () => { + if (!registry.get(listAtom).waiting) registry.refresh(listAtom); + }; + askAgain(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") askAgain(); + }); + return () => subscription.remove(); + }, [listAtom, registry]), ); const [nowMs, setNowMs] = useState(Date.now); const hasLinks = supported && (links.data?.links.length ?? 0) > 0; diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index 6119023fb926c..4ed7e780a6117 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -41,7 +41,6 @@ export function useConnectionController() { const headersFollowAddress = useRequestHeadersFollowAddress(connectedEnvironments); const registerEnvironment = useAtomCommand(environmentCatalog.register, "environment register"); const removeEnvironmentMutation = useAtomCommand(environmentCatalog.remove, "environment remove"); - const retryEnvironmentMutation = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const setEnvironmentEnabledMutation = useAtomCommand( environmentCatalog.setEnabled, "environment toggle", @@ -87,8 +86,6 @@ export function useConnectionController() { ); const removeEnvironment = (environmentId: EnvironmentId) => removeEnvironmentMutation(environmentId); - const retryEnvironment = (environmentId: EnvironmentId) => - retryEnvironmentMutation(environmentId); const setEnvironmentEnabled = (environmentId: EnvironmentId, enabled: boolean) => setEnvironmentEnabledMutation({ environmentId, enabled }); const updateEnvironment = async ( @@ -118,7 +115,6 @@ export function useConnectionController() { connectPairingUrl, connectRelayEnvironment, removeEnvironment, - retryEnvironment, setEnvironmentEnabled, updateEnvironment, refreshRelayEnvironments, diff --git a/apps/mobile/src/features/connection/useRetryEnvironment.test.tsx b/apps/mobile/src/features/connection/useRetryEnvironment.test.tsx new file mode 100644 index 0000000000000..da94dedf2e78a --- /dev/null +++ b/apps/mobile/src/features/connection/useRetryEnvironment.test.tsx @@ -0,0 +1,108 @@ +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + EnvironmentRegistry, + PrimaryConnectionTarget, + type ConnectionCatalogEntry, +} from "@t3tools/client-runtime/connection"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AtomRegistry } from "effect/reactivity"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + environment: undefined as + | Pick< + EnvironmentRegistry.EnvironmentRegistry["Service"], + "entries" | "retryNow" | "setEnabled" + > + | undefined, +})); + +vi.mock("../../connection/runtime", async () => { + const { Atom } = await import("effect/reactivity"); + const Layer = await import("effect/Layer"); + const { EnvironmentRegistry } = await import("@t3tools/client-runtime/connection"); + return { + connectionAtomRuntime: Atom.runtime( + Layer.suspend(() => + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + fixture.environment as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"], + ), + ), + ), + }; +}); + +import { useRetryEnvironment } from "./useRetryEnvironment"; + +const environmentId = EnvironmentId.make("retry-environment"); + +describe("useRetryEnvironment", () => { + it.effect.each([ + { enabled: false, retries: 0 }, + { enabled: true, retries: 1 }, + ])("Retry now with the environment enabled: $enabled", ({ enabled, retries }) => + Effect.gen(function* () { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const target = new PrimaryConnectionTarget({ + environmentId, + label: "Retry", + httpBaseUrl: "https://retry.test", + wsBaseUrl: "wss://retry.test", + }); + const entries = yield* SubscriptionRef.make< + ReadonlyMap + >(new Map([[environmentId, { target, profile: Option.none(), enabled }]])); + let retryCount = 0; + fixture.environment = { + entries, + retryNow: () => + Effect.sync(() => { + retryCount += 1; + }), + setEnabled: (id, next) => + SubscriptionRef.update(entries, (current) => + new Map(current).set(id, { ...current.get(id)!, enabled: next }), + ), + }; + + const registry = AtomRegistry.make(); + const root = createRoot(document.createElement("div")); + using _mounted = { + [Symbol.dispose]() { + act(() => root.unmount()); + registry.dispose(); + }, + }; + let retryEnvironment!: ReturnType; + function Probe() { + const value = useRetryEnvironment(); + useLayoutEffect(() => { + retryEnvironment = value; + }); + return null; + } + yield* Effect.promise(async () => { + await act(async () => + root.render( + + + , + ), + ); + await act(async () => { + await retryEnvironment(environmentId); + }); + }); + + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + expect({ enabled: entry?.enabled, retries: retryCount }).toEqual({ enabled: true, retries }); + }), + ); +}); diff --git a/apps/mobile/src/features/connection/useRetryEnvironment.ts b/apps/mobile/src/features/connection/useRetryEnvironment.ts new file mode 100644 index 0000000000000..b5d8edc749230 --- /dev/null +++ b/apps/mobile/src/features/connection/useRetryEnvironment.ts @@ -0,0 +1,17 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { environmentCatalog } from "../../connection/catalog"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function useRetryEnvironment() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const retryNow = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const setEnabled = useAtomCommand(environmentCatalog.setEnabled, "environment toggle"); + + return (environmentId: EnvironmentId) => + // A retry does nothing while the environment is switched off. + catalog.entries.get(environmentId)?.enabled === false + ? setEnabled({ environmentId, enabled: true }) + : retryNow(environmentId); +} diff --git a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx index 63c3e7be0c65e..eccac90d9abea 100644 --- a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx +++ b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx @@ -2,11 +2,10 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { View } from "react-native"; import { EmptyState } from "../../components/ui/EmptyState"; -import { environmentCatalog } from "../../connection/catalog"; import type { AssetUrlFailureReason } from "../../state/asset-url-state"; -import { useAtomCommand } from "../../state/use-atom-command"; import { useEnvironmentPresentation } from "../../state/presentation"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { useRetryEnvironment } from "../connection/useRetryEnvironment"; export function WorkspaceFilePreviewError(props: { readonly environmentId: EnvironmentId | null; @@ -15,7 +14,7 @@ export function WorkspaceFilePreviewError(props: { }) { const { environmentId, onRetry } = props; const environment = useEnvironmentPresentation(environmentId); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const retryEnvironment = useRetryEnvironment(); const retryConnection = () => { if (environmentId !== null) void retryEnvironment(environmentId); onRetry(); diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index c58316af3698b..75f46e23f10cd 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -62,11 +62,18 @@ describe("needsWorkspaceFileContents", () => { }); describe("workspace preview classifiers", () => { - it("treat # and ? as literal filename characters", () => { + it("recognize preview extensions and treat # and ? as literal filename characters", () => { for (const path of ["notes/clip#1.wav", "notes/clip?1.wav", "notes#old/clip.wav"]) { expect(isAudioPreviewFile(path)).toBe(true); } - for (const path of ["docs/draft#1.md", "docs/draft?1.md", "docs#old/draft.md"]) { + for (const path of [ + "docs/draft#1.md", + "docs/draft?1.md", + "docs#old/draft.md", + "CHANGELOG.markdown", + "notes.MDOWN", + "todo.mkd", + ]) { expect(isMarkdownPreviewFile(path)).toBe(true); } }); diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index 330ff5784da6d..8e659280fdd27 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -108,7 +108,7 @@ export function isSvgImagePreviewFile(path: string): boolean { } export function isMarkdownPreviewFile(path: string): boolean { - return /\.(?:md|mdx)$/i.test(path); + return /\.(?:md|mdx|markdown|mdown|mkd)$/i.test(path); } export function needsWorkspaceFileContents(input: { diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 0bea39e419e21..135cf4e1d85a7 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({ failedAction: null as string | null, titleOutcome: null as ThreadTitleRegenerationOutcome | null, archivedRefreshes: [] as string[], + onArchivedRefresh: undefined as (() => void) | undefined, scopes: new Map>(), shells: [] as EnvironmentThreadShell[], requests: [] as { @@ -59,6 +60,7 @@ vi.mock("../../components/ui/Dialog", () => ({ vi.mock("../archive/useArchivedThreadSnapshots", () => ({ refreshArchivedThreadsForEnvironment: (environmentId: string) => { state.archivedRefreshes.push(environmentId); + state.onArchivedRefresh?.(); }, })); vi.mock("../../state/session", () => ({ @@ -71,8 +73,9 @@ vi.mock("../../state/presentation", () => ({ vi.mock("../../state/server", () => ({ environmentServerConfigsAtom: "server-configs", })); -vi.mock("../../state/atom-registry", () => ({ - appAtomRegistry: { +vi.mock("../../state/atom-registry", async () => { + const registry = (await import("effect/reactivity")).AtomRegistry.make(); + const stub = { set: (_atom: string, value: boolean) => { state.dropBusy = value; }, @@ -103,8 +106,21 @@ vi.mock("../../state/atom-registry", () => ({ }, ]), ), - }, -})); + }; + // Real atoms such as composerDraftsAtom go to a real registry; strings are stubbed state. + return { + appAtomRegistry: { + get: (atom: unknown) => + typeof atom === "string" + ? stub.get(atom) + : registry.get(atom as Parameters[0]), + set: (atom: unknown, value: unknown) => + typeof atom === "string" + ? stub.set(atom, value as boolean) + : registry.set(atom as Parameters[0], value), + }, + }; +}); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command, })); @@ -164,6 +180,8 @@ vi.mock("../../state/threads", () => ({ ), })); +import { appAtomRegistry } from "../../state/atom-registry"; +import { composerDraftsAtom } from "../../state/use-composer-drafts"; import { dismissThreadUndoNotice, readThreadUndoNotice } from "./thread-undo-notice"; import { useThreadListActions } from "./useThreadListActions"; @@ -197,6 +215,7 @@ beforeEach(() => { state.failedAction = null; state.titleOutcome = null; state.archivedRefreshes = []; + state.onArchivedRefresh = undefined; state.pendingOrder = null; state.dropBusy = false; state.holdingRows = false; @@ -250,7 +269,9 @@ describe("thread list operation permissions", () => { expect(state.requests).toEqual([]); }); - it("rechecks permission after a retained delete confirmation", async () => { + it("rechecks permission after a retained delete confirmation, then clears the draft", async () => { + const draft = { text: "unsent", attachments: [] }; + appAtomRegistry.set(composerDraftsAtom, { "primary:thread": draft, "primary:other": draft }); useThreadListActions().confirmDeleteThread(makeThread()); const confirm = state.dialogs[0]?.onConfirm; expect(confirm).toBeTypeOf("function"); @@ -259,6 +280,15 @@ describe("thread list operation permissions", () => { await confirm!(); expect(state.requests).toEqual([]); + expect(Object.keys(appAtomRegistry.get(composerDraftsAtom))).toContain("primary:thread"); + + state.scopes.get(primaryEnvironmentId)!.add(AuthOrchestrationOperateScope); + const deleted = new Promise((resolve) => (state.onArchivedRefresh = resolve)); + confirm!(); + await deleted; + + expect(state.requests).toEqual([expect.objectContaining({ action: "delete" })]); + expect(Object.keys(appAtomRegistry.get(composerDraftsAtom))).toEqual(["primary:other"]); }); }); @@ -594,6 +624,19 @@ describe("thread action undo", () => { await undo(); expect(state.requests.map(({ action }) => action)).toEqual(["unpin"]); }); + + it("keeps the earlier Undo until archiving succeeds", async () => { + const actions = useThreadListActions(); + const thread = makeThread(); + await actions.settleThread(thread); + state.failedAction = "archive"; + expect(await actions.archiveThread(thread)).toBe(false); + expect(readThreadUndoNotice()?.action).toBe("Settled"); + + state.failedAction = null; + expect(await actions.archiveThread(thread)).toBe(true); + expect(readThreadUndoNotice()).toBeNull(); + }); }); describe("thread archive guard", () => { diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 70c1e3621a84c..938b44efc7da8 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -21,6 +21,7 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { connectedEnvironmentIdsAtom } from "../../state/presentation"; import { readEnvironmentScope } from "../../state/session"; +import { clearComposerDraft } from "../../state/use-composer-drafts"; import { environmentThreadShells, steadyNavigationThreadShellsAtom, @@ -154,9 +155,6 @@ function useThreadActionExecutor( return false; } if (action === "unsettle") ThreadUndo.invalidate("settle", key); - if (action === "archive" || action === "delete") { - for (const kind of ["settle", "snooze", "pin"]) ThreadUndo.invalidate(kind, key); - } onStarted?.(); const result = await withThreadDismissal( key, @@ -186,9 +184,13 @@ function useThreadActionExecutor( Dialog.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); return false; } + if (action === "archive" || action === "delete") { + for (const kind of ["settle", "snooze", "pin"]) ThreadUndo.invalidate(kind, key); + } if (action === "archive" || action === "unarchive" || action === "delete") { refreshArchivedThreadsForEnvironment(thread.environmentId); } + if (action === "delete") clearComposerDraft(key); onCompleted?.(action, thread); return true; })(); diff --git a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx index bac954d99f118..71a452f7d342a 100644 --- a/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx +++ b/apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx @@ -331,6 +331,7 @@ function WorkspaceColumns( const primary = props.descriptors[props.primary.key]; if (!primary) return null; + const primaryColumnWidth = (layout.listPaneWidth ?? 320) + (layout.listPaneGap ?? 0) / 2; return ( [comment.id, comment]), ).values(), - ].sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + ].sort((left, right) => compareTimestamps(left.createdAt, right.createdAt)); const scrollRef = useRef(null); const pendingTop = useRef(null); const positionedComment = useRef(null); diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index fbcb62406b7a7..5bec031fb735f 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -41,12 +41,18 @@ function choices(field: string, options: Readonly>, selec })); } -type ProjectOrder = readonly { readonly memberProjectRefs: readonly ScopedProjectRef[] }[]; +type ProjectOrder = readonly { + readonly memberProjectRefs: readonly ScopedProjectRef[]; + readonly pinned: boolean; + readonly archived: boolean; +}[]; const sameProjectOrder = (previous: ProjectOrder, next: ProjectOrder) => previous.length === next.length && previous.every((group, index) => { const members = next[index]!.memberProjectRefs; return ( + group.pinned === next[index]!.pinned && + group.archived === next[index]!.archived && group.memberProjectRefs.length === members.length && group.memberProjectRefs.every( (ref, member) => @@ -69,7 +75,11 @@ const sidebarProjectOrderFamily = Atom.family((mode: SidebarProjectGroupingMode) threads: get(environmentThreadShells.threadShellsAtom), pendingTasks: [], projectSortOrder: "updated_at", - }).map((scope) => ({ memberProjectRefs: scope.projectRefs })), + }).map((scope) => ({ + memberProjectRefs: scope.projectRefs, + pinned: scope.pinned, + archived: scope.archived, + })), ).pipe(Atom.withEquality(sameProjectOrder)), ); diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index 2d0414b503653..3889dd87ed2f4 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -3,12 +3,14 @@ import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState } from "react"; import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { SplitButton } from "../../components/ui/SplitButton"; +import { useEnvironmentServerConfig } from "../../state/entities"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -19,6 +21,7 @@ import { MERGE_METHODS, PR_ACTION_ICONS, pullRequestActionLabel, + resolveMergeMethod, } from "./pullRequestActions"; import { pullRequestStackMerge, singlePullRequestMergeAllowed } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; @@ -39,6 +42,7 @@ export function PullRequestMergeControls(props: { const permissions = usePullRequestPermissions(props.params, pr); const stateAction = usePullRequestAction(props.params, pr); const preferences = useAtomValue(mobilePreferencesAtom); + const serverSettings = useEnvironmentServerConfig(target.environmentId)?.settings; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const runAction = useAtomCommand(composerPullRequests.runAction, { reportFailure: false }); const refreshDetail = useAtomRefresh(composerPullRequests.detail(target)); @@ -47,15 +51,15 @@ export function PullRequestMergeControls(props: { const [busy, setBusy] = useState(false); const pending = useRef(false); const methods = allowedMergeMethods(pr); - const preferred = - selectedMethod ?? - (AsyncResult.isSuccess(preferences) ? preferences.value.pullRequestMergeMethod : undefined) ?? - "squash"; - const method = methods.includes(preferred) - ? preferred - : methods.includes("squash") - ? "squash" - : methods[0]; + const method = resolveMergeMethod( + pr, + selectedMethod, + serverSettings + ? resolveProjectSettings(serverSettings, target.input.projectId).settings + .pullRequestMergeMethod + : null, + AsyncResult.isSuccess(preferences) ? preferences.value.pullRequestMergeMethod : undefined, + ); const scope = stack.stack ? pullRequestStackMerge(stack.stack, pr.number, terminology) : null; const canMergeSingle = singlePullRequestMergeAllowed({ supportsStackActions: stack.supportsStackActions, diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index f3c47e2582858..8e33a5bec1007 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -142,15 +142,13 @@ function usePullRequestActivityRows(params: PullRequestParams, filter: ActivityF const filtered = result.data ? filterPullRequestActivity(result.data, filter) : null; const rows = (() => { if (!filtered) return []; - const rows = pullRequestTimelineRows(filtered.activity, params, navigation, { - permissions, - onChanged: result.refresh, - }); - if (filter.newestFirst) { - const notice = rows.at(-1)?.key === "truncated" ? rows.pop() : undefined; - rows.reverse(); - if (notice) rows.push(notice); - } + const rows = pullRequestTimelineRows( + filtered.activity, + params, + navigation, + { permissions, onChanged: result.refresh }, + filter.newestFirst, + ); if (!rows.length) rows.push({ key: "empty", diff --git a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx index bf9875a806907..9ec967dc0ed2c 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx @@ -29,6 +29,7 @@ import { } from "./pullRequestActions"; import { checkStatus, + isFailingCheck, pullRequestBranchSummary, pullRequestChecksSummary, pullRequestReviewSummary, @@ -134,8 +135,7 @@ export function PullRequestChecksRow(props: RowProps) { exiting={PR_ROW_EXIT} className="pl-8" > - {props.pr.state === "open" && - props.pr.checks.some((check) => check.status === "failure") ? ( + {props.pr.state === "open" && props.pr.checks.some(isFailingCheck) ? ( ) : null} {action.available("approve-workflows") ? ( diff --git a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx index 4b7ba3a968187..586d9947510f3 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestTimeline.tsx @@ -17,7 +17,7 @@ import { Pressable, StyleSheet, View } from "react-native"; import { parseMarkdownDocument } from "../../lib/markdownDocument"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; -import { relativeTime } from "../../lib/time"; +import { byTimestamp, relativeTime } from "../../lib/time"; import { PullRequestAvatar } from "./PullRequestPresentation"; import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestCommentFooter } from "./PullRequestReactions"; @@ -218,6 +218,7 @@ export function pullRequestTimelineRows( params: PullRequestParams, navigation: Pick, "navigate" | "dispatch">, actions: CommentActions, + newestFirst: boolean, ) { const linkTarget = parsePullRequestAnchor(params.navigationUrl); const threads = pullRequestConversationThreads(activity); @@ -320,7 +321,7 @@ export function pullRequestTimelineRows( ), })), - ].sort((a, b) => a.date.localeCompare(b.date)); + ].sort(byTimestamp(newestFirst)); if (activity.commentsTruncated || activity.reviewThreadsTruncated) events.push({ key: "truncated", diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts index 2234a31081802..6862b5b31b2fb 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.test.ts @@ -5,6 +5,7 @@ import { canPerformPullRequestAction, mergeUnavailableReason, pullRequestActionLabel, + resolveMergeMethod, } from "./pullRequestActions"; const GITHUB = sourceControlClients.get("github").changeRequest; @@ -65,6 +66,22 @@ describe("mobile pull request actions", () => { expect(mergeUnavailableReason(pr, true, GITHUB)).toContain("conflicts"); }); + it("picks the chosen, project default, then last used merge method the repository allows", () => { + const pr = detail(); + expect(resolveMergeMethod(pr, null, "merge", "squash")).toBe("merge"); + expect(resolveMergeMethod(pr, "squash", "merge", "merge")).toBe("squash"); + expect(resolveMergeMethod(pr, null, "rebase", "merge")).toBe("merge"); + expect(resolveMergeMethod(pr, null, null, undefined)).toBe("squash"); + expect( + resolveMergeMethod( + detail({ mergeCapabilities: { merge: true, squash: false, rebase: false } }), + null, + null, + "rebase", + ), + ).toBe("merge"); + }); + it("names the change request with its host's term", () => { expect(pullRequestActionLabel("close", GITHUB)).toBe("Close pull request"); expect(pullRequestActionLabel("close", GITLAB)).toBe("Close merge request"); diff --git a/apps/mobile/src/features/pull-requests/pullRequestActions.ts b/apps/mobile/src/features/pull-requests/pullRequestActions.ts index 132501b60a240..967689b57d84e 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestActions.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestActions.ts @@ -87,6 +87,20 @@ export function allowedMergeMethods(pr: ActionDetail) { return pr.capabilities.mergeMethods.filter((method) => pr.mergeCapabilities[method]); } +export function resolveMergeMethod( + pr: ActionDetail, + selected: PullRequestMergeMethod | null, + projectDefault: PullRequestMergeMethod | null, + lastUsed: PullRequestMergeMethod | undefined, +) { + const methods = allowedMergeMethods(pr); + return ( + [selected, projectDefault, lastUsed, "squash" as const].find( + (method) => method != null && methods.includes(method), + ) ?? methods[0] + ); +} + export function allowedUpdateMethods(pr: ActionDetail) { return (pr.capabilities.updateMethods ?? []).filter((method) => pr.viewerPermissions.updateMethods?.includes(method), diff --git a/apps/mobile/src/features/pull-requests/pullRequestList.ts b/apps/mobile/src/features/pull-requests/pullRequestList.ts index 674d3b0e24fe3..5a4572dbde803 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestList.ts @@ -11,6 +11,7 @@ import { type PullRequestListStatsInput, } from "@t3tools/contracts"; import type { PullRequestListControls } from "../../persistence/mobile-preferences"; +import { compareTimestamps } from "../../lib/time"; export const PR_LIST_PAGE_SIZE = 30; export type { PullRequestListControls } from "../../persistence/mobile-preferences"; @@ -242,7 +243,7 @@ export function mergePullRequestListEntries( const held = entries.get(key); if (!held || (entry.observedAt ?? 0) >= (held.observedAt ?? 0)) entries.set(key, entry); } - return [...entries.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return [...entries.values()].sort((a, b) => compareTimestamps(b.updatedAt, a.updatedAt)); } export interface PullRequestListPages { @@ -455,10 +456,10 @@ export function filterPullRequestList< } return [...unique.values()].sort((a, b) => controls.sort === "created" - ? b.createdAt.localeCompare(a.createdAt) + ? compareTimestamps(b.createdAt, a.createdAt) : controls.sort === "oldest" - ? a.updatedAt.localeCompare(b.updatedAt) - : b.updatedAt.localeCompare(a.updatedAt), + ? compareTimestamps(a.updatedAt, b.updatedAt) + : compareTimestamps(b.updatedAt, a.updatedAt), ); } diff --git a/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts b/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts index aef40179ded64..e5cb4551db56b 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStatus.test.ts @@ -97,5 +97,8 @@ describe("mobile overview check status", () => { expect( pullRequestChecksSummary({ checks: [check("success"), check("pending"), check("failure")] }), ).toMatchObject({ title: "1 check failing", count: "1 of 3" }); + expect( + pullRequestChecksSummary({ checks: [check("success"), check("cancelled")] }), + ).toMatchObject({ title: "1 check failing", tint: "accent-danger-foreground" }); }); }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestStatus.ts b/apps/mobile/src/features/pull-requests/pullRequestStatus.ts index 5426266dc6893..d664aef1fc015 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestStatus.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestStatus.ts @@ -88,8 +88,12 @@ export function checkStatus(check: PullRequestCheck) { } } +export function isFailingCheck(check: PullRequestCheck) { + return check.status === "failure" || check.status === "cancelled"; +} + export function pullRequestChecksSummary(pr: Pick) { - const failed = pr.checks.filter((check) => check.status === "failure").length; + const failed = pr.checks.filter(isFailingCheck).length; const required = pr.checks.filter((check) => check.status === "action-required").length; const pending = pr.checks.filter((check) => check.status === "pending").length; const passed = pr.checks.filter((check) => check.status === "success").length; diff --git a/apps/mobile/src/features/review/ReviewCommentCard.tsx b/apps/mobile/src/features/review/ReviewCommentCard.tsx index a65bd83e9a66d..3e2cdc9ddfb6c 100644 --- a/apps/mobile/src/features/review/ReviewCommentCard.tsx +++ b/apps/mobile/src/features/review/ReviewCommentCard.tsx @@ -12,8 +12,10 @@ import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { buildNativeReviewDiffData, buildNativeReviewSnippetRows, + buildReviewCommentPatch, createNativeReviewDiffTheme, NATIVE_REVIEW_DIFF_CONTENT_WIDTH, + reviewCommentDiffText, } from "./nativeReviewDiffAdapter"; import { buildReviewParsedDiff } from "./reviewModel"; import { REVIEW_MONO_FONT_FAMILY } from "./reviewDiffRendering"; @@ -86,7 +88,7 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { theme: appearanceScheme, }); const snippetTokens = useMarkdownCodeHighlight({ - code: props.comment.diff.trim(), + code: reviewCommentDiffText(props.comment.diff), language: props.comment.fenceLanguage ?? language, enabled: compactNativeRows.length === 0, theme: appearanceScheme, @@ -234,32 +236,10 @@ export const ReviewCommentCard = memo(function ReviewCommentCard(props: { ) /* oxlint-enable react/no-array-index-key */, ) - : props.comment.diff.trim()} + : reviewCommentDiffText(props.comment.diff)} ) : null} ); }); - -function buildReviewCommentPatch(comment: ReviewInlineComment): string { - if ((comment.fenceLanguage ?? "diff") !== "diff") { - return ""; - } - const diff = comment.diff.trim(); - if (!diff) { - return ""; - } - - if (diff.startsWith("diff --git ")) { - return diff; - } - - const normalizedPath = comment.filePath.replaceAll("\\", "/"); - return [ - `diff --git a/${normalizedPath} b/${normalizedPath}`, - `--- a/${normalizedPath}`, - `+++ b/${normalizedPath}`, - diff, - ].join("\n"); -} diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 21bf033b761be..7cf0716277b80 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -26,11 +26,10 @@ import { import { PaneListItem } from "../../components/ui/PaneListItem"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { SheetPane } from "../../components/ui/SheetPane"; -import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; -import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { useRetryEnvironment } from "../connection/useRetryEnvironment"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { Inspector, @@ -351,7 +350,7 @@ export function ReviewSheet(props: ReviewSheetProps) { const { themeAppearance: selectedTheme } = useAppearancePreferences(); const { environmentId, threadId } = props.route.params; const environment = useEnvironmentPresentation(environmentId); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const retryEnvironment = useRetryEnvironment(); const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage, draftContext } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 7e620836baccd..06c577df1bda0 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -8,7 +8,9 @@ import { combinePullRequestDiffPages } from "../pull-requests/pullRequestDiffPag import { createPullRequestDiffHighlightFiles } from "../pull-requests/pullRequestDiffHighlightFiles"; import { buildNativeReviewDiffData, + buildReviewCommentPatch, getCachedNativeReviewDiffData, + reviewCommentDiffText, } from "./nativeReviewDiffAdapter"; import type { ReviewInlineComment } from "./reviewCommentSelection"; import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; @@ -46,6 +48,27 @@ function filesPatch(paths: ReadonlyArray) { .join("\n"); } +describe("buildReviewCommentPatch", () => { + it("keeps a selection's trailing blank line and whitespace", () => { + const rows = (diff: string) => + buildNativeReviewDiffData( + buildReviewParsedDiff( + buildReviewCommentPatch({ ...makeComment("Insert here."), diff }), + "review-comment-trailing-blank", + ), + ).rows.filter((row) => row.kind === "line"); + + expect(rows("@@ -3,1 +2,2 @@\n+two\t\n ")).toMatchObject([ + { content: "two\t", change: "add" }, + { content: "", change: "context" }, + ]); + expect(rows("@@ -4,1 +4,1 @@\n ")).toMatchObject([{ content: "", change: "context" }]); + expect(reviewCommentDiffText("\n@@ -3,1 +2,2 @@\n+two\t\n \n")).toBe( + "@@ -3,1 +2,2 @@\n+two\t\n ", + ); + }); +}); + describe("buildNativeReviewDiffData languages", () => { it.each([ ["js", "javascript"], diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index d55aa97e305c9..e943fd5c57928 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -44,6 +44,32 @@ export function buildNativeReviewSnippetRows( })); } +export function reviewCommentDiffText(diff: string): string { + return diff.trimStart().replace(/\n+$/, ""); +} + +export function buildReviewCommentPatch(comment: ReviewInlineComment): string { + if ((comment.fenceLanguage ?? "diff") !== "diff") { + return ""; + } + if (!comment.diff.trim()) { + return ""; + } + const diff = reviewCommentDiffText(comment.diff); + + if (diff.startsWith("diff --git ")) { + return diff; + } + + const normalizedPath = comment.filePath.replaceAll("\\", "/"); + return [ + `diff --git a/${normalizedPath} b/${normalizedPath}`, + `--- a/${normalizedPath}`, + `+++ b/${normalizedPath}`, + diff, + ].join("\n"); +} + function opaqueNativeHexColor(color: string, background: string): string { const hex = NATIVE_HEX_COLOR.exec(color); if (hex && !hex[4]) return color; diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts index d9ec656cbc773..18a22e96b62bb 100644 --- a/apps/mobile/src/features/review/useReviewDiffData.ts +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -142,13 +142,7 @@ export function useReviewDiffData(input: { }); const registry = useContext(RegistryContext); const { environmentId, cwd } = input; - const scope = JSON.stringify([ - environmentId, - cwd, - source?.kind, - source?.baseRef, - source?.diffHash, - ]); + const scope = JSON.stringify([environmentId, cwd, lazySource?.kind, source?.baseRef]); const [requested, setRequested] = useState({ scope, indices: [0, 1, 2] }); const indices = useMemo( () => new Set(requested.scope === scope ? requested.indices : [0, 1, 2]), diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index 3cd08581c762b..c995084a9765e 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -139,7 +139,11 @@ function ProjectOverviewContent(props: { }) { const { isRemoving, setIsRemoving, startRemoval, finishRemoval } = props; const representative = props.members[0]!; - const displayName = deriveProjectGroupLabel({ representative, members: props.members }); + const displayName = deriveProjectGroupLabel({ + groupKey: props.group.key, + representative, + members: props.members, + }); const [draftName, setDraftName] = useState(null); const [isSaving, setIsSaving] = useState(false); const threads = useThreadShells(); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 76052f2d799a3..9e97fa47ad7a9 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -255,8 +255,9 @@ function useSettingsIndexSections(): SettingsListSection[] { selectedTargets.some((target) => target.environmentId === project.environmentId), ) ?? []; const projectLabel = - scopedProjectMembers.length > 0 + selectedProject && scopedProjectMembers.length > 0 ? deriveProjectGroupLabel({ + groupKey: selectedProject.key, representative: scopedProjectMembers[0]!, members: scopedProjectMembers, }) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx new file mode 100644 index 0000000000000..ff6ca4e0f160d --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx @@ -0,0 +1,266 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ScheduledTaskId, + type ScheduledTask, +} from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { Children, mocks } = vi.hoisted(() => ({ + Children: ({ children }: { children?: ReactNode }) => children, + mocks: { upsert: vi.fn(), alert: vi.fn(), tasks: [] as ScheduledTask[] }, +})); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + View: Children, + TextInput: () => null, + Pressable: ({ children, onPress }: { children?: ReactNode; onPress: () => void }) => ( + + ), +})); +vi.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ isFocused: () => false }), + usePreventRemove: () => {}, +})); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ bottom: 0 }) })); +vi.mock("@expo/ui/community/datetime-picker", () => ({ DateTimePicker: () => null })); +vi.mock("../../components/ui/Dialog", () => ({ alert: mocks.alert })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: () => null })); +vi.mock("../../components/EnvironmentMachineSymbol", () => ({ + EnvironmentMachineSymbol: () => null, + ENVIRONMENT_MACHINE_SYMBOLS: {}, +})); +vi.mock("../../components/ui/Menu", () => ({ + Menu: ({ + actions, + onPressAction, + }: { + actions: readonly { id: string; title: string }[]; + onPressAction: (event: { nativeEvent: { event: string } }) => void; + }) => + actions.map(({ id, title }) => ( + + )), +})); +vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); +vi.mock("./components/SettingsScreen", () => ({ SettingsScreen: Children })); +vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children })); +vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({ + useSettingsEnvironmentMenu: () => null, +})); +vi.mock("./components/ScheduledTaskPromptField", () => ({ ScheduledTaskPromptField: () => null })); +vi.mock("../layout/workspace/useSelectWorkspaceDetail", () => ({ + useSelectWorkspaceDetail: () => vi.fn(), +})); +vi.mock("../../state/threads", () => ({ threadFamilies: {} })); +vi.mock("../../lib/copyTextWithHaptic", () => ({ tryCopyTextWithHaptic: vi.fn() })); +vi.mock("../voice-input/useVoiceInputController", () => ({ + useVoiceInputController: () => ({ blocksSubmission: false }), +})); +vi.mock("../../components/ui/Button", () => ({ + Button: ({ label, onPress }: { label: string; onPress: () => void }) => ( + + ), +})); +vi.mock("../../components/ui/SegmentedControl", () => ({ + SegmentedControl: ({ + options, + onSelect, + }: { + options: readonly { value: string; label: string }[]; + onSelect: (value: string) => void; + }) => + options.map(({ value, label }) => ( + + )), +})); +vi.mock("../../components/ui/ThemedSwitch", () => ({ + ThemedSwitch: ({ + accessibilityLabel, + value, + onValueChange, + }: { + accessibilityLabel: string; + value: boolean; + onValueChange: (value: boolean) => void; + }) => , +})); +vi.mock("../../state/entities", () => ({ + useProjects: () => [ + { environmentId: "environment", id: "project", title: "Project" }, + { environmentId: "target", id: "target-project", title: "Target project" }, + ], + useServerConfigs: () => new Map(), + useEnvironmentServerConfig: () => null, + useThreadShell: () => null, +})); +vi.mock("./settings-environment-filter", () => ({ + useSettingsEnvironmentFilter: () => ({ + selectedTargets: [{ environmentId: "environment", label: "Environment" }], + availableTargets: [ + { environmentId: "environment", label: "Environment" }, + { environmentId: "target", label: "Target" }, + ], + selectedProjectKey: null, + projectGroups: [], + }), +})); +vi.mock("../../lib/modelOptions", () => ({ + buildModelOptions: () => [], + resolveDefaultableModelSelection: () => null, + resolveNewTaskModelSelection: () => null, +})); +vi.mock("../../state/session", () => ({ + readEnvironmentScope: () => true, + usePreparedConnection: () => ({ _tag: "None" }), +})); +vi.mock("../../state/server", () => ({ + serverEnvironment: { + scheduledTasksLive: () => ({}), + upsertScheduledTask: { permissionAtom: () => Atom.make(true) }, + }, +})); +vi.mock("../../state/query", () => ({ + useEnvironmentQuery: () => ({ data: { tasks: mocks.tasks, threads: [] } }), +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.upsert })); + +import { appAtomRegistry } from "../../state/atom-registry"; +import { editDraft } from "./scheduledTaskDraft"; +import { + scheduledTaskEditorSessionAtom, + startScheduledTaskEditor, +} from "./scheduled-task-editor-state"; +import { SettingsScheduledTaskEditRouteScreen } from "./SettingsScheduledTasksRouteScreen"; + +let root: Root; +let container: HTMLDivElement; +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + root = createRoot(container); + mocks.alert.mockReset(); + mocks.upsert.mockReset(); + mocks.upsert.mockResolvedValue({ _tag: "Success" }); + const task: ScheduledTask = { + id: ScheduledTaskId.make("task"), + title: "Task", + prompt: "Prompt", + enabled: true, + schedule: { type: "fixed_time", timeOfDay: "05:00" }, + projectId: ProjectId.make("project"), + threadId: null, + workspaceStrategy: { type: "root" }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "model", + }, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "user", + creationSource: "mobile", + createdAt: "2026-10-22T00:00:00.000Z", + updatedAt: "2026-10-22T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + lastRunStatus: "never", + lastRunError: null, + runCount: 0, + }; + mocks.tasks = [task]; + const draft = editDraft(task); + startScheduledTaskEditor({ + environmentId: EnvironmentId.make("environment"), + environmentLabel: "Environment", + draft: { ...draft, schedule: { ...draft.schedule, intervalMinutes: "60" } }, + }); + act(() => + root.render( + + + , + ), + ); +}); +afterEach(() => { + act(() => root.unmount()); + appAtomRegistry.set(scheduledTaskEditorSessionAtom, null); +}); +function button(label: string) { + const element = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === label, + ); + if (!element) throw new Error(`Missing button: ${label}`); + return element; +} +describe("scheduled task batched edits", () => { + it("submits the selected interval when saved before React commits", async () => { + await act(async () => { + button("Interval").click(); + button("Save changes").click(); + }); + expect(mocks.alert).not.toHaveBeenCalled(); + expect(mocks.upsert).toHaveBeenCalledExactlyOnceWith({ + environmentId: "environment", + input: expect.objectContaining({ + id: "task", + requireExisting: true, + schedule: { type: "interval", everyMs: 3_600_000 }, + }), + }); + }); + it("retains the interval when enabled changes before React commits", async () => { + act(() => { + button("Interval").click(); + button("Task enabled").click(); + }); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current?.draft.schedule.mode).toBe( + "interval", + ); + await act(async () => button("Save changes").click()); + expect(mocks.alert).not.toHaveBeenCalled(); + expect(mocks.upsert).toHaveBeenCalledExactlyOnceWith({ + environmentId: "environment", + input: expect.objectContaining({ + id: "task", + requireExisting: true, + enabled: false, + schedule: { type: "interval", everyMs: 3_600_000 }, + }), + }); + }); + it("keeps every webhook filter added before React commits", () => { + act(() => button("On webhook").click()); + act(() => { + button("Add filter").click(); + button("Add filter").click(); + }); + expect( + appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current?.draft.schedule.filters, + ).toHaveLength(2); + }); + it("keeps the target project when an old picker fires after changing environments", () => { + const editor = appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current; + if (!editor) throw new Error("Missing editor"); + act(() => startScheduledTaskEditor({ ...editor, draft: { ...editor.draft, task: null } })); + act(() => { + button("Select Target").click(); + button("Interval").click(); + button("Select Project").click(); + }); + expect(appAtomRegistry.get(scheduledTaskEditorSessionAtom)?.current).toMatchObject({ + environmentId: "target", + draft: { projectId: "target-project", schedule: { mode: "interval" } }, + }); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index d1939dc5a64ab..25afbed29ffce 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -83,7 +83,11 @@ import { type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; -import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./scheduled-task-editor"; +import { + useScheduledTaskDestinationLabel, + useScheduledTaskEditor, + useScheduledTaskThread, +} from "./scheduled-task-editor"; import { discardScheduledTaskEditor, markScheduledTaskEditorSaved, @@ -267,11 +271,13 @@ function SelectRow(props: { function WebhookFilterRows(props: { readonly filters: ReadonlyArray; readonly disabled: boolean; - readonly onChange: (filters: ReadonlyArray) => void; + readonly onChange: ( + update: (filters: ReadonlyArray) => ReadonlyArray, + ) => void; }) { - const update = (index: number, patch: Partial) => - props.onChange( - props.filters.map((filter, at) => (at === index ? { ...filter, ...patch } : filter)), + const update = (id: WebhookFilterDraft["id"], patch: Partial) => + props.onChange((filters) => + filters.map((filter) => (filter.id === id ? { ...filter, ...patch } : filter)), ); return ( <> @@ -284,7 +290,9 @@ function WebhookFilterRows(props: { accessibilityLabel={`Remove filter ${index + 1}`} disabled={props.disabled} hitSlop={8} - onPress={() => props.onChange(removeWebhookFilterDraft(props.filters, filter.id))} + onPress={() => + props.onChange((filters) => removeWebhookFilterDraft(filters, filter.id)) + } className="active:opacity-70" > @@ -295,7 +303,7 @@ function WebhookFilterRows(props: { value={filter.path} placeholder="body.action" disabled={props.disabled} - onChange={(path) => update(index, { path })} + onChange={(path) => update(filter.id, { path })} /> { const known = WEBHOOK_FILTER_OPS.find((option) => option.op === id); - if (known) update(index, { op: known.op }); + if (known) update(filter.id, { op: known.op }); }} /> {filter.op === "exists" ? null : ( @@ -316,7 +324,7 @@ function WebhookFilterRows(props: { value={filter.value} placeholder={filter.op === "in" ? "opened, reopened" : "opened"} disabled={props.disabled} - onChange={(value) => update(index, { value })} + onChange={(value) => update(filter.id, { value })} /> )} @@ -325,7 +333,13 @@ function WebhookFilterRows(props: { props.onChange([...props.filters, emptyWebhookFilterDraft()])} + onPress={() => + props.onChange((filters) => + filters.length < MAX_WEBHOOK_FILTERS + ? [...filters, emptyWebhookFilterDraft()] + : filters, + ) + } className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" > @@ -590,8 +604,19 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } voiceInput={voiceInput} /> } - setDraft={(draft) => { - if (!saving) setEditor({ ...editor, draft }); + setDraft={(update) => { + if (!saving) + setEditor((current) => + current ? { ...current, draft: update(current.draft) } : current, + ); + }} + setEnvironmentDraft={(update) => { + if (!saving) + setEditor((current) => + current && current.environmentId === editor.environmentId + ? { ...current, draft: update(current.draft) } + : current, + ); }} onSaved={() => { saved.current = true; @@ -599,18 +624,21 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string } if (navigation.isFocused()) navigation.goBack(); }} onChangeEnvironment={(target) => { - if (target.environmentId === editor.environmentId) return; - setEditor({ - environmentId: target.environmentId, - environmentLabel: target.label, - draft: { - ...draftForEnvironment(target.environmentId), - title: editor.draft.title, - prompt: editor.draft.prompt, - schedule: editor.draft.schedule, - enabled: editor.draft.enabled, - }, - }); + setEditor((current) => + !current || target.environmentId === current.environmentId + ? current + : { + environmentId: target.environmentId, + environmentLabel: target.label, + draft: { + ...draftForEnvironment(target.environmentId), + title: current.draft.title, + prompt: current.draft.prompt, + schedule: current.draft.schedule, + enabled: current.draft.enabled, + }, + }, + ); }} /> ) : ( @@ -635,6 +663,7 @@ function TaskForm({ dictationPending, promptField, setDraft, + setEnvironmentDraft, onSaved, onChangeEnvironment, }: { @@ -646,10 +675,12 @@ function TaskForm({ readonly setSaving: (saving: boolean) => void; readonly dictationPending: boolean; readonly promptField: ReactNode; - readonly setDraft: (draft: Draft) => void; + readonly setDraft: (update: (current: Draft) => Draft) => void; + readonly setEnvironmentDraft: (update: (current: Draft) => Draft) => void; readonly onSaved: () => void; readonly onChangeEnvironment: (target: SettingsTarget) => void; }) { + const { readEditor } = useScheduledTaskEditor(); const navigation = useNavigation>(); const tasks = useEnvironmentQuery( serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), @@ -666,6 +697,10 @@ function TaskForm({ .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); const destinationLabel = useScheduledTaskDestinationLabel(environmentId, draft.threadId); + const boundThread = useScheduledTaskThread(environmentId, draft.threadId); + // Bound runs use the thread's current model, so the locked row shows that one. + const shownSelection = + draft.threadId === null ? draft.modelSelection : (boundThread?.modelSelection ?? null); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); // Existing webhook tasks keep the option so they can still switch to a timer. const webhookDenial = managedCapabilityDenial(config?.managedPolicy, "webhookTasks"); @@ -699,6 +734,9 @@ function TaskForm({ draft.task?.schedule.type === "once"; const offersThreadModes = config?.environment.capabilities.scheduledTaskThreadModes === true; const save = async () => { + const current = readEditor(); + if (!current || current.environmentId !== environmentId) return; + const draft = current.draft; if ( !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) || submissionPending.current || @@ -854,7 +892,7 @@ function TaskForm({ disabled={saving} value={draft.title} placeholder="Check for issues" - onChange={(title) => setDraft({ ...draft, title })} + onChange={(title) => setDraft((current) => ({ ...current, title }))} /> {promptField} @@ -874,21 +912,23 @@ function TaskForm({ onSelect={(id) => { const project = projects.find((item) => item.id === id); if (!project) return; - const keepsExplicitModel = - draft.modelSelectionIsExplicit && - (draft.modelSelection === null || - !config?.settings || - resolveProjectProviderInstanceEnabled( - config.settings, - project.id, - draft.modelSelection.instanceId, - )); - setDraft({ - ...changeDraftProject(draft, project.id), - modelSelection: keepsExplicitModel - ? draft.modelSelection - : scheduledTaskDefaultModel(config, project), - modelSelectionIsExplicit: keepsExplicitModel, + setEnvironmentDraft((current) => { + const keepsExplicitModel = + current.modelSelectionIsExplicit && + (current.modelSelection === null || + !config?.settings || + resolveProjectProviderInstanceEnabled( + config.settings, + project.id, + current.modelSelection.instanceId, + )); + return { + ...changeDraftProject(current, project.id), + modelSelection: keepsExplicitModel + ? current.modelSelection + : scheduledTaskDefaultModel(config, project), + modelSelectionIsExplicit: keepsExplicitModel, + }; }); }} /> @@ -899,16 +939,16 @@ function TaskForm({ value={draft.useProjectDefaultModel} disabled={saving || dictationPending || environmentUnavailable} onValueChange={(useProjectDefaultModel) => - setDraft({ - ...draft, + setDraft((current) => ({ + ...current, useProjectDefaultModel, modelSelection: - draft.modelSelection ?? + current.modelSelection ?? scheduledTaskDefaultModel( config, - projects.find((project) => project.id === draft.projectId) ?? null, + projects.find((project) => project.id === current.projectId) ?? null, ), - }) + })) } /> @@ -916,19 +956,25 @@ function TaskForm({ label="Model" borderTop value={ - draft.useProjectDefaultModel + // Bound runs keep the thread's model, also with the project default switched on. + draft.useProjectDefaultModel && draft.threadId === null ? "Project default" : (modelOptions.find( (option) => - option.selection.instanceId === draft.modelSelection?.instanceId && - option.selection.model === draft.modelSelection?.model, + option.selection.instanceId === shownSelection?.instanceId && + option.selection.model === shownSelection?.model, )?.label ?? - draft.modelSelection?.model ?? + shownSelection?.model ?? (modelOptions.length ? "Choose model" : "No models available")) } onPress={() => navigation.navigate("SettingsScheduledTaskModel")} + // The saved selection only seeds new threads; bound runs ignore it. disabled={ - saving || dictationPending || environmentUnavailable || draft.useProjectDefaultModel + saving || + dictationPending || + environmentUnavailable || + draft.useProjectDefaultModel || + draft.threadId !== null } /> @@ -960,9 +1006,9 @@ function TaskForm({ )} onSelect={(id) => { if (id === "new_thread") - setDraft({ ...unbindDraftFromThread(draft), threadMode: id }); + setDraft((current) => ({ ...unbindDraftFromThread(current), threadMode: id })); else if (id === "continue" || id === "fresh_session") - setDraft({ ...draft, threadMode: id }); + setDraft((current) => ({ ...current, threadMode: id })); }} /> ) : null} @@ -1010,7 +1056,7 @@ function TaskForm({ ]} onSelect={(id) => { if (id === "worktree" || id === "root" || id === "existing_worktree") - setDraft({ ...draft, workspace: id }); + setDraft((current) => ({ ...current, workspace: id })); }} /> {draft.workspace === "worktree" ? ( @@ -1033,10 +1079,10 @@ function TaskForm({ value={draft.checkoutPath} borderTop onChange={(checkoutPath) => - setDraft({ - ...draft, - checkoutPath: resolvePastedPath(draft.checkoutPath, checkoutPath), - }) + setEnvironmentDraft((current) => ({ + ...current, + checkoutPath: resolvePastedPath(current.checkoutPath, checkoutPath), + })) } /> ) : null} @@ -1061,21 +1107,21 @@ function TaskForm({ onSelect={(mode) => { setTimePickerOpen(false); setRunAtPicker(null); - setDraft({ - ...draft, + setDraft((current) => ({ + ...current, prompt: - mode === "webhook" && !draft.prompt.trim() + mode === "webhook" && !current.prompt.trim() ? DEFAULT_WEBHOOK_PROMPT - : draft.prompt, + : current.prompt, schedule: { - ...draft.schedule, + ...current.schedule, mode, runAt: - mode === "once" && !draft.schedule.runAt + mode === "once" && !current.schedule.runAt ? new Date(Math.ceil(Date.now() / 3_600_000) * 3_600_000).toISOString() - : draft.schedule.runAt, + : current.schedule.runAt, }, - }); + })); }} /> @@ -1114,7 +1160,10 @@ function TaskForm({ onDismiss={() => setTimePickerOpen(false)} onValueChange={(_, selected) => { const timeOfDay = `${String(selected.getHours()).padStart(2, "0")}:${String(selected.getMinutes()).padStart(2, "0")}`; - setDraft({ ...draft, schedule: { ...draft.schedule, timeOfDay } }); + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, timeOfDay }, + })); }} /> ) : null} @@ -1143,19 +1192,21 @@ function TaskForm({ })), ]} onSelect={(id) => { - const weekdays = - id === "every_day" - ? DAYS.map((day) => day.index) - : id === "weekdays" - ? [1, 2, 3, 4, 5] - : (() => { - const day = DAYS.find((item) => String(item.index) === id); - if (!day) return draft.schedule.weekdays; - return draft.schedule.weekdays.includes(day.index) - ? draft.schedule.weekdays.filter((index) => index !== day.index) - : [...draft.schedule.weekdays, day.index]; - })(); - setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } }); + setDraft((current) => { + const weekdays = + id === "every_day" + ? DAYS.map((day) => day.index) + : id === "weekdays" + ? [1, 2, 3, 4, 5] + : (() => { + const day = DAYS.find((item) => String(item.index) === id); + if (!day) return current.schedule.weekdays; + return current.schedule.weekdays.includes(day.index) + ? current.schedule.weekdays.filter((index) => index !== day.index) + : [...current.schedule.weekdays, day.index]; + })(); + return { ...current, schedule: { ...current.schedule, weekdays } }; + }); }} /> @@ -1200,10 +1251,10 @@ function TaskForm({ onValueChange={(_, selected) => { // Android shows a dialog that closes on pick; iOS stays inline. if (Platform.OS === "android") setRunAtPicker(null); - setDraft({ - ...draft, - schedule: { ...draft.schedule, runAt: selected.toISOString() }, - }); + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, runAt: selected.toISOString() }, + })); }} /> ) : null} @@ -1231,14 +1282,20 @@ function TaskForm({ disabled={saving} borderTop onChange={(maxDeliveryAgeMinutes) => - setDraft({ ...draft, schedule: { ...draft.schedule, maxDeliveryAgeMinutes } }) + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, maxDeliveryAgeMinutes }, + })) } /> - setDraft({ ...draft, schedule: { ...draft.schedule, filters } }) + onChange={(update) => + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, filters: update(current.schedule.filters) }, + })) } /> - setDraft({ ...draft, schedule: { ...draft.schedule, replayKey } }) + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, replayKey }, + })) } /> @@ -1261,7 +1321,10 @@ function TaskForm({ disabled={saving} borderTop onChange={(coalesceKey) => - setDraft({ ...draft, schedule: { ...draft.schedule, coalesceKey } }) + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, coalesceKey }, + })) } /> - setDraft({ ...draft, schedule: { ...draft.schedule, coalesceWindowSeconds } }) + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, coalesceWindowSeconds }, + })) } /> @@ -1289,7 +1355,10 @@ function TaskForm({ disabled={saving} borderTop onChange={(intervalMinutes) => - setDraft({ ...draft, schedule: { ...draft.schedule, intervalMinutes } }) + setDraft((current) => ({ + ...current, + schedule: { ...current.schedule, intervalMinutes }, + })) } /> {!(parseIntervalMinutes(draft.schedule.intervalMinutes) >= 1) ? ( @@ -1312,7 +1381,7 @@ function TaskForm({ setDraft({ ...draft, enabled })} + onValueChange={(enabled) => setDraft((current) => ({ ...current, enabled }))} /> diff --git a/apps/mobile/src/features/settings/scheduled-task-editor.tsx b/apps/mobile/src/features/settings/scheduled-task-editor.tsx index e99600ae533b8..d79ef40896e87 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor.tsx +++ b/apps/mobile/src/features/settings/scheduled-task-editor.tsx @@ -21,22 +21,29 @@ import { export const NEW_THREAD_DESTINATION_LABEL = "New thread each run"; -export function useScheduledTaskDestinationLabel( +export function useScheduledTaskThread( environmentId: EnvironmentId | null, threadId: ThreadId | null, -): string { +) { const ref = environmentId === null || threadId === null ? null : scopeThreadRef(environmentId, threadId); const shell = useThreadShell(ref); - // The shell can omit older settled threads; their family read still carries the title. + // The shell can omit older settled threads; their family read still carries the row. const family = useEnvironmentQuery( ref !== null && shell === null ? threadFamilies.familyAtom(ref.environmentId, ref.threadId) : null, ); - const thread = shell ?? family.data?.threads.find((row) => row.id === threadId); - if (ref === null) return NEW_THREAD_DESTINATION_LABEL; - return thread?.title.trim() || `Thread ${ref.threadId}`; + return shell ?? family.data?.threads.find((row) => row.id === threadId) ?? null; +} + +export function useScheduledTaskDestinationLabel( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): string { + const thread = useScheduledTaskThread(environmentId, threadId); + if (environmentId === null || threadId === null) return NEW_THREAD_DESTINATION_LABEL; + return thread?.title.trim() || `Thread ${threadId}`; } /** The form draft lives in an atom, so it survives while its native picker routes are on top. */ diff --git a/apps/mobile/src/features/settings/scheduledTaskPresentation.test.ts b/apps/mobile/src/features/settings/scheduledTaskPresentation.test.ts new file mode 100644 index 0000000000000..02ca0a2d11c78 --- /dev/null +++ b/apps/mobile/src/features/settings/scheduledTaskPresentation.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatNextScheduledTaskRun } from "./scheduledTaskPresentation"; + +describe("formatNextScheduledTaskRun", () => { + it("names the rounded unit for a run later today", () => { + const now = new Date(2026, 10, 3, 10, 0, 0).getTime(); + const at = (minutes: number) => new Date(now + minutes * 60_000).toISOString(); + expect(formatNextScheduledTaskRun(at(59), now)).toBe("Next run in 59 minutes"); + expect(formatNextScheduledTaskRun(at(59.5), now)).toBe("Next run in 1 hour"); + expect(formatNextScheduledTaskRun(at(150), now)).toBe("Next run in 3 hours"); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskPresentation.ts b/apps/mobile/src/features/settings/scheduledTaskPresentation.ts index 0fbc2626f1fbb..7ebdcfc2d3b3c 100644 --- a/apps/mobile/src/features/settings/scheduledTaskPresentation.ts +++ b/apps/mobile/src/features/settings/scheduledTaskPresentation.ts @@ -38,8 +38,9 @@ export function formatNextScheduledTaskRun(nextRunAt: string, now: number): stri const days = localCalendarDay(next) - localCalendarDay(current); if (days === 0) { if (remaining < MINUTE) return "Next run in less than a minute"; - const unit = remaining < HOUR ? "minute" : "hour"; - const count = Math.round(remaining / (remaining < HOUR ? MINUTE : HOUR)); + const minutes = Math.round(remaining / MINUTE); + const unit = minutes < 60 ? "minute" : "hour"; + const count = minutes < 60 ? minutes : Math.round(remaining / HOUR); return `Next run in ${count} ${unit}${count === 1 ? "" : "s"}`; } diff --git a/apps/mobile/src/features/sharing/incoming-share-model.test.ts b/apps/mobile/src/features/sharing/incoming-share-model.test.ts index 3e798975d743c..1dea547de53b4 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.test.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.test.ts @@ -54,7 +54,7 @@ describe("incoming native shares", () => { text: "Please explain this error\n\nhttps://example.com/issue/1", attachments: [ { - id: "share-1:image:3", + id: "share-1-image-3", type: "image", name: "Screenshot.png", mimeType: "image/png", @@ -131,7 +131,7 @@ describe("incoming native shares", () => { expect(result.attachments).toEqual([ { - id: "share-report:file:0", + id: "share-report-file-0", type: "file", name: "report.pdf", mimeType: "application/pdf", @@ -165,7 +165,7 @@ describe("incoming native shares", () => { expect(result.warnings).toEqual([]); expect(result.attachments).toEqual([ { - id: "share-video:file:0", + id: "share-video-file-0", type: "file", name: video.originalName, mimeType: video.mimeType, @@ -239,7 +239,7 @@ describe("incoming native shares", () => { expect(result.attachments).toEqual([ { - id: "share-android-report:file:0", + id: "share-android-report-file-0", type: "file", name: "report", mimeType: "application/pdf", diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts index aa097713923b6..aa97294e15466 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -332,7 +332,7 @@ export async function buildIncomingShareDraft(input: { } } attachments.push({ - id: `${input.id}:file:${index}`, + id: `${input.id}-file-${index}`, type: "file", name, mimeType, @@ -397,7 +397,7 @@ export async function buildIncomingShareDraft(input: { } const dataUrl = `data:${mimeType};base64,${base64}`; attachments.push({ - id: `${input.id}:image:${index}`, + id: `${input.id}-image-${index}`, type: "image", name: resolved?.originalName ?? fallbackName(uri, index, mimeType), mimeType, diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index f0055d1d598f7..8881545ce6f7f 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -41,7 +41,6 @@ import { LoadingScreen } from "../../components/LoadingScreen"; import { ScreenContent } from "../../components/ui/ScreenContent"; import { Button } from "../../components/ui/Button"; import { IconButton } from "../../components/ui/IconButton"; -import { environmentCatalog } from "../../connection/catalog"; import { useEnvironmentPresentation } from "../../state/presentation"; import { terminalEnvironment } from "../../state/terminal"; import { environmentSession, readEnvironmentScope } from "../../state/session"; @@ -65,6 +64,7 @@ import { import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadProjection } from "../../state/use-thread-detail"; import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; +import { useRetryEnvironment } from "../connection/useRetryEnvironment"; import { TerminalSurface, type TerminalSurfaceHandle } from "./NativeTerminalSurface"; import { getMobileTerminalTheme } from "./terminalTheme"; import { useTerminalExitNavigation } from "./useTerminalExitNavigation"; @@ -384,7 +384,7 @@ function ThreadTerminal(props: { const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); - const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); + const retryEnvironment = useRetryEnvironment(); const selectedThreadDetail = useSelectedThreadProjection(); useForkTerminalThreadTitle(selectedThread); const selectedThreadDetailWorktreePath = diff --git a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx index bf22cd01a1966..b13704196468b 100644 --- a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx +++ b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx @@ -22,10 +22,11 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { webViewDecelerationRate } from "../../lib/webViewDecelerationRate"; const ROW_BOTTOM_MARGIN = 8; +const ACTION_ROW_HEIGHT = 32; const FULL_SCREEN_GUTTER = 16; export function htmlRenderRowHeight(frameHeight: number) { - return frameHeight + ROW_BOTTOM_MARGIN; + return ACTION_ROW_HEIGHT + frameHeight + ROW_BOTTOM_MARGIN; } function useHtmlRenderTheme() { @@ -213,6 +214,34 @@ export function ThreadHtmlRender(props: { return ( + + {uri !== null && !failed ? ( + + navigation.navigate("ThreadAttachment", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + attachmentId, + name: fileName, + mimeType: "text/html", + sizeBytes: "0", + htmlRender: "1", + }) + } + > + + + ) : null} + {uri !== null && !failed ? ( )} - {uri !== null && !failed ? ( - - navigation.navigate("ThreadAttachment", { - environmentId: String(props.environmentId), - threadId: String(props.threadId), - attachmentId, - name: fileName, - mimeType: "text/html", - sizeBytes: "0", - htmlRender: "1", - }) - } - > - - - ) : null} ); diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index b8fa753188608..3cc26c42fd7fc 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -105,10 +105,7 @@ export function QuestionAttachments(props: { props.question.id, ); changeQuestionAttachmentPreparation(key, 1); - void convertPastedImagesToAttachments({ - uris, - existingCount: appAtomRegistry.get(composerDraftsAtom)[key]?.attachments.length ?? 0, - }) + void convertPastedImagesToAttachments({ uris }) .then(async (result) => { if ( scope.active && diff --git a/apps/mobile/src/features/threads/SubagentRow.tsx b/apps/mobile/src/features/threads/SubagentRow.tsx index 7c71ac2680de1..96f6f659aa5a1 100644 --- a/apps/mobile/src/features/threads/SubagentRow.tsx +++ b/apps/mobile/src/features/threads/SubagentRow.tsx @@ -4,7 +4,10 @@ import { resolveProviderInstanceDisplayName, shouldShowInstanceBadge, } from "@t3tools/client-runtime/state/provider-instance-display"; -import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display"; +import { + resolveSubagentMetadata, + subagentCardDetail, +} from "@t3tools/client-runtime/state/subagent-display"; import { formatSubagentEffort } from "@t3tools/client-runtime/state/thread-execution"; import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts"; import type { ReactNode } from "react"; @@ -15,7 +18,6 @@ import { SymbolView } from "../../components/ui/AppSymbol"; import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { cn } from "../../lib/cn"; import { useEnvironmentServerConfig, useProject, useThreadShell } from "../../state/entities"; -import { subagentCardDetail } from "./subagent-card-presentation"; import { SUBAGENT_TONE_TEXT_CLASS, SubagentStatusDot } from "./SubagentStatusDot"; import { resolveSubagentRowPresentation } from "./threadAgentsPresentation"; diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e3f3f3355756c..a4cfdd2f3602c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -17,6 +17,7 @@ import { useAtomValue } from "@effect/atom-react"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { pastedTextDisposition, replaceTextSelection } from "@t3tools/client-runtime/text-paste"; import { + AuthOrchestrationOperateScope, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ChatAttachment, @@ -68,6 +69,7 @@ import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useVisibleThreadShells } from "../../state/entities"; import { useMentionableEnvironments } from "../../state/environments"; import { useLinkedEnvironments } from "../../state/peerLinks"; +import { useEnvironmentScopeDenied } from "../../state/session"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { AppText as Text } from "../../components/ui/AppText"; @@ -480,6 +482,10 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps setResumingQueue(false); }; + const operateDenied = useEnvironmentScopeDenied( + props.environmentId, + AuthOrchestrationOperateScope, + ); const uploadStates = useAtomValue(composerAttachmentUploadsAtom); const attachmentsUploading = props.connectionState === "connected" && @@ -1211,7 +1217,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps - {props.connectionState === "connected" && !props.canOperateThread ? ( + {props.connectionState === "connected" && operateDenied ? ( This connection cannot control this task. You can still edit your draft. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 70afd695d31e3..186e095127de0 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -37,7 +37,11 @@ import { useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; import { NativeLayoutObserver } from "../../native/NativeLayoutObserver"; -import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/reserved-regions"; +import { + deriveBottomControlInsets, + deriveContentSideInsets, + type NativeLayoutMetrics, +} from "../../lib/reserved-regions"; import type { EnvironmentId, MessageId, @@ -1542,7 +1546,7 @@ function ThreadDetailBody( historyControls={props.historyControls} forkMessageLink={props.forkMessageLink} forkThreadSynced={props.threadSyncStatus === "live"} - contentSideInsets={nativeMetrics?.safeArea} + contentSideInsets={deriveContentSideInsets(nativeMetrics)} layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2f08b5cc6d98e..c5042ad15e054 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3885,17 +3885,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (props.contentPresentation.kind === "unavailable" && props.queuedMessages.length === 0) { return ( - - } - topInset={topContentInset} - bottomInset={bottomContentInset} - horizontalPadding={horizontalPadding} - /> + + + } + topInset={topContentInset} + bottomInset={bottomContentInset} + horizontalPadding={horizontalPadding} + /> + ); } diff --git a/apps/mobile/src/features/threads/resume-thread.ts b/apps/mobile/src/features/threads/resume-thread.ts index e112423868254..94bed891d7ae4 100644 --- a/apps/mobile/src/features/threads/resume-thread.ts +++ b/apps/mobile/src/features/threads/resume-thread.ts @@ -69,7 +69,7 @@ export async function resumeThread(input: { message: { messageId: input.messageId, role: "user", - text: "Continue where you left off.", + text: "", attachments: [], }, runtimeMode, diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts index 2b32f84fcb9cc..14a8e6ce99dfe 100644 --- a/apps/mobile/src/features/threads/subagent-card-presentation.test.ts +++ b/apps/mobile/src/features/threads/subagent-card-presentation.test.ts @@ -1,17 +1,8 @@ import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { subagentCardDetail, subagentCardElapsed } from "./subagent-card-presentation"; +import { subagentCardElapsed } from "./subagent-card-presentation"; describe("subagent card", () => { - it.each([ - ["![screenshot](screenshots/shot.png) Verified", "screenshot Verified"], - ["[docs](https://en.wikipedia.org/wiki/T3_(protocol))", "docs"], - ["[report](outputs/report%20(final).md)", "report"], - ["[report](outputs/report\\(final.md)", "report"], - ])("strips image and link markup from %s", (detail, expected) => { - expect(subagentCardDetail(detail)).toBe(expected); - }); - const start = DateTime.makeUnsafe("2026-09-21T12:00:00Z"); const end = DateTime.makeUnsafe("2026-09-21T12:01:00Z"); const later = DateTime.makeUnsafe("2026-09-21T12:02:00Z"); diff --git a/apps/mobile/src/features/threads/subagent-card-presentation.ts b/apps/mobile/src/features/threads/subagent-card-presentation.ts index 42e75c5cf214e..239f48cf441cc 100644 --- a/apps/mobile/src/features/threads/subagent-card-presentation.ts +++ b/apps/mobile/src/features/threads/subagent-card-presentation.ts @@ -3,18 +3,6 @@ import type { OrchestrationV2Subagent } from "@t3tools/contracts"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import * as DateTime from "effect/DateTime"; -export function subagentCardDetail(detail: string | null): string | null { - if (!detail || /^Child task ended with status\b/i.test(detail)) return null; - return ( - detail - .replace(/!?\[([^\]]+)\]\((?:\\.|[^\\()]|\((?:\\.|[^\\()])*\))*\)/g, "$1") - .replace(/`/g, "") - .replace(/^[ \t]*[-*][ \t]+/gm, "") - .replace(/\s+/g, " ") - .trim() || null - ); -} - export function subagentCardElapsed( agents: ReadonlyArray>, nowMs: number, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 25dbe940a0f3e..de8a428a4f225 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -85,6 +85,7 @@ import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, threadHasUnseenCompletion, + resolveThreadListV2RowStatus, resolveThreadListV2Status, resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, @@ -810,7 +811,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // Fork: a terminal thread fills in an idle row from its main terminal. const terminalLabel = terminalThreadStatusLabel(thread.terminal, thread.lastVisitedAt); - const status = terminalThreadRowStatus(resolveThreadListV2Status(thread), terminalLabel); + const rawStatus = resolveThreadListV2Status(thread); + const status = terminalThreadRowStatus(resolveThreadListV2RowStatus(thread), terminalLabel); const isUnread = status === "ready" && (threadHasUnseenCompletion(thread) || terminalLabel === "Completed"); const workingLabel = STATUS_LABEL_BY_STATUS[status]; @@ -1356,7 +1358,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} - {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( + {(rawStatus === "failed" || rawStatus === "limited") && thread.runtime?.lastError ? ( { ); }); + it("settles a failure the user opened to ready in the row, keeping the raw status", () => { + const failed = presentThreadShell( + environmentId, + makeRawThreadShell({ + status: "failed", + latestRunId: RunId.make("run-failed"), + latestRunCompletedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe(NOW), + }), + ); + + expect(resolveThreadListV2Status(failed)).toBe("failed"); + expect(resolveThreadListV2RowStatus(failed)).toBe("ready"); + expect(resolveThreadListV2RowStatus({ ...failed, lastVisitedAt: null })).toBe("failed"); + }); + it("shows a waiting guest message as Request, after the agent's own requests", () => { const thread = presentThreadShell( environmentId, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 5ef1ffe6b46df..3b65259f161c5 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -7,7 +7,10 @@ import { snoozeWakeLabel, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; -import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import { + hasUnseenRunFailure, + type SnoozePreset, +} from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { @@ -300,6 +303,17 @@ export function resolveThreadListV2Status( return "ready"; } +/** Row status: a failure the user has already opened settles to ready, like Done. */ +export function resolveThreadListV2RowStatus( + thread: Parameters[0] & + Pick, +): ThreadListV2Status { + const status = resolveThreadListV2Status(thread); + // A runtime failure without a failed run has nothing to date it, so it stays. + if (status !== "failed" || thread.latestRun?.status !== "failed") return status; + return hasUnseenRunFailure(thread) ? status : "ready"; +} + function parseTimestampMs(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? 0 : parsed; @@ -529,7 +543,7 @@ function resolveThreadListV2ItemTimeLabel( if (showSnoozeWakeLabel) return ""; if ( variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || + (resolveThreadListV2RowStatus(thread) !== "ready" || item.wokeAt !== null || threadHasUnseenCompletion(thread)) ) diff --git a/apps/mobile/src/features/threads/useLinkedPullRequestActions.ts b/apps/mobile/src/features/threads/useLinkedPullRequestActions.ts index 33ac7013f19d2..90bb70dec5e71 100644 --- a/apps/mobile/src/features/threads/useLinkedPullRequestActions.ts +++ b/apps/mobile/src/features/threads/useLinkedPullRequestActions.ts @@ -11,7 +11,10 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { planThreadPullRequestMutation } from "@t3tools/client-runtime/thread-pull-request-compatibility"; +import { + canStartThreadPullRequestWatch, + planThreadPullRequestMutation, +} from "@t3tools/client-runtime/thread-pull-request-compatibility"; import { capitalizeTerm, sourceControlClients, @@ -43,13 +46,7 @@ export function useLinkedPullRequestActions(input: { ); const canEdit = canOperate && capabilities !== undefined; const canWatch = canOperate && capabilities?.threadPullRequestWatch === true; - const canStartWatch = - canWatch && - thread !== null && - thread.archivedAt === null && - thread.settledAt === null && - thread.settledOverride !== "settled" && - thread.lineage.relationshipToParent !== "subagent"; + const canStartWatch = canWatch && thread !== null && canStartThreadPullRequestWatch(thread); const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false }); const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: false }); diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 776aa87e77e35..368ac898d1e90 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -573,15 +573,19 @@ function isOwnedPastedImageUri(uri: string): boolean { } } +/** Leave out existingCount when the draft store caps after its duplicate check. */ export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; - readonly existingCount: number; + readonly existingCount?: number; }): Promise<{ readonly attachments: ReadonlyArray; readonly error: string | null; }> { const { File } = await import("expo-file-system"); - const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + const remainingSlots = + input.existingCount === undefined + ? Number.POSITIVE_INFINITY + : PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; const attachments: DraftComposerImageAttachment[] = []; let error: string | null = null; diff --git a/apps/mobile/src/lib/reserved-regions.test.ts b/apps/mobile/src/lib/reserved-regions.test.ts index c5faebc31653c..60dd501ae37a4 100644 --- a/apps/mobile/src/lib/reserved-regions.test.ts +++ b/apps/mobile/src/lib/reserved-regions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { deriveBottomControlInsets, type NativeLayoutMetrics } from "./reserved-regions"; +import { + deriveBottomControlInsets, + deriveContentSideInsets, + type NativeLayoutMetrics, +} from "./reserved-regions"; import { deriveLayout, deriveWorkspacePaneLayout } from "./layout"; const innerDisplay: NativeLayoutMetrics = { @@ -21,26 +25,27 @@ describe("bottom controls around reserved regions", () => { expect(innerDisplay.width - insets.left - insets.right).toBeGreaterThan(320); }); - it("does not reserve a hinge twice when a native column starts at the division", () => { + it("starts the editor after a fold that covers a native column's leading edge", () => { expect( deriveBottomControlInsets({ ...innerDisplay, - width: 412, + width: 411.33, + height: 669, safeArea: { top: 82, bottom: 34, left: 0, right: 0 }, - reservedRegions: [{ kind: "division", x: 0, y: 0, width: 40, height: 560 }], + reservedRegions: [{ kind: "division", x: 0, y: 0, width: 39.83, height: 669 }], }), - ).toEqual({ left: 0, right: 0, bottom: 0 }); + ).toEqual({ left: 39.83, right: 0, bottom: 0 }); }); - it("keeps a native column's trailing division at its existing edge", () => { + it("does not reserve a hinge twice when the safe area already covers it", () => { expect( deriveBottomControlInsets({ ...innerDisplay, - width: 456, - safeArea: { top: 82, bottom: 34, left: 0, right: 0 }, - reservedRegions: [{ kind: "division", x: 455.75, y: 0, width: 0.25, height: 560 }], + width: 412, + safeArea: { top: 82, bottom: 34, left: 40, right: 0 }, + reservedRegions: [{ kind: "division", x: 0, y: 0, width: 40, height: 560 }], }), - ).toEqual({ left: 0, right: 0, bottom: 0 }); + ).toEqual({ left: 40, right: 0, bottom: 0 }); }); it("keeps controls below a horizontal fold in a hands-free pose", () => { @@ -65,6 +70,59 @@ describe("bottom controls around reserved regions", () => { }); }); +describe("content side insets around reserved regions", () => { + it("keeps transcript text on the wider side of a book fold", () => { + expect( + deriveContentSideInsets({ + ...innerDisplay, + reservedRegions: [{ kind: "division", x: 456, y: 0, width: 48, height: 560 }], + }), + ).toEqual({ left: 8, right: 504 }); + expect( + deriveContentSideInsets({ + ...innerDisplay, + width: 800, + reservedRegions: [{ kind: "division", x: 240, y: 0, width: 40, height: 560 }], + }), + ).toEqual({ left: 280, right: 72 }); + }); + + it("starts transcript text after a fold that covers a native column's leading edge", () => { + expect( + deriveContentSideInsets({ + ...innerDisplay, + width: 411.33, + height: 669, + safeArea: { top: 82, bottom: 34, left: 0, right: 0 }, + reservedRegions: [{ kind: "division", x: 0, y: 0, width: 39.83, height: 669 }], + }), + ).toEqual({ left: 39.83, right: 0 }); + }); + + it("ends transcript text before a fold at a native column's trailing edge", () => { + expect( + deriveContentSideInsets({ + ...innerDisplay, + width: 456, + safeArea: { top: 82, bottom: 34, left: 0, right: 0 }, + reservedRegions: [{ kind: "division", x: 416, y: 0, width: 40, height: 560 }], + }), + ).toEqual({ left: 0, right: 40 }); + }); + + it("ignores a horizontal fold and bottom cameras", () => { + expect( + deriveContentSideInsets({ + ...innerDisplay, + reservedRegions: [ + { kind: "division", x: 0, y: 240, width: 960, height: 40 }, + { kind: "occlusion", x: 8, y: 510, width: 60, height: 50 }, + ], + }), + ).toEqual({ left: 8, right: 72 }); + }); +}); + describe("Duo workspace sizing", () => { it("shows both columns on the unfolded inner display in portrait", () => { // Reported by the iOS 27.1 Duo simulator: no side bar or fold, only the hinge. diff --git a/apps/mobile/src/lib/reserved-regions.ts b/apps/mobile/src/lib/reserved-regions.ts index bb8ff981a6de9..a3d8b6c140657 100644 --- a/apps/mobile/src/lib/reserved-regions.ts +++ b/apps/mobile/src/lib/reserved-regions.ts @@ -23,6 +23,30 @@ export interface NativeLayoutMetrics { readonly bottomBarInset?: number; } +function isFullHeightDivision(region: ReservedRegion, metrics: NativeLayoutMetrics) { + return region.kind === "division" && region.height >= metrics.height / 2; +} + +function sideOfDivision(left: number, right: number, x: number, endX: number) { + if (x <= left) return { left: Math.min(right, endX), right }; + if (endX >= right) return { left, right: Math.max(left, x) }; + return right - endX >= x - left ? { left: endX, right } : { left, right: x }; +} + +export function deriveContentSideInsets(metrics: NativeLayoutMetrics | null) { + if (!metrics) return { left: 0, right: 0 }; + let left = metrics.safeArea.left; + let right = metrics.width - metrics.safeArea.right; + for (const region of metrics.reservedRegions) { + if (!isFullHeightDivision(region, metrics)) continue; + const x = Math.max(left, region.x); + const endX = Math.min(right, region.x + region.width); + if (endX <= x) continue; + ({ left, right } = sideOfDivision(left, right, x, endX)); + } + return { left, right: metrics.width - right }; +} + export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) { if (!metrics) return { left: 0, right: 0, bottom: 0 }; let left = metrics.safeArea.left; @@ -34,12 +58,8 @@ export function deriveBottomControlInsets(metrics: NativeLayoutMetrics | null) { const y = Math.max(0, region.y); const endY = Math.min(bottom, region.y + region.height); if (endX <= x || endY <= y) continue; - if (region.kind === "division" && region.height >= metrics.height / 2) { - // A native column ending at a division is already split there. Only an - // interior division needs to move these controls into one side of the view. - if (x <= left || endX >= right) continue; - if (right - endX >= x - left) left = endX; - else right = x; + if (isFullHeightDivision(region, metrics)) { + ({ left, right } = sideOfDivision(left, right, x, endX)); } else if (endY >= bottom - metrics.safeArea.bottom) { if (region.width >= right - left) bottom = y; else if (right - endX >= x - left) left = endX; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 02522d3ff81e5..ed85709f30059 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -761,7 +761,9 @@ function toWorkLogEntry( case "file_change": return { ...common, - changedFiles: item.changes?.map((change) => change.path) ?? [item.fileName], + changedFiles: item.changes?.length + ? item.changes.map((change) => change.path) + : [item.fileName], toolTitle: title ?? "File change", toolData: item, }; diff --git a/apps/mobile/src/lib/time.test.ts b/apps/mobile/src/lib/time.test.ts index 8881b6bbab8b7..c9c24b9c8f97f 100644 --- a/apps/mobile/src/lib/time.test.ts +++ b/apps/mobile/src/lib/time.test.ts @@ -1,9 +1,39 @@ import { describe, expect, it } from "vite-plus/test"; -import { msUntilRelativeTimeChange, relativeTime } from "./time"; +import { byTimestamp, compareTimestamps, msUntilRelativeTimeChange, relativeTime } from "./time"; const at = "2026-10-19T12:00:00.000Z"; const timestamp = Date.parse(at); +describe("timestamp order", () => { + it("orders host timestamps by instant across offsets and fractional seconds, missing ones first", () => { + const dates = [ + "2026-07-05T00:30:00.5Z", + "2026-07-05T01:00:00+02:00", + "", + "2026-07-05T00:30:00Z", + ]; + + expect(dates.toSorted(compareTimestamps)).toEqual([ + "", + "2026-07-05T01:00:00+02:00", + "2026-07-05T00:30:00Z", + "2026-07-05T00:30:00.5Z", + ]); + }); + it("keeps equal instants in input order whichever way it sorts", () => { + const rows = [ + { id: "comment", date: "2026-07-05T00:30:00Z" }, + { id: "commit", date: "2026-07-04T20:30:00-04:00" }, + { id: "older", date: "2026-07-04T00:00:00Z" }, + ]; + const ids = (newestFirst: boolean) => + rows.toSorted(byTimestamp(newestFirst)).map((row) => row.id); + + expect(ids(false)).toEqual(["older", "comment", "commit"]); + expect(ids(true)).toEqual(["comment", "commit", "older"]); + }); +}); + describe("relative time", () => { it.each([ [59_999, "<1m", "1m"], diff --git a/apps/mobile/src/lib/time.ts b/apps/mobile/src/lib/time.ts index 8635ccb37138b..5a32e16fc75c9 100644 --- a/apps/mobile/src/lib/time.ts +++ b/apps/mobile/src/lib/time.ts @@ -1,3 +1,15 @@ +// Host timestamps may carry UTC offsets or mixed fractions, so text order is not time order. +export function compareTimestamps(left: string, right: string): number { + return (Date.parse(left) || 0) - (Date.parse(right) || 0); +} + +export function byTimestamp(newestFirst: boolean) { + return (left: { readonly date: string }, right: { readonly date: string }) => + newestFirst + ? compareTimestamps(right.date, left.date) + : compareTimestamps(left.date, right.date); +} + export function relativeTime(input: string, nowMs: number = Date.now()): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) { diff --git a/apps/mobile/src/state/queries.debounce.test.tsx b/apps/mobile/src/state/queries.debounce.test.tsx index e2eccb4d4125c..8e67b617b6ee2 100644 --- a/apps/mobile/src/state/queries.debounce.test.tsx +++ b/apps/mobile/src/state/queries.debounce.test.tsx @@ -8,7 +8,8 @@ import { expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => ({ sessionAtom: {}, - searchEntries: vi.fn(() => null), + searchEntriesAtom: {}, + searchEntries: vi.fn((): unknown => mocks.searchEntriesAtom), listRefs: vi.fn(), refresh: vi.fn(), })); @@ -35,7 +36,9 @@ vi.mock("./query", () => ({ data: atom === mocks.sessionAtom ? { authenticated: true, scopes: [AuthFilesystemReadScope] } - : null, + : atom === mocks.searchEntriesAtom + ? { entries: [{ path: "src/only-in-original.ts", kind: "file" }] } + : null, error: null, isPending: false, refresh: () => {}, @@ -53,12 +56,16 @@ function setup() { vi.useFakeTimers(); mocks.searchEntries.mockClear(); const root = createRoot(document.createElement("div")); + let latest: ReturnType | null = null; function Probe({ target }: { readonly target: ComposerPathSearchTarget }) { - useComposerPathSearch(target); + latest = useComposerPathSearch(target); return null; } return { + get result() { + return latest; + }, render(target: ComposerPathSearchTarget) { act(() => root.render()); }, @@ -128,6 +135,21 @@ it.each([ }); }); +it.each([ + { field: "environment", changed: { ...target, environmentId: EnvironmentId.make("other") } }, + { field: "project directory", changed: { ...target, cwd: "/other" } }, +])("reports settled results as pending when the $field changes", ({ changed }) => { + using harness = setup(); + harness.render(target); + act(() => vi.advanceTimersByTime(200)); + expect(harness.result?.isPending).toBe(false); + expect(harness.result?.entries).toHaveLength(1); + + harness.render(changed); + expect(harness.result?.isPending).toBe(true); + expect(harness.result?.entries).toEqual([]); +}); + it("keeps compiled branch results stable and resets pagination for a new target or refresh", () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const registry = AtomRegistry.make(); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 38890d7d57506..a84059d2e3606 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -440,9 +440,13 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null, ); const hasTarget = searchTarget !== null; + const isSameCheckout = + normalizedTarget.environmentId === debouncedTarget.environmentId && + normalizedTarget.cwd === debouncedTarget.cwd; return { - entries: result.data?.entries ?? [], + // Entries from another checkout must not be offered while the new one is debounced. + entries: isSameCheckout ? (result.data?.entries ?? []) : [], error: !hasTarget || fileAccess.isPending ? null @@ -450,6 +454,7 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { ? result.error : (fileAccess.error ?? "This connection cannot search host files."), isPending: + !isSameCheckout || normalizedTarget.query !== debouncedTarget.query || (hasTarget && (fileAccess.isPending || result.isPending)), refresh: result.refresh, diff --git a/apps/mobile/src/state/recover-failed-thread-draft.ts b/apps/mobile/src/state/recover-failed-thread-draft.ts index 5512dccbeb266..25573ee3363c7 100644 --- a/apps/mobile/src/state/recover-failed-thread-draft.ts +++ b/apps/mobile/src/state/recover-failed-thread-draft.ts @@ -26,7 +26,7 @@ export async function recoverFailedThreadDraft(message: QueuedThreadMessage): Pr appendComposerDraftAttachments( targetKey, source.attachments.filter((attachment) => !existingIds.has(attachment.id)), - { allowOverflow: true }, + { restore: true }, ); // Recovery may exceed caps; preserve every file and leave trimming to the editor. await flushComposerDrafts(); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 5d6a441e9b9e4..74876954b2716 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -1,5 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; -import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import { + createEnvironmentSessionAtoms, + sessionDeniesScope, +} from "@t3tools/client-runtime/state/session"; import { type AuthEnvironmentScope, type AuthSessionState, @@ -25,16 +28,30 @@ export function sessionHasScope( return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } +function sessionStateAtomFor(environmentId: EnvironmentId | null) { + return environmentId === null + ? EMPTY_SESSION_STATE_ATOM + : environmentSession.sessionStateAtom(environmentId); +} + +// Both hooks select a boolean: every revalidation emits a new result, and most consumers are +// list rows. export function useEnvironmentScope( environmentId: EnvironmentId | null, scope: AuthEnvironmentScope, ): boolean { - // Select the grant: every revalidation emits a new result, and most consumers are list rows. - return useAtomValue( - environmentId === null - ? EMPTY_SESSION_STATE_ATOM - : environmentSession.sessionStateAtom(environmentId), - (result) => sessionHasScope(result, scope), + return useAtomValue(sessionStateAtomFor(environmentId), (result) => + sessionHasScope(result, scope), + ); +} + +/** Unlike `!useEnvironmentScope`, stays false until the host has answered without the scope. */ +export function useEnvironmentScopeDenied( + environmentId: EnvironmentId | null, + scope: AuthEnvironmentScope, +): boolean { + return useAtomValue(sessionStateAtomFor(environmentId), (result) => + sessionDeniesScope(result, scope), ); } diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index a44f410b54ca3..a57d04fb24918 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -36,7 +36,7 @@ export interface ThreadPrPresentation { const PR_STATE_TEXT_CLASS: Record = { open: "text-adaptive-emerald-600-400", merged: "text-adaptive-violet-600-400", - closed: "text-foreground-muted", + closed: "text-adaptive-rose-600-400", }; export function presentThreadPr( @@ -101,12 +101,7 @@ export function presentThreadLinkedPullRequests( : linkedCount !== null ? `${linkedCount} linked ${changeRequest.plural}, overall ${badge.state}` : `#${link.number} ${changeRequest.singular} ${state === null ? "status pending" : isDraft ? "draft" : state}`, - textClassName: - state === null || isDraft - ? "text-foreground-muted" - : isMultiple && state === "closed" - ? "text-adaptive-rose-600-400" - : PR_STATE_TEXT_CLASS[state], + textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state], }; } diff --git a/apps/mobile/src/state/threadOutboxDelivery.ts b/apps/mobile/src/state/threadOutboxDelivery.ts index ed9c33719a4d8..b3feeae3bb801 100644 --- a/apps/mobile/src/state/threadOutboxDelivery.ts +++ b/apps/mobile/src/state/threadOutboxDelivery.ts @@ -372,7 +372,7 @@ export async function recoverEditedCreationAfterDelivery( appendComposerDraftAttachments( draftKey, kept.attachments.filter((attachment) => !existingAttachmentIds.has(attachment.id)), - { allowOverflow: true }, + { restore: true }, ); updateComposerDraftSettings(draftKey, { ...(kept.modelSelection !== undefined ? { modelSelection: kept.modelSelection } : {}), diff --git a/apps/mobile/src/state/use-composer-attachment-actions.ts b/apps/mobile/src/state/use-composer-attachment-actions.ts index eeb8742010858..2352f3f7848eb 100644 --- a/apps/mobile/src/state/use-composer-attachment-actions.ts +++ b/apps/mobile/src/state/use-composer-attachment-actions.ts @@ -101,10 +101,7 @@ export function useComposerAttachmentActions(props: { const insertion = captureComposerDraftInsertion(key); await withDraftOperation(key, async (signal) => { try { - const result = await convertPastedImagesToAttachments({ - uris, - existingCount: countComposerDraftAttachmentsAfterSelection(key, insertion), - }); + const result = await convertPastedImagesToAttachments({ uris }); report( "Could not paste images", result.error, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index f25037f40f0b4..925fc4eb3f4fb 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -28,6 +28,9 @@ import { } from "@t3tools/contracts"; import { onTestFinished, vi } from "vite-plus/test"; +import { buildIncomingShareDraft } from "../features/sharing/incoming-share-model"; +import { composerContextSendBlockReason } from "../lib/composerContext"; + const composerDraftFileMocks = vi.hoisted(() => { let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); let readError: Error | null = null; @@ -458,7 +461,7 @@ describe("mobile composer drafts", () => { const restored = history.restore(text, getComposerDraftSnapshot(key)); setComposerDraftText(key, text); setComposerDraftContext(key, restored.context); - appendComposerDraftAttachments(key, restored.attachments, { allowOverflow: true }); + appendComposerDraftAttachments(key, restored.attachments, { restore: true }); }; try { changeText(""); @@ -1054,12 +1057,45 @@ describe("mobile composer drafts", () => { const overflowRejected = appendComposerDraftAttachments( draftKey, [makeAttachment("restored-1")], - { allowOverflow: true }, + { restore: true }, ); expect(overflowRejected).toBe(0); expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(101); }); + it("drops duplicate attachments, releases their files, and keeps them on restore", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const cleanup = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + cleanup.resolve(); + }); + const makeVideo = (id: string) => ({ + id, + type: "file" as const, + name: "clip.mov", + mimeType: "video/quicktime", + sizeBytes: 42, + fileUri: `file:///documents/t3-composer-attachments/${id}.mov`, + }); + const draftKey = "new-task:environment-1:project-duplicates"; + appAtomRegistry.set(composerDraftsAtom, { + [draftKey]: { text: "", attachments: [makeVideo("held")] }, + }); + + expect( + appendComposerDraftAttachments(draftKey, [makeVideo("again"), makeVideo("again-2")]), + ).toBe(0); + expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toEqual([ + makeVideo("held"), + ]); + await cleanup.promise; + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(makeVideo("again").fileUri); + + appendComposerDraftAttachments(draftKey, [makeVideo("restored")], { restore: true }); + expect(appAtomRegistry.get(composerDraftsAtom)[draftKey]?.attachments).toHaveLength(2); + }); + it("keeps shared attachment files until every draft releases them", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); @@ -2203,7 +2239,7 @@ describe("mobile composer drafts", () => { it("merges shared content into a project draft without duplicating retries", () => { const draftKey = "new-task:environment-1:project-1"; const sharedAttachment = { - id: "share-1:image:0", + id: "share-1-image-0", type: "image" as const, name: "Screenshot.png", mimeType: "image/png", @@ -2238,7 +2274,7 @@ describe("mobile composer drafts", () => { it("shows a shared non-image file as a composer chip", () => { const draftKey = "new-task:environment-1:project-1"; const sharedFile = { - id: "share-1:file:0", + id: "share-1-file-0", type: "file" as const, name: "backup.json", mimeType: "application/json", @@ -2273,6 +2309,37 @@ describe("mobile composer drafts", () => { expect(mergeComposerDraftContentState(once, draftKey, recovered)).toBe(once); }); + it("lets a shared file through the send check", async () => { + const { attachments } = await buildIncomingShareDraft({ + id: "share-1", + createdAt: "2026-07-15T10:00:00.000Z", + payloads: [ + { + shareType: "file", + value: "file:///shared/report.xlsx", + mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + originalName: "report.xlsx", + }, + ], + resolvedPayloads: [], + fileReader: { + readBase64: async () => "", + readSize: async () => 42, + removeOwnedFile: async () => {}, + }, + }); + const draftKey = "new-task:environment-1:project-1"; + + const merged = mergeComposerDraftContentState({}, draftKey, { + text: "", + attachments, + sourceShareId: "share-1", + })[draftKey]!; + + expect(merged.context?.records).toHaveLength(1); + expect(composerContextSendBlockReason(merged.context)).toBeNull(); + }); + it("preserves existing images when shared content exceeds the draft attachment limit", () => { const draftKey = "new-task:environment-1:project-1"; const image = (id: string) => ({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7fb44be817f32..6fd7c1d10f5b1 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1373,28 +1373,46 @@ export function appendComposerDraftText(draftKey: string, value: string): void { }); } -/** Restore with allowOverflow to preserve every attachment after failed sends. */ +function attachmentDedupKey(attachment: DraftComposerAttachment): string { + return `${attachment.mimeType}\u0000${attachment.sizeBytes}\u0000${attachment.name}`; +} + +/** + * Restore puts back attachments after failed sends, so it skips the duplicate check and the caps. + * Returns how many attachments the caps rejected. + */ export function appendComposerDraftAttachments( draftKey: string, - attachments: ReadonlyArray, + incoming: ReadonlyArray, options?: { - readonly allowOverflow?: boolean; + readonly restore?: boolean; readonly appendReference?: boolean; readonly insertion?: ComposerDraftInsertion; readonly maxAttachments?: number; }, ): number { - if (attachments.length === 0) { + if (incoming.length === 0) { return 0; } let rejected: ReadonlyArray = []; let removed: ReadonlyArray = []; + let duplicates: ReadonlyArray = []; updateComposerDrafts((current) => { const existing = normalizeDraft(current[draftKey]); const retained = options?.appendReference ? draftWithoutInsertionSelection(draftKey, existing, options.insertion) : existing; - const remaining = options?.allowOverflow + const keys = new Set(retained.attachments.map(attachmentDedupKey)); + const attachments = options?.restore + ? incoming + : incoming.filter((attachment) => { + const key = attachmentDedupKey(attachment); + if (keys.has(key)) return false; + keys.add(key); + return true; + }); + duplicates = incoming.filter((attachment) => !attachments.includes(attachment)); + const remaining = options?.restore ? attachments.length : Math.max( 0, @@ -1437,7 +1455,7 @@ export function appendComposerDraftAttachments( [draftKey]: draft, }; }); - scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed]); + scheduleUnusedComposerAttachmentCleanup([...rejected, ...removed, ...duplicates]); return rejected.length; } diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index a1488220d6952..0b4163aafbd95 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -9,6 +9,7 @@ import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { hasCloudPublicConfig } from "../features/cloud/publicConfig"; import { useConnectionController } from "../features/connection/useConnectionController"; +import { useRetryEnvironment } from "../features/connection/useRetryEnvironment"; import { environmentPresentations } from "./presentation"; import { useWorkspaceConnectionState, useWorkspaceEnvironments } from "./workspace"; import type { SavedRemoteConnection } from "../lib/connection"; @@ -166,8 +167,7 @@ export function useRemoteConnections() { const onConnectPress = (pairingUrl?: string, expectedEnvironmentId?: EnvironmentId) => pairEnvironment(pairingUrl ?? connectionPairingUrl, expectedEnvironmentId); - const onReconnectEnvironment = (environmentId: EnvironmentId) => - controller.retryEnvironment(environmentId); + const onReconnectEnvironment = useRetryEnvironment(); const onSetEnvironmentEnabled = (environmentId: EnvironmentId, enabled: boolean) => controller.setEnvironmentEnabled(environmentId, enabled); const onUpdateEnvironment = ( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index e6b8cf9fc3bd4..736eb0bc10d58 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -833,7 +833,7 @@ export function useThreadComposerState(input: { context: draft.context, attachments: [], }); - appendComposerDraftAttachments(threadKey, attachments, { allowOverflow: true }); + appendComposerDraftAttachments(threadKey, attachments, { restore: true }); if (freshContext) setFreshSessionPending(threadKey, true); setThreadComposerError( threadKey, diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 2b688fee30ad2..05a2254fc6b27 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -26,6 +26,7 @@ const reactTests = [ "src/features/files/ZipArchiveView.test.tsx", "src/features/pull-requests/guide/useReviewGuide.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", + "src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", "src/features/settings/components/SettingsDragHandle.test.tsx", "src/features/threads/ThreadMarkdownImage.test.tsx", @@ -45,7 +46,9 @@ const reactTests = [ "src/features/home/home-list-options.test.tsx", "src/features/home/home-route-visibility.test.tsx", "src/features/layout/workspace/useSearchScrollRestoration.test.tsx", + "src/features/connection/useRetryEnvironment.test.tsx", "src/features/connection/ConnectionsNewRouteScreen.test.tsx", + "src/features/connection/LinkedEnvironmentsSection.test.tsx", "src/features/devices/device-stream.browser.test.ts", "src/features/keyboard/keyboardControllerState.test.tsx", ]; diff --git a/apps/server/scripts/dev-replay-agent.ts b/apps/server/scripts/dev-replay-agent.ts index b6eea893ad6b7..2aa50e353f57a 100644 --- a/apps/server/scripts/dev-replay-agent.ts +++ b/apps/server/scripts/dev-replay-agent.ts @@ -64,6 +64,11 @@ export const SCENARIOS = [ name: "Multi turn", file: `${FIXTURES}/multi_turn/registry_transcript.ndjson`, }, + { + id: "command-output", + name: "Command output", + file: "./dev-replay/command_output_transcript.ndjson", + }, ] as const; // Session setup and vendor state stay out: they would replace the replay agent's own models. diff --git a/apps/server/scripts/dev-replay/command_output_transcript.ndjson b/apps/server/scripts/dev-replay/command_output_transcript.ndjson new file mode 100644 index 0000000000000..0bda8c67adbf5 --- /dev/null +++ b/apps/server/scripts/dev-replay/command_output_transcript.ndjson @@ -0,0 +1,6 @@ +{"type":"transcript_start","provider":"acpRegistry","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"dev_replay_command_output","metadata":{"generatedBy":"hand-written for the dev replay agent"}} +{"type":"expect_outbound","frame":{"kind":"request","method":"session/prompt","params":""}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-output-json","kind":"execute","title":"node -e 'process.stdout.write(JSON.stringify({stdout:\"line 1\\n行 2 🚀\",stderr:\"é warning\",interrupted:false,requestId:\"keep-me\"})+\"\\n\")'","status":"in_progress","rawInput":{"command":"node -e 'process.stdout.write(JSON.stringify({stdout:\"line 1\\n行 2 🚀\",stderr:\"é warning\",interrupted:false,requestId:\"keep-me\"})+\"\\n\")'"}}}}} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-output-json","status":"completed","rawOutput":"{\"stdout\":\"line 1\\n行 2 🚀\",\"stderr\":\"é warning\",\"interrupted\":false,\"requestId\":\"keep-me\"}\n"}}},"afterMs":300} +{"type":"emit_inbound","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"dev-replay-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The command printed one JSON line."}}}}} +{"type":"emit_inbound","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"end_turn"}}} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 582e828842ede..8f050c3748c56 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -525,50 +525,53 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("resolves home-relative media paths independently of the workspace", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-home-" }); - const home = path.join(directory, "var", "home", "alice"); - const filePath = path.join(home, "Downloads", "repro.mp4"); - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, "recording bytes"); - const canonicalFile = yield* fs.realPath(filePath); - yield* Effect.gen(function* () { - for (const workspaceRoot of [ - path.join(home, "project"), - path.join(directory, "srv", "project"), - undefined, - ]) { - if (workspaceRoot) yield* fs.makeDirectory(workspaceRoot, { recursive: true }); - for (const requestedPath of ["~/Downloads/repro.mp4", "~\\Downloads/repro.mp4"]) { - const result = yield* issueAssetUrl({ - resource: { - _tag: "media-file", - threadId: ThreadId.make("thread-1"), - path: requestedPath, - }, - ...(workspaceRoot ? { workspaceRoot } : {}), - }); - const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); - const separator = suffix.indexOf("/"); - const asset = yield* resolveAsset( - suffix.slice(0, separator), - suffix.slice(separator + 1), - ); - expect(asset).toMatchObject({ - kind: "file", - path: canonicalFile, - mimeType: "video/mp4", - }); - if (asset?.kind !== "file") throw new Error("Expected a resolved home media file"); - const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset)); - expect(yield* Effect.promise(() => response.text())).toBe("recording bytes"); + it.effect.each(["media-file", "draft-workspace-file"] as const)( + "resolves home-relative %s paths independently of the workspace", + (tag) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-home-" }); + const home = path.join(directory, "var", "home", "alice"); + const filePath = path.join(home, "Downloads", "repro.mp4"); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, "recording bytes"); + const canonicalFile = yield* fs.realPath(filePath); + yield* Effect.gen(function* () { + for (const workspaceRoot of [ + path.join(home, "project"), + path.join(directory, "srv", "project"), + undefined, + ]) { + if (workspaceRoot) yield* fs.makeDirectory(workspaceRoot, { recursive: true }); + for (const requestedPath of ["~/Downloads/repro.mp4", "~\\Downloads/repro.mp4"]) { + const result = yield* issueAssetUrl({ + resource: { + ...(tag === "media-file" + ? { _tag: tag, threadId: ThreadId.make("thread-1") } + : { _tag: tag, cwd: workspaceRoot ?? directory }), + path: requestedPath, + }, + ...(workspaceRoot ? { workspaceRoot } : {}), + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const asset = yield* resolveAsset( + suffix.slice(0, separator), + suffix.slice(separator + 1), + ); + expect(asset).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType: "video/mp4", + }); + if (asset?.kind !== "file") throw new Error("Expected a resolved home media file"); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset)); + expect(yield* Effect.promise(() => response.text())).toBe("recording bytes"); + } } - } - }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + }).pipe(Effect.provide(layerTest)), ); it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c841f8eecff2e..b1c1b28b626cd 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -607,9 +607,10 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } case "draft-workspace-file": { const draftWorkspaceRoot = input.workspaceRoot ?? input.resource.cwd; - const hostPath = input.resource.path.startsWith("~/") - ? expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory) - : yield* toWslDrivePath(input.resource.path); + const hostPath = + input.resource.path.startsWith("~/") || input.resource.path.startsWith("~\\") + ? expandHomePath(input.resource.path, yield* HostProcess.HomeDirectory) + : yield* toWslDrivePath(input.resource.path); if (path.isAbsolute(hostPath)) { const finalized = yield* finalizeAbsoluteMediaFileAsset({ requestedPath: hostPath, diff --git a/apps/server/src/claudeModelOptions.test.ts b/apps/server/src/claudeModelOptions.test.ts index b69b20db7ec4d..a7513a5931831 100644 --- a/apps/server/src/claudeModelOptions.test.ts +++ b/apps/server/src/claudeModelOptions.test.ts @@ -105,14 +105,33 @@ describe("compileClaudeModelSelection", () => { ).toMatchObject({ effort: "high", promptEffort: "ultrathink" }); }); - it("keeps explicit effort for models missing from the local catalog", () => { + it("ignores thinking for catalog models without a thinking option", () => { expect( - compileClaudeModelSelection( - selection("claude-pool-model-not-in-catalog", [{ id: "effort", value: "max" }]), - ).effort, - ).toBe("max"); - expect( - compileClaudeModelSelection(selection("claude-pool-model-not-in-catalog", [])).effort, - ).toBeUndefined(); + compileClaudeModelSelection(selection("claude-opus-5-5", [{ id: "thinking", value: false }])) + .settings, + ).not.toHaveProperty("alwaysThinkingEnabled"); }); + + it.each(["gateway-route", "gateway-route[1m]"])( + "keeps explicit effort and thinking for %s outside the local catalog", + (model) => { + for (const thinking of [true, false]) { + expect( + compileClaudeModelSelection( + selection(model, [ + { id: "effort", value: "max" }, + { id: "thinking", value: thinking }, + ]), + ), + ).toMatchObject({ + apiModelId: model, + effort: "max", + settings: { alwaysThinkingEnabled: thinking }, + }); + } + const unconfigured = compileClaudeModelSelection(selection(model, [])); + expect(unconfigured.effort).toBeUndefined(); + expect(unconfigured.settings).toEqual({}); + }, + ); }); diff --git a/apps/server/src/claudeModelOptions.ts b/apps/server/src/claudeModelOptions.ts index 12530db50ec5e..c3376ef7ed8dc 100644 --- a/apps/server/src/claudeModelOptions.ts +++ b/apps/server/src/claudeModelOptions.ts @@ -12,6 +12,7 @@ import { normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, resolveClaudeCatalogEffort, + resolveClaudeCatalogModel, resolveClaudeModelSlug, type ClaudeModelCatalog, } from "./provider/ClaudeModelCatalog.ts"; @@ -45,9 +46,11 @@ export function compileClaudeModelSelection( const fastMode = supportsBoolean("fastMode") ? (getModelSelectionBooleanOptionValue(selection, "fastMode") ?? false) : undefined; - const thinking = supportsBoolean("thinking") - ? getModelSelectionBooleanOptionValue(selection, "thinking") - : undefined; + const inCatalog = resolveClaudeCatalogModel(catalog, selection.model) !== undefined; + const thinking = + !inCatalog || supportsBoolean("thinking") + ? getModelSelectionBooleanOptionValue(selection, "thinking") + : undefined; const ultracode = descriptors.find((descriptor) => descriptor.id === "ultracode")?.currentValue; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), diff --git a/apps/server/src/cli/sshHelper.ts b/apps/server/src/cli/sshHelper.ts index 09a729ef44891..e98e5e0537af1 100644 --- a/apps/server/src/cli/sshHelper.ts +++ b/apps/server/src/cli/sshHelper.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import { Argument, Command } from "effect/cli"; import * as DesktopUpdateMarker from "../desktopUpdate/DesktopUpdateMarker.ts"; +import { serviceLauncherIsServing, stateDirOwned } from "../serviceLauncher.ts"; const canBind = (port: number) => new Promise((resolve) => { @@ -142,7 +143,20 @@ const updateRemaining = Command.make("update-remaining", { Command.provide(DesktopUpdateMarker.layer), ); +/** Exits 0 while a server holds this home or a boot service is about to start one. */ +const homeOwned = Command.make("home-owned", { + baseDir: Argument.String("base-dir"), +}).pipe( + Command.withHandler(({ baseDir }) => + Effect.promise(async () => { + if (!((await serviceLauncherIsServing(baseDir)) || (await stateDirOwned(baseDir)))) { + process.exitCode = 1; + } + }), + ), +); + export const sshHelperCommand = Command.make("__ssh-helper").pipe( Command.unlisted, - Command.withSubcommands([pickPort, waitReady, runtimePort, updateRemaining]), + Command.withSubcommands([pickPort, waitReady, runtimePort, updateRemaining, homeOwned]), ); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 875681cb988e9..ad8613d8f0ebc 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -233,9 +233,20 @@ it.layer(NodeServices.layer)("server self update", (it) => { // regression: #34502 a remote update never replaces the launcher, so it can be older than the server it.effect("names its launcher's protocol to the staged preflight, not its own", () => Effect.gen(function* () { - const { selfUpdate, preflightProtocols } = yield* makeHarness({ launcherProtocol: 3 }); + const { selfUpdate, preflightProtocols, baseDir } = yield* makeHarness({ + launcherProtocol: 3, + }); + yield* TestClock.setTime(5_000); expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id"); expect(preflightProtocols).toEqual(["3"]); + // That launcher holds no lock between its servers; SSH launches wait on this marker instead. + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect( + JSON.parse( + yield* fs.readFileString(path.join(baseDir, "runtime", "desktop-update-in-progress")), + ), + ).toEqual({ startedAt: 5_000, expiresAt: 65_000 }); }), ); @@ -372,6 +383,27 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); + it.effect("stages the runtime without asking the launcher to restart", () => + Effect.gen(function* () { + const { selfUpdate, order } = yield* makeHarness(); + expect(yield* selfUpdate.update({ targetVersion: "1.1.0", stageOnly: true })).toEqual({ + targetVersion: "1.1.0", + method: "boot-service", + }); + expect(order).toEqual(["index", "download", "extract", "preflight"]); + expect((yield* selfUpdate.update({ targetVersion: "1.1.0" })).updateId).toBe("launcher-id"); + expect(order).toEqual([ + "index", + "download", + "extract", + "preflight", + "index", + "preflight", + "accept", + ]); + }), + ); + // regression: #28198 a slow staged preflight on a loaded host no longer fails the update it.effect("waits for a staged preflight that takes a minute on a loaded host", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 4cebe5a6ce1cf..4112ade25e47c 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -1,4 +1,5 @@ import { + DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE, ServerSelfUpdateError, type ServerSelfUpdateCapability, type ServerSelfUpdateInput, @@ -29,6 +30,7 @@ import { import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; +import * as DesktopUpdateMarker from "../desktopUpdate/DesktopUpdateMarker.ts"; import { forkUpdateTarget } from "../fork/forkReleases.ts"; import { databaseMigrationHead } from "../persistence/databaseMigrationHead.ts"; import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; @@ -343,6 +345,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { }), ); + if (input.stageOnly === true) { + yield* Ref.set(inFlight, false); + return { targetVersion, method: "boot-service" as const }; + } yield* reportProgress("installing"); const updateId = yield* Effect.uninterruptible( launcher.requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }).pipe( @@ -354,6 +360,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { error, ), ), + // The launcher stops this server next; one from before its serving lock holds nothing + // until the new server starts, so this marker is what an SSH launch waits on. + Effect.tap(() => + DesktopUpdateMarker.publish( + path.join(serverConfig.baseDir, "runtime", DESKTOP_UPDATE_IN_PROGRESS_MARKER_FILE), + ).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.ignore({ log: true })), + ), Effect.tap(() => onHandoffAccepted()), ), ); diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index c0a43bfe23e16..3800d58bdb1a8 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -23,6 +23,9 @@ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; /** Held by a Windows launcher while it runs: Task Scheduler tracks only the task's console host. */ export const SERVICE_LAUNCHER_LOCK_FILE = "service-launcher.lock"; +/** Held by a launcher while it has, or is about to start, a server: an SSH launch waits for that + server instead of starting its own. Released while the launcher idles. */ +export const SERVICE_SERVING_LOCK_FILE = "service-serving.lock"; const unquoteSystemdValue = (raw: string) => (raw.startsWith('"') && raw.endsWith('"') diff --git a/apps/server/src/desktopUpdate/DesktopUpdateMarker.ts b/apps/server/src/desktopUpdate/DesktopUpdateMarker.ts index 1f175bbe7db14..9befa63b4becb 100644 --- a/apps/server/src/desktopUpdate/DesktopUpdateMarker.ts +++ b/apps/server/src/desktopUpdate/DesktopUpdateMarker.ts @@ -10,6 +10,24 @@ import * as Schema from "effect/Schema"; const UpdateMarker = Schema.fromJsonString( Schema.Struct({ startedAt: Schema.Number, expiresAt: Schema.Number }), ); +const encodeUpdateMarker = Schema.encodeEffect(UpdateMarker); + +/** + * Makes SSH launches wait for the server that replaces this one instead of starting their own. + * The server writes it, so it also covers a boot service whose launcher predates its own lock. + */ +export const publish = Effect.fn("DesktopUpdateMarker.publish")(function* (markerFile: string) { + const fileSystem = yield* FileSystem.FileSystem; + const startedAt = yield* Clock.currentTimeMillis; + const contents = yield* encodeUpdateMarker({ + startedAt, + expiresAt: startedAt + DESKTOP_UPDATE_IN_PROGRESS_TTL_MS, + }); + // Renamed into place: a launch must never read a half-written marker. + const temporary = `${markerFile}.tmp`; + yield* fileSystem.writeFileString(temporary, contents); + yield* fileSystem.rename(temporary, markerFile); +}); export class DesktopUpdateMarker extends Context.Service< DesktopUpdateMarker, diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 604b92c34d091..71c7b6994ca86 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1782,67 +1782,84 @@ it.effect("reconciles SSH host settings without publishing no-op changes", () => ), ); -it.effect("a failed tool update reports npm's exit code but not its output", () => - Effect.gen(function* () { - const host: DeviceHost.DeviceHost["Service"] = { - id: LOCAL_DEVICE_HOST_ID, - label: "This machine", - summary: Effect.succeed({ +it.effect.each(["exit", "missing"] as const)( + "a failed tool update reports a safe %s failure", + (failure) => + Effect.gen(function* () { + const host: DeviceHost.DeviceHost["Service"] = { id: LOCAL_DEVICE_HOST_ID, - kind: "local", - label: "Test server", - platforms: [], - hubInstalled: false, - agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: false }), - ensureReady: () => Effect.die("Updating a tool does not start the hub"), - ensureAgentReady: () => Effect.die("Updating a tool does not start the agent"), - current: Effect.succeed(null), - stopAgent: Effect.void, - stop: Effect.void, - }; - const npmFailure = { - code: ChildProcessSpawner.ExitCode(243), - stdout: "", - stderr: "npm error EACCES https://private:credential@registry.example.test/expo-device-hub", - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - const service = yield* DeviceService.make.pipe( - Effect.provideService(DeviceHost.DeviceHost, host), - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => Effect.succeed(npmFailure), - }), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("Updating a tool makes no hub requests")), - ), - ); + label: "This machine", + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Test server", + platforms: [], + hubInstalled: false, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: false }), + ensureReady: () => Effect.die("Updating a tool does not start the hub"), + ensureAgentReady: () => Effect.die("Updating a tool does not start the agent"), + current: Effect.succeed(null), + stopAgent: Effect.void, + stop: Effect.void, + }; + const npmFailure = { + code: ChildProcessSpawner.ExitCode(243), + stdout: "", + stderr: "npm error EACCES https://private:credential@registry.example.test/expo-device-hub", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + const service = yield* DeviceService.make.pipe( + Effect.provideService(DeviceHost.DeviceHost, host), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: ({ command, args }) => + failure === "exit" + ? Effect.succeed(npmFailure) + : Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "private diagnostic text", + }), + }), + ), + }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Updating a tool makes no hub requests")), + ), + ); - const error = yield* service.updateTool("hub").pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "DeviceOperationError", - reason: "command_failed", - exitCode: 243, - }); - expect(error.message).toBe( - "Device update device tool failed: The device command failed (exit code 243).", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - ServerSettings.layerTest({ enableDeviceSupport: true }), - layerSecretResolver, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-update-" }), - NodeCrypto.layer, - NetService.layer, - ).pipe(Layer.provideMerge(NodeServices.layer)), + const error = yield* service.updateTool("hub").pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceOperationError", + reason: failure === "exit" ? "command_failed" : "package_manager_missing", + }); + expect(error.message).toBe( + failure === "exit" + ? "Device update device tool failed: The device command failed (exit code 243)." + : "Device update device tool failed: Device support needs npm, which is not on the T3 server's PATH. Install npm (it comes with Node.js from nodejs.org), restart T3 Code, then retry.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ enableDeviceSupport: true }), + layerSecretResolver, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-update-" }), + NodeCrypto.layer, + NetService.layer, + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), ), - ), ); describe("agent session release on close", () => { diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 15e159062a32d..78b31b87aefd3 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -1430,8 +1430,9 @@ export const make = Effect.gen(function* () { (cause) => new DeviceOperationError({ operation: "update device tool", - reason: "command_failed", - // Only the exit code goes into the shown message; npm's stderr can carry registry credentials. + reason: + cause.reason === "npm_not_found" ? "package_manager_missing" : "command_failed", + // npm's stderr can carry registry credentials; expose only the reason and exit code. ...(cause.exitCode !== undefined ? { exitCode: cause.exitCode } : {}), cause, }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 12b35aa9b4756..d5082ee1110ee 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -289,6 +289,7 @@ export const make = Effect.gen(function* () { ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { serverSelfUpdateProgress: true } : {}), + ...(serverSelfUpdate === "boot-service" ? { serverUpdateStaging: true } : {}), ...(desktopAppUpdate ? { desktopAppUpdate: true, desktopAppChannelSwitch: true } : {}), serverBrowser: true, peerLinks: true, diff --git a/apps/server/src/fork/ForkWorktreeBranches.test.ts b/apps/server/src/fork/ForkWorktreeBranches.test.ts index 54c955f9d318e..172f8f8539552 100644 --- a/apps/server/src/fork/ForkWorktreeBranches.test.ts +++ b/apps/server/src/fork/ForkWorktreeBranches.test.ts @@ -139,7 +139,8 @@ it.effect.each(["succeeds", "fails"] as const)( ), ); } - if (args.includes("worktree") && args.includes("add")) { + // `worktree add -h` only probes for --reason support; it checks nothing out. + if (args.includes("worktree") && args.includes("add") && !args.includes("-h")) { cleanups.push( yield* withRepositoryLease( leaseKey, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 3ca4e1bd88020..c63258d187a65 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4759,6 +4759,57 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect( + "create_pr publishes to the push remote when the tracked upstream already has HEAD", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "topic"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Topic change"]); + yield* runGit(repoDir, ["push", "-u", "origin", "topic"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.com:octocat/codething-mvp.git", + forkDir, + ); + yield* runGit(repoDir, ["config", "remote.pushDefault", "fork"]); + + const { manager, providerCalls } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.push).toMatchObject({ status: "pushed", upstreamBranch: "fork/topic" }); + expect(result.pr.status).toBe("created"); + expect( + yield* runGit(forkDir, ["--git-dir=.", "log", "-1", "--pretty=%s", "topic"]), + ).toMatchObject({ + stdout: "Topic change\n", + }); + expect(created(providerCalls)).toEqual([ + { baseRefName: "main", headSelector: "octocat:topic" }, + ]); + expect(yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toMatchObject({ + stdout: "origin/topic\n", + }); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 48956139565f6..953b8b1376b00 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2378,6 +2378,7 @@ export const make = Effect.gen(function* () { fallbackBranch: string | null, threadId: ThreadId | undefined, emit: GitActionProgressEmitter, + pushRemoteRef?: { readonly remoteName: string; readonly upstreamRef: string }, ) { const provider = yield* sourceControlProvider(cwd); const terms = sourceControlDefinitions.get(provider.kind).changeRequest; @@ -2398,10 +2399,12 @@ export const make = Effect.gen(function* () { }); } - const headContext = yield* resolveBranchHeadContext(cwd, { - branch, - upstreamRef: details.upstreamRef, - }); + // A configured push remote receives the commits while the branch keeps + // tracking its upstream, so the PR head is the push remote's branch. + const headContext = yield* resolveBranchHeadContext( + cwd, + pushRemoteRef ? { branch, ...pushRemoteRef } : { branch, upstreamRef: details.upstreamRef }, + ); const existing = yield* findOpenPr(cwd, headContext); if (existing) { @@ -3414,12 +3417,26 @@ export const make = Effect.gen(function* () { > { const initialStatus = yield* gitCore.statusDetails(input.cwd); const wantsCommit = isCommitAction(input.action); + const upToDateBranch = + input.action === "create_pr" && + initialStatus.hasUpstream && + initialStatus.aheadCount === 0 + ? initialStatus.branch + : null; + // A current upstream says nothing about a separate push remote, which may lack HEAD. + const pushTarget = upToDateBranch + ? yield* gitCore + .resolvePushTarget(input.cwd, { branch: upToDateBranch, hasUpstream: true }) + .pipe(Effect.orElseSucceed(() => null)) + : null; const wantsPush = input.action === "push" || input.action === "commit_push" || input.action === "commit_push_pr" || (input.action === "create_pr" && - (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + (!initialStatus.hasUpstream || + initialStatus.aheadCount > 0 || + pushTarget?.viaPushRemote === true)); const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; if (input.featureBranch && !wantsCommit) { @@ -3572,6 +3589,9 @@ export const make = Effect.gen(function* () { currentBranch, input.threadId, progress.emit, + "pushRemote" in push && push.pushRemote && push.upstreamBranch + ? { remoteName: push.pushRemote, upstreamRef: push.upstreamBranch } + : undefined, ), ), ) diff --git a/apps/server/src/guest/GuestShareService.fork.test.ts b/apps/server/src/guest/GuestShareService.fork.test.ts index a4fd984799f3f..3ae2ccb120771 100644 --- a/apps/server/src/guest/GuestShareService.fork.test.ts +++ b/apps/server/src/guest/GuestShareService.fork.test.ts @@ -27,6 +27,7 @@ import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterReg import * as ResourceCleanupService from "../orchestration-v2/ResourceCleanupService.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as DocumentText from "../provider/DocumentText.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; @@ -88,6 +89,7 @@ const layerTestWith = (tunnelPairingUrl: string | undefined) => Layer.provide(layerConnect), Layer.provide(Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void })), Layer.provide(ServerSecretStore.layer), + Layer.provide(DocumentText.layer), Layer.provideMerge(layerConfigWith(tunnelPairingUrl)), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 024c89e35f899..c7cc9e94e2df0 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -52,6 +52,7 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ResourceCleanupService from "../orchestration-v2/ResourceCleanupService.ts"; import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as DocumentText from "../provider/DocumentText.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; @@ -149,6 +150,7 @@ const layerTest = GuestShareService.layer.pipe( Layer.provide(layerConnect), Layer.provideMerge(layerAnalytics), Layer.provide(ServerSecretStore.layer), + Layer.provide(DocumentText.layer), Layer.provideMerge(layerConfig), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); @@ -761,6 +763,11 @@ it.effect("refuses too many files, too many bytes, and images that are not what contentType: "text/html", bytes: new TextEncoder().encode(""), }, + { + name: "notes.rtf", + contentType: "application/rtf", + bytes: new TextEncoder().encode(String.raw`{\rtf1 Available}`), + }, ]), ); const [message] = yield* waitingMessages; @@ -774,26 +781,46 @@ it.effect("refuses too many files, too many bytes, and images that are not what [ { type: "image", name: "screen.png", mimeType: "image/png", sizeBytes: PNG.length }, { type: "file", name: "notes_fdp.html", mimeType: "text/html", sizeBytes: 3 }, + { type: "file", name: "notes.rtf", mimeType: "application/rtf", sizeBytes: 17 }, ], ); + // Agents read a guest's documents through the same extracted text as the owner's uploads. + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const document = resolveAttachmentPath({ + attachmentsDir, + attachment: message!.attachments![2]!, + }); + assert.equal( + yield* (yield* FileSystem.FileSystem).readFileString(`${document}.txt`), + "Available", + ); }).pipe(Effect.scoped, Effect.provide(layerTest)), ); it.effect("caps the bytes of files waiting in one share", () => Effect.gen(function* () { const { guestShares, session, shareId } = yield* joinedGuest; - const submit = (sizeBytes: number) => + const submit = (sizeBytes: number, ...more: Parameters[0]) => Effect.gen(function* () { return guestShares.submit( session, { shareId, text: "Look" }, - yield* uploadsOf([png("big.png", sizeBytes)]), + yield* uploadsOf([png("big.png", sizeBytes), ...more]), ); }).pipe(Effect.flatten); yield* submit(9 * MIB); yield* submit(9 * MIB); - const error = yield* Effect.flip(submit(9 * MIB)); + const before = yield* storedFiles; + const error = yield* Effect.flip( + submit(9 * MIB, { + name: "notes.rtf", + contentType: "application/rtf", + bytes: new TextEncoder().encode(String.raw`{\rtf1 Available}`), + }), + ); assert.equal(error.reason, "files_busy"); + // The refused message keeps neither its files nor their extracted text. + assert.deepEqual(yield* storedFiles, before); // Text alone still goes through. yield* guestShares.submit(session, { shareId, text: "One more thing" }, []); assert.equal((yield* waitingMessages).length, 3); diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index fe8e18ecf6c57..096e6f99bb44e 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -55,6 +55,7 @@ import { projectTurnItemForWire, threadStreamEventItem, } from "../orchestration-v2/WireProjection.ts"; +import * as DocumentText from "../provider/DocumentText.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ForkGuestShareHost from "../fork/forkGuestShareHost.ts"; @@ -242,6 +243,7 @@ const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const random = yield* SecureRandom.SecureRandom; const analytics = yield* AnalyticsService.AnalyticsService; + const documents = yield* DocumentText.DocumentText; const signer = yield* PayloadSigner.make(SIGNING_SECRET_NAME); /** A truncated HMAC keeps invite links short. 128 bits is still far beyond guessing range. */ const inviteSignature = (shareId: string) => @@ -421,7 +423,12 @@ const make = Effect.gen(function* () { const path = resolveAttachmentPath({ attachmentsDir, attachment }); return path === null ? Effect.void - : fileSystem.remove(path, { force: true }).pipe(Effect.ignore); + : fileSystem + .remove(path, { force: true }) + .pipe( + Effect.andThen(fileSystem.remove(`${path}.txt`, { force: true })), + Effect.ignore, + ); }, { discard: true }, ); @@ -467,6 +474,7 @@ const make = Effect.gen(function* () { yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.copyFile(uploads[index]!.path, path); stored.push(attachment); + yield* documents.ensure(path); }).pipe(Effect.mapError(() => uploadFailed)), { discard: true }, ).pipe(Effect.onError(() => removeStored(stored))); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index fa498209b1117..6f8d669be4a85 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -277,6 +277,7 @@ it.effect.each([ const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, + OrchestratorToolkit.tools.t3_thread_list, OrchestratorToolkit.tools.t3_thread_read, OrchestratorToolkit.tools.t3_thread_wait, ProjectToolkit.tools.t3_project_update, @@ -293,6 +294,7 @@ const layerRecordingTools = (calls: Array) => { RecordingToolkit, McpToolAccess.toLayer(RecordingToolkit, { delegate_task: record, + t3_thread_list: record, t3_thread_read: record, t3_thread_wait: record, t3_project_update: record, @@ -728,6 +730,19 @@ it.effect.each(["limit", "runLimit", "maxCharsPerItem"])( }, ); +it.effect("clamps a t3_thread_list limit above its maximum instead of rejecting the call", () => { + const calls: Array = []; + return Effect.gen(function* () { + yield* callTool("t3_thread_list", { allProjects: true, limit: 200 }); + yield* callTool("t3_thread_list", { allProjects: true, limit: 7 }); + + expect(calls).toEqual([ + { allProjects: true, limit: 100 }, + { allProjects: true, limit: 7 }, + ]); + }).pipe(Effect.provide(layerRecordingTools(calls))); +}); + it("keeps cross-tool routing out of the delegate_task description", () => { const description = OrchestratorToolkit.tools.delegate_task.description ?? ""; expect(description).toContain("Start one child agent with only `task`"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 2ff2125a51800..cd8c5064476b2 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -105,7 +105,7 @@ function baseThread(input: { function makeRun(input: { readonly id: RunId; readonly ordinal: number; - readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed"; + readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed" | "failed"; readonly instanceId?: ProviderInstanceId; }) { return { @@ -125,6 +125,15 @@ function makeRun(input: { it("readThread preserves caller access errors and prefers activity-run status over a newer cancelled queued run", async () => { let callerUnavailable = false; + let pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"] = [ + { + taskId: "task-subagent", + kind: "subagent", + parentTaskId: "task-parent", + providerInstanceId: parentInstanceId, + childThreadId, + }, + ]; const pullRequestLink = (number: number, state: "open" | "merged", watched: boolean) => ({ host: "github.com", repository: "acme/web", @@ -202,15 +211,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov ? ({ ...projection.thread, blockedOnThreadWait: true, - pendingBackgroundTasks: [ - { - taskId: "task-subagent", - kind: "subagent", - parentTaskId: "task-parent", - providerInstanceId: parentInstanceId, - childThreadId, - }, - ], + pendingBackgroundTasks, } as unknown as OrchestrationV2ThreadShell) : null, ), @@ -263,6 +264,26 @@ it("readThread preserves caller access errors and prefers activity-run status ov expect(laterPage.thread.pullRequests).toBeUndefined(); expect(laterPage.thread.pullRequestCount).toBe(8); + Object.assign(projection, { + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "completed" })], + }); + const awaiting = yield* service.readThread(makeScope(), { threadId: parentThreadId }); + expect(awaiting.thread.status).toBe("waiting"); + + Object.assign(projection, { + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "failed" })], + }); + const failed = yield* service.readThread(makeScope(), { threadId: parentThreadId }); + expect(failed.thread.status).toBe("failed"); + + Object.assign(projection, { + runs: [makeRun({ id: activeRunId, ordinal: 1, status: "completed" })], + }); + // An unawaited command (a dev server) does not hold the thread once its turn ends. + pendingBackgroundTasks = [{ taskId: "dev-server", kind: "command" }]; + const released = yield* service.readThread(makeScope(), { threadId: parentThreadId }); + expect(released.thread.status).toBe("completed"); + callerUnavailable = true; const denied = yield* service .readThread({ ...makeScope(), capabilities: new Set() }, { threadId: parentThreadId }) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3de2ac26796d8..13dbc879e0887 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -2253,7 +2253,7 @@ describe("OrchestratorMcpService provider resolution", () => { cause: new Error("refused by the orchestrator"), }) as never, ) - : Effect.succeed({} as never), + : Effect.succeed({ storedEvents: [] } as never), }), ServerSettings.layerTest(), providerRegistryLayer([ @@ -2637,6 +2637,17 @@ describe("OrchestratorMcpService provider resolution", () => { }); const titled = yield* orchestrator.getThreadProjection(emojiAtLimit.threads[0]!.threadId); assert.equal(titled.thread.title, `${"a".repeat(76)}...`); + // One commit holds a prompted thread and its run, so no client sees it idle between. + const promptedCommits = yield* orchestrator + .streamStoredEventsFrom({ threadId: titled.thread.id }) + .pipe( + Stream.filter( + ({ event }) => event.type === "thread.created" || event.type === "run.created", + ), + Stream.take(2), + Stream.runCollect, + ); + assert.equal(promptedCommits[0]?.commandId, promptedCommits[1]?.commandId); yield* settings.updateSettings({ projectSettingsOverrides: { @@ -3386,7 +3397,7 @@ describe("OrchestratorMcpService provider resolution", () => { git: Partial, saved: Ref.Ref>, task: ScheduledTask = existing, - modes?: Ref.Ref>, + inputs?: Ref.Ref>, ) => OrchestratorMcpService.layer.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -3418,9 +3429,9 @@ describe("OrchestratorMcpService provider resolution", () => { upsert: (input) => Ref.update(saved, (strategies) => [...strategies, input.workspaceStrategy]).pipe( Effect.andThen( - modes === undefined + inputs === undefined ? Effect.void - : Ref.update(modes, (all) => [...all, input.threadMode]), + : Ref.update(inputs, (all) => [...all, input]), ), Effect.as({ task }), ), @@ -3449,16 +3460,48 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect.each([ + { + name: "drops an emoji the limit splits", + prompt: `${"a".repeat(79)}😀 rest`, + title: "a".repeat(79), + }, + { + name: "keeps an emoji that ends at the limit", + prompt: `${"a".repeat(78)}😀 rest`, + title: `${"a".repeat(78)}😀`, + }, + ])("a prompt-derived title $name", ({ prompt, title }) => + Effect.gen(function* () { + const inputs = yield* Ref.make>([]); + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( + Effect.provide( + service( + masterRepository, + yield* Ref.make>([]), + existing, + inputs, + ), + ), + ); + yield* mcp.scheduleTask(client, { projectId, prompt, schedule }); + assert.deepEqual( + (yield* Ref.get(inputs)).map((input) => input.title), + [title], + ); + }), + ); + it.effect("keeps one thread when fresh sessions are turned off before the first run", () => Effect.gen(function* () { - const modes = yield* Ref.make>([]); + const inputs = yield* Ref.make>([]); const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( Effect.provide( service( masterRepository, yield* Ref.make>([]), { ...existing, threadMode: "fresh_session" }, - modes, + inputs, ), ), ); @@ -3470,7 +3513,10 @@ describe("OrchestratorMcpService provider resolution", () => { scheduledTaskId: existing.id, bindToCurrentThread: false, }); - assert.deepEqual(yield* Ref.get(modes), ["continue", "new_thread"]); + assert.deepEqual( + (yield* Ref.get(inputs)).map((input) => input.threadMode), + ["continue", "new_thread"], + ); }), ); @@ -3478,7 +3524,7 @@ describe("OrchestratorMcpService provider resolution", () => { "refuses unbinding into fresh sessions in one call, which would keep the thread", () => Effect.gen(function* () { - const modes = yield* Ref.make>([]); + const inputs = yield* Ref.make>([]); const mcp = yield* OrchestratorMcpService.OrchestratorMcpService.pipe( Effect.provide( service( @@ -3489,7 +3535,7 @@ describe("OrchestratorMcpService provider resolution", () => { threadId: ThreadId.make("thread:scheduled-base"), threadMode: "continue", }, - modes, + inputs, ), ), ); @@ -3501,7 +3547,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) .pipe(Effect.flip); assert.include(refused.message, "first unbind it"); - assert.deepEqual(yield* Ref.get(modes), []); + assert.deepEqual(yield* Ref.get(inputs), []); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index baf3bac62b441..3746c5af94f28 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -910,11 +910,18 @@ function threadPullRequests( } // Awaited background work keeps a thread live after its turn ends, as auto-settle and the sidebar treat it. +function threadStatus( + activeStatus: OrchestratorMcpThreadListItem["status"] | null | undefined, + settledStatus: OrchestratorMcpThreadListItem["status"], + pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"], +) { + if (activeStatus != null) return activeStatus; + if (settledStatus === "failed") return settledStatus; + return backgroundWorkHoldsCompletion(pendingBackgroundTasks ?? []) ? "waiting" : settledStatus; +} + function listedThreadStatus(shell: OrchestrationV2ThreadShell) { - if (shell.activityRunStatus != null) return shell.activityRunStatus; - return backgroundWorkHoldsCompletion(shell.pendingBackgroundTasks ?? []) - ? "waiting" - : shell.status; + return threadStatus(shell.activityRunStatus, shell.status, shell.pendingBackgroundTasks); } function listItemFromShell( @@ -967,7 +974,7 @@ function threadDetail( appLink: threadAppLinkFor(scope, projection.thread.id), createdBy: projection.thread.createdBy, creationSource: projection.thread.creationSource, - status: active?.status ?? latest?.status ?? "idle", + status: threadStatus(active?.status, latest?.status ?? "idle", shell.pendingBackgroundTasks), latestRunId: latest?.id ?? null, activeRunId: active?.id ?? null, providerInstanceId: projection.thread.modelSelection.instanceId, @@ -2265,9 +2272,11 @@ const make = Effect.gen(function* () { ); yield* refuseTerminalWhileOff(modelFields.modelSelection.instanceId); yield* refuseTerminalThreadWithoutExec(scope.requestNamespace, modelFields.modelSelection); - const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? ""; - const title = - input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task"); + // Never end on a lone high surrogate, which renders as a broken character. + const derivedTitle = (input.prompt.split("\n")[0]?.trim() ?? "") + .slice(0, 80) + .replace(/[\uD800-\uDBFF]$/, ""); + const title = input.title ?? (derivedTitle.length > 0 ? derivedTitle : "Scheduled task"); const upsertInput: ScheduledTaskUpsertInput = { title, prompt: input.prompt, @@ -3143,7 +3152,20 @@ const make = Effect.gen(function* () { title: request.title, index, }); - yield* threadManagement + const initialMessage = + request.prompt === undefined + ? undefined + : { + senderThreadId: scope.thread.threadId, + messageId: stableMessageId({ + scope, + requestKey: key, + index, + }), + text: request.prompt, + attachments: [], + }; + const created = yield* threadManagement .dispatch({ type: "thread.create", createdBy: "agent", @@ -3160,6 +3182,7 @@ const make = Effect.gen(function* () { ...(parent.thread.linkOrigin === undefined ? {} : { linkOrigin: parent.thread.linkOrigin }), + ...(initialMessage === undefined ? {} : { initialMessage }), }) .pipe( Effect.mapError((error) => @@ -3173,9 +3196,14 @@ const make = Effect.gen(function* () { const resolvedModelSelection = (yield* threadManagement .getThreadRecords(threadId, []) .pipe(Effect.mapError(threadManagementFailure))).thread.modelSelection; - if (request.prompt !== undefined) { + // A replayed creation that carried no prompt has no run yet. + if ( + initialMessage !== undefined && + !created.storedEvents.some(({ event }) => event.type === "run.created") + ) { yield* threadManagement .dispatch({ + ...initialMessage, type: "message.dispatch", createdBy: "agent", creationSource: "mcp", @@ -3186,14 +3214,6 @@ const make = Effect.gen(function* () { index, }), threadId, - senderThreadId: scope.thread.threadId, - messageId: stableMessageId({ - scope, - requestKey: key, - index, - }), - text: request.prompt, - attachments: [], modelSelection: resolvedModelSelection, dispatchMode: { type: "start_immediately" }, }) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 28b1897e58d81..03a3a99e2d703 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -2456,12 +2456,7 @@ describe("AcpAdapterV2", () => { }, stopReason === "error", ); - const expectedStatus = - stopReason === "error" - ? "failed" - : stopReason === "cancelled" - ? "cancelled" - : "completed"; + const expectedStatus = stopReason === "cancelled" ? "cancelled" : "failed"; yield* reportNextPromptStopped(stopReason); assertReinjectedInstructions(yield* runTurn("Start work.", expectedStatus), "Start work."); assertReinjectedInstructions( @@ -3180,6 +3175,87 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("replays history in order, with stable earlier snapshots", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let sessionUpdateHandler: Parameters[0] | undefined; + const instanceId = ProviderInstanceId.make("acp-test-v2-replay-order"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (handler) => + Effect.sync(() => { + sessionUpdateHandler = handler; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), + }), + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-v2-replay-order"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-acp-v2-replay-order"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + assert.isDefined(sessionUpdateHandler); + const replay = ( + sessionUpdate: "user_message_chunk" | "agent_message_chunk", + messageId: string, + text: string, + ) => + sessionUpdateHandler!({ + sessionId: "mock-session-1", + update: { sessionUpdate, messageId, content: { type: "text", text } }, + }); + + yield* replay("user_message_chunk", "u1", "hel"); + yield* replay("user_message_chunk", "u1", "lo"); + yield* replay("agent_message_chunk", "a1", "hi"); + yield* replay("user_message_chunk", "u2", "next"); + yield* replay("agent_message_chunk", "a1", " there"); + const first = yield* runtime.readThreadSnapshot({ providerThread }); + yield* replay("agent_message_chunk", "a2", "done"); + yield* replay("user_message_chunk", "u2", " one"); + const second = yield* runtime.readThreadSnapshot({ providerThread }); + + const texts = (snapshot: typeof first) => + snapshot.messages.map((message) => `${message.role}:${message.text}`); + assert.deepEqual(texts(first), ["user:hello", "assistant:hi there", "user:next"]); + assert.deepEqual(texts(second), [ + "user:hello", + "assistant:hi there", + "user:next one", + "assistant:done", + ]); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("persists a bounded subset of streamed root and child tool updates", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -3502,14 +3578,14 @@ describe("AcpAdapterV2", () => { "cognition.ai/subagent_context": { parentAgentId: "child-a" }, }, }, - { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: "ONE" }, + ...["O", "N", "E"].map((text) => ({ + sessionUpdate: "agent_message_chunk" as const, + content: { type: "text" as const, text }, _meta: { "cognition.ai/streamingMessageId": "child-result", "cognition.ai/subagent_context": { parentAgentId: "child-a" }, }, - }, + })), { sessionUpdate: "tool_call", toolCallId: "child-later", @@ -3662,6 +3738,15 @@ describe("AcpAdapterV2", () => { (item) => item.threadId === task?.childThreadId && item.type === "assistant_message", ); assert.equal(new Set(childAnswers.map((item) => item.ordinal)).size, 2); + // Later chunks coalesce; the subagent's completion flushes the final text. + assert.deepEqual( + childAnswers.flatMap((item) => + item.type === "assistant_message" && item.text !== "Checking the code." + ? [item.text] + : [], + ), + ["O", "ONE"], + ); const parentTools = items.filter( (item) => item.threadId === threadId && item.type === "dynamic_tool", ); @@ -3670,6 +3755,115 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("emits coalesced subagent text before a failed turn's terminal", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type Runtime = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let handler: Parameters[0] | undefined; + const instanceId = ProviderInstanceId.make("devin-failed-flush"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + selfInvocation: yield* resolveSelfInvocation(), + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + normalizeSessionUpdate: normalizeDevinSessionUpdate, + normalizeToolCall: normalizeDevinToolCall, + extractSubagentUpdate: extractDevinSubagentUpdate, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + wrapRuntime: (runtime) => ({ + ...runtime, + handleSessionUpdate: (next) => + Effect.sync(() => { + handler = next; + }).pipe(Effect.andThen(runtime.handleSessionUpdate(next))), + prompt: () => + Effect.gen(function* () { + const updates = [ + { + sessionUpdate: "tool_call_update", + toolCallId: "child-a", + status: "in_progress", + _meta: { + "cognition.ai/subagent_started": { + agentId: "child-a", + title: "Answer", + task: "Reply ONE.", + }, + }, + }, + ...["O", "N", "E"].map((text) => ({ + sessionUpdate: "agent_message_chunk" as const, + content: { type: "text" as const, text }, + _meta: { + "cognition.ai/streamingMessageId": "child-result", + "cognition.ai/subagent_context": { parentAgentId: "child-a" }, + }, + })), + ] satisfies Array; + for (const update of updates) + yield* handler!({ sessionId: "mock-session-1", update }); + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "prompt failed", + }); + }), + }), + }), + }, + }); + const threadId = ThreadId.make("devin-failed-flush-parent"); + const modelSelection = { instanceId, model: "default" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("devin-failed-flush-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const events = Array.from( + yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ), + ); + const terminal = events.at(-1); + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : undefined, "failed"); + const childText = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.threadId !== threadId + ? [event.turnItem.text] + : [], + ); + assert.equal(childText.at(-1), "ONE"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("routes interleaved Copilot subagent output into subagent threads", () => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 70f45bc8a46c0..8982fb122fc7b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2685,6 +2685,42 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("writes a child's coalesced text before a restart resets it", () => + Effect.gen(function* () { + const { update, childState, idle, prompt } = claudeFrames; + const reply = (text: string) => + update("child-1", { + sessionUpdate: "agent_message_chunk", + messageId: "reply", + content: { type: "text", text }, + }); + const session = yield* openClaudeReplaySession([ + ...claudeFrames.open, + ...prompt(1), + ...claudeChild.slice(0, -1), + reply("Review "), + reply("done."), + // A working grandchild keeps the idle child from publishing (and flushing) its status. + childState("child-1", "grandchild", "running"), + childState(CLAUDE_ROOT_SESSION, "child-1", "idle"), + childState(CLAUDE_ROOT_SESSION, "child-1", "running"), + idle("end_turn"), + claudeFrames.closeRequest, + claudeFrames.closed, + ]); + yield* session.runtime.startTurn(session.turnInput(1)); + const events = yield* session.until((event) => event.type === "turn.terminal"); + assert.isTrue( + events.some( + (event) => + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.text === "Review done.", + ), + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("fails active Claude work and releases its children when the process exits", () => Effect.gen(function* () { const session = yield* openClaudeReplaySession([ @@ -2755,6 +2791,65 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect.each([ + { stopReason: "refusal", childUpdate: "state_update" }, + { stopReason: "max_tokens", childUpdate: "subagent_update" }, + ])( + "fails Claude's turn and child and closes its running tool when they stop with $stopReason via $childUpdate", + ({ stopReason, childUpdate }) => + Effect.gen(function* () { + const { update, idle, prompt } = claudeFrames; + const session = yield* openClaudeReplaySession([ + ...claudeFrames.open, + ...prompt(1), + ...claudeChild, + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "root-tool", + name: "Bash", + title: "Read package", + kind: "execute", + status: "in_progress", + rawInput: { command: "cat package.json" }, + }), + update("child-1", { sessionUpdate: "state_update", state: "running" }), + childUpdate === "state_update" + ? update("child-1", { sessionUpdate: "state_update", state: "idle", stopReason }) + : update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "subagent_update", + sessionId: "child-1", + state: { state: "idle", stopReason }, + }), + idle(stopReason), + ]); + yield* session.runtime.startTurn(session.turnInput(1)); + const events = yield* session.until((event) => event.type === "turn.terminal"); + const terminal = events.at(-1)!; + if (terminal.type !== "turn.terminal") throw new Error("Missing root terminal"); + assert.equal(terminal.status, "failed"); + assert.equal(terminal.failure?.code, stopReason); + const child = events.findLast((event) => event.type === "subagent.updated"); + if (child?.type !== "subagent.updated") throw new Error("Missing child update"); + assert.equal(child.subagent.status, "failed"); + assert.isNotNull(child.subagent.result, "the child shows why it stopped"); + assert.isFalse( + events.some( + (event) => + event.type === "message.updated" && + event.message.threadId === child.subagent.childThreadId && + event.message.role === "assistant", + ), + "the child's conversation has no reply it didn't send", + ); + const tool = events.findLast( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "command_execution", + ); + if (tool?.type !== "turn_item.updated") throw new Error("Missing tool update"); + assert.equal(tool.turnItem.status, "failed"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("keeps Claude activity after a refused Stop and closes it on retry", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["close-response", "queued-close-response"]); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3de6205ae4ff4..76ae9416330b4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -281,6 +281,69 @@ function makeClaudeTestTurnInput(input: { }; } +describe("ClaudeAdapterV2 native resume history", () => { + const entry = (uuid: string, type: string, content: unknown) => ({ + uuid, + type, + message: { content }, + }); + const prompt = entry("prompt", "user", "Run the task"); + const completed = entry("completed-tool", "user", [ + { type: "tool_result", tool_use_id: "done", content: "Saved", is_error: false }, + ]); + const call = entry("call", "assistant", [ + { type: "tool_use", id: "pending", name: "Bash", input: {} }, + ]); + const interrupted = entry("stopped-tool", "user", [ + { + type: "tool_result", + tool_use_id: "pending", + content: "The user doesn't want to proceed with this tool use. STOP", + is_error: true, + }, + ]); + it.each([ + { + name: "an interrupted reply", + messages: [ + prompt, + entry("reply", "assistant", "Partial response"), + entry("stop", "user", [{ type: "text", text: "[Request interrupted by user]" }]), + ], + head: "reply", + }, + { + name: "an interrupted tool after completed work", + messages: [ + prompt, + completed, + call, + interrupted, + entry("stop", "user", "[Request interrupted by user for tool use]"), + ], + head: "call", + }, + { + name: "an interrupted request before any reply", + messages: [prompt, entry("stop", "user", "[Request interrupted by user]")], + head: "prompt", + }, + { + name: "completed tool results", + messages: [prompt, completed, entry("stop", "user", "[Request interrupted by user]")], + head: "completed-tool", + }, + { name: "an explicit refusal", messages: [prompt, call, interrupted], head: undefined }, + { + name: "ordinary conversation text", + messages: [prompt, entry("reply", "assistant", "Continue where you left off.")], + head: undefined, + }, + ])("selects the native history head for $name", ({ messages, head }) => { + assert.equal(ClaudeAdapterV2.claudeResumeSessionAt(messages), head); + }); +}); + describe("ClaudeAdapterV2 runtime query policy", () => { it.each([ "-- --thinking-display omitted", @@ -3661,6 +3724,72 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarness = makeWakeHarnessWithOptions(); + it.effect.each([false, true])( + "resumes a stopped query with empty SDK content and a fresh native process, buffered: %s", + (buffered) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + freshQueueOnReopen: true, + close: (queue) => Queue.shutdown(queue), + }); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: DateTime.makeUnsafe(0), + attemptId: RunAttemptId.make("native-resume"), + text: "Initial task", + attachments: [], + providerTurnOrdinal: 1, + }); + yield* harness.runtime.startTurn(input); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + if (buffered) { + yield* harness.offerAndWait(wakeAssistant); + yield* harness.offerAndWait(wakeResult); + } + const { message, ...turn } = input; + yield* harness.runtime.resumeTurn!({ + ...turn, + providerTurnOrdinal: 2, + messageId: message.messageId, + }); + assert.equal(harness.processQueues.length, 2); + assert.equal( + harness.getOpenedOptions()?.resume, + harness.providerThread.nativeThreadRef?.nativeId, + ); + if (buffered) { + assert.equal(harness.offeredMessages.length, 1); + } else { + assert.deepEqual(harness.offeredMessages.at(-1)?.message.content, []); + yield* harness.offerAndWait(turnOneResult); + } + yield* Queue.take(harness.terminalReceipts); + assert.equal(harness.terminalEvents().length, 2); + if (buffered) { + assert.isTrue( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === WAKE_ASSISTANT_TEXT, + ), + ); + } + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("resumes the session a /clear selected after the Claude process is replaced", () => Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ @@ -8789,6 +8918,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { { status: 503, cooldown: "999999999999999999999999h", seconds: 0 }, { status: 503, cooldown: "4m 6s", seconds: 0, errorType: "overloaded_error" }, { status: 401, cooldown: "4m 6s", seconds: 0 }, + { status: 429, cooldown: "1h2m3s", seconds: 3723, proxy: true, sdkResetSeconds: 60 }, + { status: 429, cooldown: "1h2m3s", seconds: 7200, proxy: true, sdkResetSeconds: 7200 }, + { status: 429, cooldown: "1h2m3s", seconds: 0, proxy: true, sdkResetSeconds: null }, + { + status: 429, + cooldown: "nested 1h", + seconds: 0, + proxy: true, + result: + "API Error: Request rejected (429) · All credentials for model claude-opus-5-5 are cooling down (last error: rate_limit_error (reset after 1h))", + }, + { + status: 429, + cooldown: "outer cooldown", + seconds: 60, + proxy: true, + result: + "API Error: Request rejected (429) · All credentials for model claude-opus-5-5 are cooling down (reset after 1m) (last error: All credentials for model upstream are cooling down (reset after 1h))", + }, ])("recovers wrapped gateway limits with status $status and cooldown $cooldown", (input) => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -8804,13 +8952,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offerAll(harness.sdkMessages, [ + ...(input.sdkResetSeconds !== undefined + ? [ + claudeSdkFrame({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + ...(input.sdkResetSeconds === null + ? {} + : { + resetsAt: DateTime.toEpochMillis(now) / 1000 + input.sdkResetSeconds, + }), + }, + uuid: "00000000-0000-4000-8000-000000000651", + session_id: WAKE_NATIVE_SESSION, + }), + ] + : []), makeAssistantErrorFrame({ uuid: "00000000-0000-4000-8000-000000000652", - error: "server_error", + error: input.proxy ? "rate_limit" : "server_error", }), makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000653", - result: `API Error: ${input.status} [claude/claude-opus-5-5] [429]: {"type":"error","error":{"type":"${input.errorType ?? "rate_limit_error"}","message":"Please try again later."}} (reset after ${input.cooldown}).`, + result: + input.result ?? + (input.proxy + ? `API Error: Request rejected (429) · All credentials for model claude-opus-5-5 are cooling down (reset after ${input.cooldown}) (last error: rate_limit_error: Please try again later.)` + : `API Error: ${input.status} [claude/claude-opus-5-5] [429]: {"type":"error","error":{"type":"${input.errorType ?? "rate_limit_error"}","message":"Please try again later."}} (reset after ${input.cooldown}).`), terminalReason: "api_error", isError: true, apiErrorStatus: input.status, @@ -8819,7 +8989,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { const terminal = yield* Queue.take(harness.terminalReceipts); assert.equal(terminal.status, "failed"); if (terminal.status !== "failed") return; - assert.equal(terminal.failure.class, input.seconds > 0 ? "usage_limit" : "provider_error"); + assert.equal( + terminal.failure.class, + input.seconds > 0 || input.proxy ? "usage_limit" : "provider_error", + ); if (input.seconds > 0) { assert.equal(terminal.failure.retryable, true); assert.equal( @@ -8829,7 +9002,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); } else { - assert.isUndefined(terminal.failure.resetAt); + assert.equal(terminal.failure.resetAt, input.proxy ? null : undefined); } }).pipe( Effect.provide( @@ -11240,6 +11413,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* harness.awaitTerminal(1); + // A recovered retry must not fire its budget later. + yield* TestClock.adjust("10 minutes"); + assert.lengthOf(harness.terminalEvents(), 1); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( Layer.mergeAll( @@ -11252,6 +11429,170 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const offerApiRetry = (harness: Effect.Success, uuid: string) => + Effect.gen(function* () { + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 10, + retry_delay_ms: 600, + error_status: 503, + error: "server_error", + uuid, + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.status === "running", + ), + ); + }); + + it.effect.each(["closed", "interrupt-hangs", "unconfirmed", "close-hangs", "synthetic"] as const)( + "fails a turn whose API retries outlast the retry budget: %s", + (response) => + Effect.gen(function* () { + const interruptSent = yield* Deferred.make(); + const closeRequested = yield* Deferred.make(); + const releaseClose = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + interrupt: Deferred.succeed(interruptSent, undefined).pipe( + Effect.andThen(response === "interrupt-hangs" ? Effect.never : Effect.void), + ), + close: (sdkMessages) => + Deferred.succeed(closeRequested, undefined).pipe( + Effect.andThen( + response === "close-hangs" + ? Deferred.await(releaseClose).pipe(Effect.andThen(Queue.shutdown(sdkMessages))) + : response === "unconfirmed" + ? Effect.void + : Queue.shutdown(sdkMessages), + ), + ), + }); + yield* Effect.addFinalizer(() => Deferred.succeed(releaseClose, undefined)); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-retry-budget"), + text: "Summarize the repo.", + attachments: [], + }), + ); + yield* offerApiRetry(harness, "00000000-0000-4000-8000-000000000211"); + if (response === "synthetic") { + // The CLI reports the API error as a assistant frame; that is not recovery. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "", + id: "retry-notice", + type: "message", + role: "assistant", + content: [{ type: "text", text: "API Error: 503" }], + stop_reason: "end_turn", + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + parent_tool_use_id: null, + uuid: "retry-notice", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ), + ); + } + yield* TestClock.adjust("9 minutes"); + assert.lengthOf(harness.terminalEvents(), 0); + yield* TestClock.adjust("1 minute"); + yield* Deferred.await(interruptSent); + if (response !== "closed" && response !== "synthetic") { + yield* TestClock.adjust("20 seconds"); + } + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.equal(terminal.failure.code, "api_error_503"); + assert.equal(terminal.retry?.attempt, 1); + assert.lengthOf(harness.terminalEvents(), 1); + assert.isTrue(yield* Deferred.isDone(closeRequested), "the query must be closed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps a user Stop interrupted when the retry budget passes before it settles", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-retry-budget-user-stop"), + text: "Summarize the repo.", + attachments: [], + }), + ); + yield* offerApiRetry(harness, "00000000-0000-4000-8000-000000000212"); + const active = harness.events.find( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + if (active?.type !== "provider_turn.updated") + return yield* Effect.die("Missing running native turn"); + // The CLI has not confirmed the Stop when the budget passes. + yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: active.providerTurn.id, + }) + .pipe(Effect.ignore, Effect.forkScoped); + yield* TestClock.adjust("10 minutes"); + yield* Queue.shutdown(harness.sdkMessages); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each(["api_error", "overloaded_status"] as const)( "fails a success-shaped Claude result with %s", (terminalReason) => @@ -17764,6 +18105,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.awaitContinuation(1); yield* Queue.shutdown(harness.sdkMessages); + yield* harness.until(() => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === OUTER_TASK_ID && + event.subagent.status === "completed", + ), + ); yield* yieldTicks(50); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -18918,12 +19267,36 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("records only explicit effort for nested subagents", () => - Effect.gen(function* () { + it.effect("records explicit effort, else the effort a subagent's transcript reports", () => { + const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; + const firstRead = Deferred.makeUnsafe(); + return Effect.gen(function* () { const OUTER_TOOL_USE_ID = "toolu-effort-outer"; - const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; - const harness = yield* makeWakeHarness; + const fileSystem = yield* FileSystem.FileSystem; + const configDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-effort-", + }); + // Claude names the folder after the resolved cwd, here as if /workspace were a symlink. + const subagentsDir = NodePath.join( + configDir, + "projects", + "-private-workspace", + WAKE_NATIVE_SESSION, + "subagents", + ); + yield* fileSystem.makeDirectory(subagentsDir, { recursive: true }); + const transcriptPath = NodePath.join( + subagentsDir, + `agent-task-${UNSPECIFIED_TOOL_USE_ID}.jsonl`, + ); + yield* fileSystem.writeFileString( + transcriptPath, + `{"type":"user","uuid":"effort-line-0","message":{"role":"user","content":"Check one thing."}}\n`, + ); + const harness = yield* makeWakeHarnessWithOptions({ + environment: { CLAUDE_CONFIG_DIR: configDir }, + }); const now = yield* DateTime.now; const parentSelection = { instanceId: CLAUDE_TEST_MODEL_SELECTION.instanceId, @@ -18993,6 +19366,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: `task-${UNSPECIFIED_TOOL_USE_ID}`, + tool_use_id: UNSPECIFIED_TOOL_USE_ID, + status: "completed", + output_file: "/tmp/claude-replay/tasks/unspecified.output", + summary: "OK", + uuid: "00000000-0000-4000-8000-000000000227", + session_id: WAKE_NATIVE_SESSION, + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000228", + toolUseId: UNSPECIFIED_TOOL_USE_ID, + }), makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000226", result: "Spawned the subagents.", @@ -19000,7 +19389,27 @@ describe("ClaudeAdapterV2 background wake turns", () => { ]) { yield* Queue.offer(harness.sdkMessages, frame); } + // The settled subagent's reply lands after the first lookup, beyond one read's cap. + yield* Deferred.await(firstRead); + NodeFS.appendFileSync( + transcriptPath, + `${"x".repeat(512 * 1024 + 1)}\n{"type":"assistant","effort":"low","uuid":"effort-line-1","message":{"role":"assistant","id":"msg_effort_child","content":[{"type":"text","text":"OK"}]}}\n`, + ); + yield* TestClock.adjust(500); yield* Queue.take(harness.terminalReceipts); + // A run can stop reading events once its subagents settle, so the settle carries the effort. + const settle = harness.events.find( + (event) => + event.type === "subagent.updated" && + event.subagent.status === "completed" && + event.subagent.nativeTaskRef?.nativeId === `task-${UNSPECIFIED_TOOL_USE_ID}`, + ); + assert.equal( + settle?.type === "subagent.updated" + ? getModelSelectionStringOptionValue(settle.subagent.modelSelection, "effort") + : null, + "low", + ); const efforts = new Map( harness.events.flatMap((event) => @@ -19016,7 +19425,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepEqual(Object.fromEntries(efforts), { [`task-${OUTER_TOOL_USE_ID}`]: "low", - [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, + [`task-${UNSPECIFIED_TOOL_USE_ID}`]: "low", [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", }); const childEfforts = harness.events.flatMap((event) => @@ -19026,6 +19435,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepEqual(childEfforts, ["low", undefined, "max"]); }).pipe( + Effect.provideService(WorkflowAgentTranscriptReadObserver, (read) => + read.agentId === `task-${UNSPECIFIED_TOOL_USE_ID}` + ? Deferred.succeed(firstRead, undefined).pipe(Effect.asVoid) + : Effect.void, + ), Effect.provide( Layer.mergeAll( IdAllocator.layer, @@ -19034,8 +19448,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { NodeCrypto.layer, ), ), - ), - ); + ); + }); it.effect.each([ { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index add8a84d703b4..d96034ef2abb6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -228,6 +228,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure, makeProviderRetryTurnItem, + PROVIDER_RETRY_BUDGET_MS, } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; @@ -648,6 +649,7 @@ export type ClaudeAgentSdkQueryOptions = Omit< } & ClaudeAgentSdkThreadIdentity; export interface ClaudeAgentSdkQueryOpenInput { + readonly resume?: true; readonly options: ClaudeAgentSdkQueryOptions; readonly threadId: ThreadId; readonly providerSessionId: OrchestrationV2ProviderSession["id"]; @@ -1221,6 +1223,67 @@ const decodeClaudeHistoryFork = Schema.decodeUnknownEffect( ), ); +const decodeClaudeHistoryMessages = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + type: Schema.String, + uuid: Schema.String, + message: Schema.Struct({ content: Schema.Unknown }), + }), + ), + ), +); + +// Claude's interactive reply-on-resume removes native abort rows before querying the saved history. +export function claudeResumeSessionAt( + messages: ReadonlyArray<{ + readonly type: string; + readonly uuid: string; + readonly message: { readonly content: unknown }; + }>, +): string | undefined { + let end = messages.length; + let toolInterrupted = false; + while (end > 0) { + const message = messages[end - 1]!; + const content = message.message.content; + const blocks = typeof content === "string" ? [{ type: "text", text: content }] : content; + if (message.type !== "user" || !Array.isArray(blocks) || blocks.length === 0) break; + const stop = blocks.every( + (block: unknown) => + Predicate.isObject(block) && + block.type === "text" && + (block.text === "[Request interrupted by user]" || + block.text === "[Request interrupted by user for tool use]"), + ); + if (stop) { + toolInterrupted ||= blocks.some( + (block: unknown) => + Predicate.isObject(block) && block.text === "[Request interrupted by user for tool use]", + ); + end--; + continue; + } + if ( + toolInterrupted && + blocks.every( + (block: unknown) => + Predicate.isObject(block) && + block.type === "tool_result" && + block.is_error === true && + typeof block.content === "string" && + block.content.startsWith("The user doesn't want to proceed with this tool use."), + ) + ) { + end--; + continue; + } + break; + } + return end === messages.length ? undefined : messages[end - 1]?.uuid; +} + const claudeConfigDirOf = ( path: Path.Path, environment: NodeJS.ProcessEnv, @@ -1230,7 +1293,7 @@ const claudeConfigDirOf = ( // The SDK reads CLAUDE_CONFIG_DIR from process.env. Isolate custom homes in a // worker so concurrent providers keep the server's environment. const runClaudeWorker = Effect.fn("runClaudeWorker")(function* (input: { - readonly method: "forkSession"; + readonly method: "forkSession" | "getSessionMessages"; readonly sessionId: string; readonly options?: ForkSessionOptions; readonly environment: NodeJS.ProcessEnv; @@ -1359,10 +1422,36 @@ export const layerQueryRunner: Layer.Layer< // Send MCP credentials over stdin to keep them out of SDK argv and inherited command environments. const { mcpServers, ...sdkOptions } = input.options; // Jobs the agent starts inherit the thread, so port discovery can attribute their servers. + let resumeSessionAt = sdkOptions.resumeSessionAt; + if (input.resume && sdkOptions.resume !== undefined) { + const messages = yield* runClaudeWorker({ + method: "getSessionMessages", + sessionId: sdkOptions.resume, + options: sdkOptions.cwd === undefined ? {} : { dir: sdkOptions.cwd }, + environment: sdkOptions.env ?? hostEnvironment, + }).pipe( + Effect.flatMap(decodeClaudeHistoryMessages), + Effect.mapError((cause) => queryRunnerError(cause, "getSessionMessages")), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + ); + const boundary = + resumeSessionAt === undefined + ? messages.length + : messages.findIndex((message) => message.uuid === resumeSessionAt) + 1; + resumeSessionAt = claudeResumeSessionAt(messages.slice(0, boundary)) ?? resumeSessionAt; + } const queryOptions = { ...sdkOptions, + ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), env: { ...(sdkOptions.env ?? hostEnvironment), + ...(input.resume + ? { + CLAUDE_CODE_RESUME_INTERRUPTED_TURN: "1", + CLAUDE_CODE_RESUME_REASON: "user_resume", + } + : {}), [PortScanner.AGENT_THREAD_ENV]: input.threadId, }, }; @@ -4550,7 +4639,13 @@ function providerFailureFromResult( const resetAt = apiErrorStatus === 429 && message.result.startsWith("API Error:") ? gatewayCooldownResetAt( - /\(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/.exec(message.result), + // CLIProxyAPI nests upstream errors in its message; only its own cooldown counts. + (message.result.startsWith( + "API Error: Request rejected (429) · All credentials for model ", + ) + ? /^API Error: Request rejected \(429\) · All credentials for model [^\s()]+ are cooling down \(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/ + : /\(reset after (?:(\d+)h ?)?(?:(\d+)m ?)?(?:(\d+)s)?\)/ + ).exec(message.result), receivedAt, ) : undefined; @@ -4720,6 +4815,7 @@ function formatClaudeUsageLimitWait(waitMs: number): string { } interface ActiveClaudeTurnContext { + retryBudgetFailure?: OrchestrationV2ProviderFailure; readonly queryPolicy: ClaudeRuntimeQueryPolicy; readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; readonly autoCompactWindow: string; @@ -6133,6 +6229,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }).pipe(Effect.withSpan("ClaudeAdapterV2.completeProviderRetry")); }); + // A hung request can take many minutes per attempt, so the budget spans the + // whole retry window rather than counting attempts. + const failExpiredProviderRetry = Effect.fn("ClaudeAdapterV2.failExpiredProviderRetry")( + function* (context: ActiveClaudeTurnContext, startedAt: DateTime.Utc) { + const isCurrentRetry = Effect.gen(function* () { + const retry = (yield* Ref.get(providerRetries)).get(context.providerTurnId); + return retry?.startedAt === startedAt && (yield* Ref.get(activeTurn)) === context + ? retry + : undefined; + }); + const retry = yield* isCurrentRetry; + // A user Stop already in flight keeps its interrupted outcome. + if ( + retry === undefined || + (yield* Ref.get(interruptedTurns)).has(context.providerTurnId) + ) + return; + context.retryBudgetFailure = { ...retry.failure, retryable: false }; + const liveQuery = yield* Ref.get(queryContext); + yield* interruptTurn({ + providerThread: context.input.providerThread, + providerTurnId: context.providerTurnId, + }).pipe( + Effect.timeoutOption(CLAUDE_STOP_CONFIRM_TIMEOUT_MS), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.claude-retry-budget-stop-failed", { cause }), + ), + ); + if (liveQuery === null || (yield* isCurrentRetry) === undefined) return; + // The CLI did not confirm the stop; settle the turn without it. + const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => + current?.query === liveQuery.query ? [true, null] : [false, current], + ); + if (!ownedLiveQuery) return; + liveQuery.stopping = true; + yield* liveQuery.query.close.pipe( + Effect.timeoutOption(CLAUDE_STOP_CONFIRM_TIMEOUT_MS), + Effect.ignore, + ); + yield* finalizeActiveTurnAfterQueryExit(liveQuery); + yield* endSubagentCallsOfClosedQuery(liveQuery.nativeThreadId, "interrupted"); + yield* Deferred.succeed(liveQuery.closed, undefined); + }, + ); + const rememberProviderThread = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* (providerThread: OrchestrationV2ProviderThread) { @@ -7429,6 +7570,63 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : new Map(current).set(nativeThreadId, { ...entry, messages }); }); + // Claude Code records the effort a subagent ran at only in its transcript. + const readSubagentTranscriptEffort = Effect.fnUntraced(function* ( + context: ActiveClaudeTurnContext, + taskId: string, + completed: boolean, + ) { + const cwd = context.input.runtimePolicy.cwd; + if (cwd === null) return undefined; + const configured = adapterOptions.environment.CLAUDE_CONFIG_DIR?.trim(); + const configDir = configured ? path.resolve(cwd, configured) : undefined; + const projectsDir = path.join( + configDir ?? path.join(yield* HostProcess.HomeDirectory, ".claude"), + "projects", + ); + // Claude names the project folder after the resolved, possibly hashed, cwd; find the session instead. + let found: string | undefined; + for (const project of yield* fileSystem + .readDirectory(projectsDir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => []))) { + const candidate = path.join(projectsDir, project, context.nativeThreadId, "subagents"); + if ( + yield* fileSystem + .exists(path.join(candidate, `agent-${taskId}.jsonl`)) + .pipe(Effect.orElseSucceed(() => false)) + ) { + found = candidate; + break; + } + } + if (found === undefined) return undefined; + const transcriptDir = found; + let metadata: WorkflowAgentMetadata = {}; + let cursor = initialWorkflowAgentTranscriptCursor; + const read = Effect.fnUntraced(function* () { + while (metadata.messageId === undefined) { + const next = yield* readWorkflowAgentTranscript({ + transcriptDir, + agentId: taskId, + cursor, + ...(configDir === undefined ? {} : { configDir }), + }).pipe(Effect.option); + if (Option.isNone(next)) return false; + metadata = mergeWorkflowAgentMetadata(next.value.lines, metadata); + const advanced = next.value.cursor.offset !== cursor.offset; + cursor = next.value.cursor; + if (!next.value.truncated || !advanced) break; + } + return true; + }); + // A completed subagent can settle before its first reply is flushed; wait out the flush once. + if ((yield* read()) && completed && metadata.messageId === undefined) { + yield* Effect.sleep(WORKFLOW_MEMBER_TRANSCRIPT_FLUSH_MS); + yield* read(); + } + return metadata.effort; + }); + const updateClaudeSubagentNode = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly taskId: string; @@ -7532,8 +7730,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( workflow === undefined ? undefined : withWorkflowMemberThreadIds(workflow, childThreadId); - // Unstated effort stays unknown: a custom agent definition may override the owner's. - const effort = existingSubagent === undefined ? input.effort : existingSubagent.effort; + // Unstated effort stays unknown until the transcript reports it: a custom agent definition may override the owner's. + // Read it before the settle: a run stops consuming events once its subagents settle. + const effort = + existingSubagent === undefined + ? input.effort + : (existingSubagent.effort ?? + (lifecycleChanged && + input.status !== "running" && + existingSubagent.task.modelSelection !== undefined && + (input.nativeTaskType ?? existingSubagent.nativeTaskType) !== + CLAUDE_WORKFLOW_TASK_TYPE + ? yield* readSubagentTranscriptEffort( + input.context, + input.taskId, + input.status === "completed", + ) + : undefined)); const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { @@ -8727,7 +8940,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }); - const finalizeActiveTurn = Effect.fnUntraced(function* (input: { + const finalizeActiveTurn = Effect.fnUntraced(function* (requested: { readonly context: ActiveClaudeTurnContext; readonly status: Extract< OrchestrationV2ProviderTurn["status"], @@ -8738,6 +8951,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly threadDisposition?: "reusable" | "broken"; readonly result?: SDKResultMessage; }) { + // A turn stopped by the retry budget settles as failed with the retry's error. + const retryBudgetFailure = requested.context.retryBudgetFailure; + const input = + requested.status === "interrupted" && retryBudgetFailure !== undefined + ? { ...requested, status: "failed" as const, failure: retryBudgetFailure } + : requested; input.context.steersClosed = true; yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.status === "completed") { @@ -10597,12 +10816,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updatedAt, }), }); + if (previous === undefined) { + yield* failExpiredProviderRetry(context, state.startedAt).pipe( + Effect.delay(PROVIDER_RETRY_BUDGET_MS), + Effect.forkIn(sessionScope), + ); + } return; } if (message.type === "assistant" && input.replayed !== true) { - const now = yield* DateTime.now; - yield* completeProviderRetry(context, now); + if (message.message.model !== "") { + const now = yield* DateTime.now; + yield* completeProviderRetry(context, now); + } // CLI-generated frames report zero usage, not context usage. if ( message.parent_tool_use_id === null && @@ -11325,18 +11552,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( usageLimited && message.subtype === "success" && message.local_command === "compact" ? context.compactionFailure : null; - const resetTimes = Array.from(context.rateLimitResetTimes.values()); - const assistantLimit = context.latestAssistantRateLimit; - if ( - assistantLimit?.type != null && - !context.rateLimitResetTimes.has(assistantLimit.type) - ) { - resetTimes.push(assistantLimit.resetAt); - } - const resetAt = - resetTimes.length > 0 && resetTimes.every((time) => time !== null) - ? resetTimes.reduce((latest, time) => (time! > latest ? time! : latest), "") - : null; const resultFailure = interrupted ? null : limitedCompaction !== null @@ -11351,9 +11566,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( failureHint, usageLimited, ); + const resetTimes = Array.from(context.rateLimitResetTimes.values()); + const assistantLimit = context.latestAssistantRateLimit; + if ( + assistantLimit?.type != null && + !context.rateLimitResetTimes.has(assistantLimit.type) + ) { + resetTimes.push(assistantLimit.resetAt); + } + if (resultFailure?.class === "usage_limit" && resultFailure.resetAt != null) { + resetTimes.push(resultFailure.resetAt); + } + const resetAt = + resetTimes.length > 0 && resetTimes.every((time) => time !== null) + ? resetTimes.reduce((latest, time) => (time! > latest ? time! : latest), "") + : null; const terminalFailure = resultFailure?.class === "usage_limit" - ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } + ? { ...resultFailure, resetAt } : resultFailure; yield* finalizeActiveTurn({ context, @@ -12176,6 +12406,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); let compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); if ( + !turnInput.resume && existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && @@ -12188,6 +12419,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* applySelectionLive(existing, compiledSelection); } if ( + !turnInput.resume && existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && @@ -12321,6 +12553,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( threadId: turnInput.threadId, providerSessionId: input.providerSessionId, options: queryOptions, + ...(turnInput.resume ? { resume: true } : {}), }); // The SDK spawns the CLI per turn, so only the open waits for a provider update. const querySession = yield* ( @@ -12567,27 +12800,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const userMessage = promptUuid === null ? null - : yield* makeClaudeUserMessageWithAttachments({ - text: promptText, - // Mentions in inline handoff history are history, not this turn's skill. - ...(turnInput.inlineContextLength === undefined - ? {} - : { - skillMentionsFrom: - promptText.length - - turnInput.message.text.slice(turnInput.inlineContextLength).trimEnd() - .length, - }), - attachments: turnInput.message.attachments, - attachmentsDir, - fileSystem, - pdfReservation, - ...(skills === undefined ? {} : { skills }), - uuid: promptUuid, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(Path.Path, path), - ); + : turnInput.resume + ? { + type: "user" as const, + message: { role: "user" as const, content: [] }, + parent_tool_use_id: null, + uuid: promptUuid, + } + : yield* makeClaudeUserMessageWithAttachments({ + text: promptText, + // Mentions in inline handoff history are history, not this turn's skill. + ...(turnInput.inlineContextLength === undefined + ? {} + : { + skillMentionsFrom: + promptText.length - + turnInput.message.text.slice(turnInput.inlineContextLength).trimEnd() + .length, + }), + attachments: turnInput.message.attachments, + attachmentsDir, + fileSystem, + pdfReservation, + ...(skills === undefined ? {} : { skills }), + uuid: promptUuid, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(Path.Path, path), + ); const querySession = yield* openQuery(turnInput, nativeThreadId, queryPolicy); const autoCompactWindow = yield* resolveAutoCompactWindow( querySession.settingsCwd, @@ -12643,7 +12883,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( subagentsByTaskId: new Map(), subagentsByToolUseId: new Map(), promptUuid, - promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending", + promptEcho: + isClaudeProviderContinuationTurn(turnInput) || turnInput.resume + ? "confirmed" + : "pending", promptStarted: false, nativeWakeStarted: false, gatedFramesBeforeEcho: 0, @@ -12724,22 +12967,23 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), }); }); - if (userMessage !== null) { + const offerUserMessage = + userMessage === null + ? Effect.void + : startUserTurnForWakeReports(nativeThreadId).pipe( + Effect.andThen( + Effect.uninterruptibleMask((restore) => + restore( + querySession.query.offer(userMessage, () => { + pdfReservation.commit(); + }), + ).pipe(Effect.tap(() => Effect.sync(() => pdfReservation.commit()))), + ), + ), + ); + if (userMessage !== null && !turnInput.resume) { yield* attachTurn; - yield* startUserTurnForWakeReports(nativeThreadId); - yield* Effect.uninterruptibleMask((restore) => - restore( - querySession.query.offer(userMessage, () => { - pdfReservation.commit(); - }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - pdfReservation.commit(); - }), - ), - ), - ); + yield* offerUserMessage; return; } // Transcript reads suspend replay; hold SDK frames until the buffer is applied to preserve order. @@ -12770,6 +13014,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : (yield* Ref.get(absorbedWakes)).has(nativeThreadId); if (!leavesLaterWake) yield* forgetAbsorbedWake(nativeThreadId); if (drained.length === 0) { + if (turnInput.resume) return; const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); return; @@ -12836,7 +13081,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const completedAt = yield* DateTime.now; yield* finalizeActiveTurn({ context, status: "completed", completedAt }); if (leavesLaterWake) yield* offerBufferedWake(nativeThreadId); - } else if (!context.nativeWakeStarted) { + } else if (!context.nativeWakeStarted && !turnInput.resume) { yield* Effect.sleep(CLAUDE_WAKE_TURN_START_DEADLINE).pipe( Effect.andThen( sdkMessageLock.withPermits(1)( @@ -12866,6 +13111,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } }), ); + if (userMessage !== null) { + yield* sdkMessageLock.withPermits(1)( + Effect.gen(function* () { + if ((yield* Ref.get(activeTurn)) === context) yield* offerUserMessage; + }), + ); + } }, (effect, turnInput) => effect.pipe( @@ -13409,6 +13661,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ), ), + resumeTurn: (input) => startTurn(ProviderAdapter.resumeTurnInput(input)), startTurn, compactThread: (turnInput) => startTurn({ diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 9c675ba6b10ef..1cc7886d350de 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1146,6 +1146,7 @@ describe("CodexAdapterV2 session initialize", () => { runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }), initializeRequests: () => initializeRequests, + events: runtime.events, }; }); @@ -1293,6 +1294,89 @@ describe("CodexAdapterV2 session initialize", () => { ), ); + it.effect("fails the open when initialize never answers during open", () => + Effect.gen(function* () { + const initializeAwaitingResponse = yield* Deferred.make(); + const opened = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "initialize-hung-open", + entries: replayPreamble("initialize-hung-open").slice(0, 2), + }), + (entry) => + entry.label === "initialize" + ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( + Effect.andThen(Effect.never), + ) + : Effect.void, + () => Effect.die("initialize never answers"), + ).pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initializeAwaitingResponse); + yield* TestClock.adjust("30 seconds"); + + // The session manager discards a session whose open fails. + assert.instanceOf(yield* Fiber.join(opened), ProviderAdapter.ProviderAdapterOpenSessionError); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("retires the shared session when initialize never answers", () => + Effect.gen(function* () { + const initializeAwaitingResponse = yield* Deferred.make(); + const session = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "initialize-hung", + entries: replayPreamble("initialize-hung").slice(0, 2), + }), + (entry) => + entry.label === "initialize" + ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( + Effect.andThen(Effect.never), + ) + : Effect.void, + ); + const eventsExit = yield* Stream.runDrain(session.events).pipe( + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + + const ensured = yield* session + .ensureThread("thread-initialize-hung") + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initializeAwaitingResponse); + const waiting = yield* session + .ensureThread("thread-initialize-hung-waiting") + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("30 seconds"); + + assert.equal((yield* Fiber.join(ensured))._tag, "ProviderAdapterEnsureThreadError"); + // A waiter fails at once instead of re-sending initialize to the hung app-server. + assert.equal((yield* Fiber.join(waiting))._tag, "ProviderAdapterEnsureThreadError"); + assert.equal(session.initializeRequests(), 1); + // The session manager retires a session whose event stream fails. + const exit = yield* Fiber.join(eventsExit); + assert.isTrue(Exit.isFailure(exit)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("completes the handshake after a caller is interrupted mid-initialize", () => Effect.gen(function* () { const initializeAwaitingResponse = yield* Deferred.make(); @@ -1519,6 +1603,81 @@ describe("CodexAdapterV2 post-settle continuation", () => { }; }); + it.effect.each(["native", "ordinary"] as const)( + "resumes Codex without a prompt only for an existing conversation (%s)", + (mode) => + Effect.gen(function* () { + const nativeThreadId = `resume-${mode}`; + const nativeTurnId = `resume-turn-${mode}`; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "Hello", + }); + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ + scenario: `manual-resume-${mode}`, + entries: [ + ...preamble.map((entry) => + mode === "native" && + entry.type === "expect_outbound" && + Predicate.isObject(entry.frame) && + Predicate.isObject(entry.frame.params) && + entry.frame.method === "turn/start" + ? { + ...entry, + frame: { + ...entry.frame, + params: { + ...entry.frame.params, + input: [], + }, + }, + } + : entry, + ), + { + type: "emit_inbound", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }), + ); + const input = { + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`resume-${mode}`), + text: "Hello", + }), + nativeThreadHasTurns: true, + }; + const { message, ...resumeInput } = input; + if (mode === "native") + yield* harness.runtime.resumeTurn!({ ...resumeInput, messageId: message.messageId }); + else yield* harness.runtime.startTurn(input); + yield* harness.firstTerminal; + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("keeps native image views available to the clients", () => Effect.gen(function* () { const nativeThreadId = "image-view-thread"; @@ -4451,27 +4610,30 @@ describe("CodexAdapterV2 post-settle continuation", () => { const scenario = "codex-provider-retry-stall"; const nativeThreadId = `native-${scenario}-thread`; const nativeTurnId = `native-${scenario}-turn`; + const retryNotice = (attempt: number) => ({ + type: "emit_inbound" as const, + label: `error/retry-${attempt}`, + frame: { + method: "error", + params: { + threadId: nativeThreadId, + turnId: nativeTurnId, + willRetry: true, + error: { + message: `Reconnecting... ${attempt}/5`, + additionalDetails: "exceeded retry limit, last status: 429 Too Many Requests", + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 429 } }, + }, + }, + }, + }); const transcript = makeCodexReplayTranscript({ scenario, entries: [ ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Continue." }), - { - type: "emit_inbound", - label: "error/retry", - frame: { - method: "error", - params: { - threadId: nativeThreadId, - turnId: nativeTurnId, - willRetry: true, - error: { - message: "Reconnecting... 1/5", - additionalDetails: "exceeded retry limit, last status: 429 Too Many Requests", - codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 429 } }, - }, - }, - }, - }, + retryNotice(1), + // A later notice in the same retry window must not push the deadline back. + { ...retryNotice(2), afterMs: 5 * 60 * 1000 }, { type: "expect_outbound", label: "turn/interrupt", @@ -4530,7 +4692,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { terminal.failure.message, "exceeded retry limit, last status: 429 Too Many Requests", ); - assert.equal(terminal.retry?.attempt, 1); + assert.equal(terminal.retry?.attempt, 2); assert.equal(terminal.threadDisposition, "reusable"); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4f1acefe64528..d799c15d46ee3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -145,6 +145,7 @@ import { makeProviderFailure, makeProviderFailureTurnItem, makeProviderRetryTurnItem, + PROVIDER_RETRY_BUDGET_MS, } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { @@ -163,8 +164,6 @@ const CODEX_PROVIDER = ProviderDriverKind.make("codex"); const CODEX_CAPACITY_MAX_RETRIES = 5; // How long Stop waits for Codex to end the turn before the user is offered Force stop. const CODEX_STOP_CONFIRM_TIMEOUT_MS = 10_000; -// Codex can announce a retry and never resume. Recovered retries resume model output within ~75s. -const CODEX_PROVIDER_RETRY_STALL_TIMEOUT = "10 minutes"; export const CODEX_DRIVER_KIND = CODEX_PROVIDER; export const CODEX_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CODEX_DRIVER_KIND); @@ -246,6 +245,8 @@ const decodeCodexBackgroundTerminalTerminateResponse = Schema.decodeUnknownEffec const decodeCodexBackgroundTerminalsListResponse = Schema.decodeUnknownEffect( CodexBackgroundTerminalsListResponse, ); +// Initialize normally answers in under a second; leave room for a slow first launch. +const CODEX_INITIALIZE_TIMEOUT = "30 seconds"; const CodexThreadSampleResponse = Schema.Struct({ text: Schema.NullOr(Schema.String) }); const decodeCodexThreadSampleResponse = Schema.decodeUnknownEffect(CodexThreadSampleResponse); export const CODEX_PROMPT_SUGGESTION_PROMPT = @@ -1937,7 +1938,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), ); + const events = yield* Queue.unbounded< + ProviderAdapter.ProviderAdapterV2Event, + ProviderAdapter.ProviderAdapterEventStreamError + >(); const initialized = yield* Ref.make(false); + // After a timeout the session is retiring; waiters must not re-send to the hung app-server. + const initializeTimeout = yield* Ref.make< + ProviderAdapter.ProviderAdapterEventStreamError | undefined + >(undefined); // Codex rejects a second initialize on the shared app-server. const initializePermit = yield* Semaphore.make(1); const ensureInitialized = initializePermit.withPermit( @@ -1946,6 +1955,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (alreadyInitialized) { return; } + const timedOut = yield* Ref.get(initializeTimeout); + if (timedOut !== undefined) return yield* timedOut; const userAgent = yield* client .request("initialize", { @@ -1961,6 +1972,23 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? Effect.succeed(null) : Effect.fail(error), }), + // A hung app-server never answers, and every thread on this shared + // session waits behind the permit. Failing the event stream retires + // the session, so the next turn spawns a fresh app-server. + Effect.timeoutOrElse({ + duration: CODEX_INITIALIZE_TIMEOUT, + orElse: () => { + const error = new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + cause: `Codex app-server did not answer initialize within ${CODEX_INITIALIZE_TIMEOUT}.`, + }); + return Ref.set(initializeTimeout, error).pipe( + Effect.andThen(Queue.fail(events, error)), + Effect.andThen(Effect.fail(error)), + ); + }, + }), ); const version = userAgent && /\/(\S+)/u.exec(userAgent)?.[1]; const advisory = @@ -1990,10 +2018,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( model: input.modelSelection.model, now, }); - const events = yield* Queue.unbounded< - ProviderAdapter.ProviderAdapterV2Event, - ProviderAdapter.ProviderAdapterEventStreamError - >(); // Drain final notifications before the session manager retires the dead process. // Queue.fail lets readers take items already buffered before they see the failure. yield* client.awaitTermination.pipe( @@ -5156,7 +5180,14 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return updated; }); yield* failStalledProviderRetry(context, state, payload.error.codexErrorInfo).pipe( - Effect.delay(CODEX_PROVIDER_RETRY_STALL_TIMEOUT), + // Every notice aims at the same deadline, so repeated notices cannot extend it. + Effect.delay( + Math.max( + 0, + PROVIDER_RETRY_BUDGET_MS - + (DateTime.toEpochMillis(updatedAt) - DateTime.toEpochMillis(state.startedAt)), + ), + ), Effect.forkIn(scope, { startImmediately: true }), ); yield* emitProviderEvent({ @@ -7852,16 +7883,16 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), ), + resumeTurn: (input) => runtime.startTurn(ProviderAdapter.resumeTurnInput(input)), startTurn: (turnInput) => { - const goalCommand = - turnInput.restartContinuationOfRunId === undefined - ? parseGoalCommand(turnInput.message) - : null; + const continuation = + turnInput.restartContinuationOfRunId !== undefined || turnInput.resume === true; + const goalCommand = !continuation ? parseGoalCommand(turnInput.message) : null; if (goalCommand !== null) return runGoalCommand(turnInput, goalCommand); const nativeThreadId = turnInput.providerThread.nativeThreadRef?.nativeId; return Effect.gen(function* () { if ( - turnInput.restartContinuationOfRunId === undefined && + !continuation && nativeThreadId != null && goalsPausedForWake.has(nativeThreadId) && goalsByNativeThread.get(nativeThreadId)?.status === "paused" @@ -7870,9 +7901,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( } yield* startNativeTurn( turnInput, - turnInput.restartContinuationOfRunId === undefined - ? yield* toCodexInput(turnInput) - : [], + !continuation ? yield* toCodexInput(turnInput) : [], ); }).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e5fedad35270c..73dbb42ca8752 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -523,6 +523,20 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }), ); + it.effect("resumes through OpenCode with an empty native prompt", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + const { message, ...input } = turnInput(thread); + yield* runtime.resumeTurn!({ ...input, messageId: message.messageId }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + it.effect("renames the session before the first turn after the thread was renamed", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -663,11 +677,27 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { const { runtime, thread } = yield* resumed([ out("session.prompt", { sessionID: SESSION, text: "" }), promptAccepted, + event("session.retry.scheduled", { + sessionID: SESSION, + assistantMessageID: "msg_0eb735d5b001oAFVeY5jz3WD4Z", + attempt: 2, + at: 2_001, + error, + }), event("session.execution.failed", { sessionID: SESSION, error }), ]); - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + const collected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.forkScoped, + ); yield* runtime.startTurn(turnInput(thread)); - const ended = yield* Fiber.join(terminal); + const seen = yield* Fiber.join(collected); + assert.deepInclude(errorRows(seen)[0], { + status: "running", + failure: { class: failureClass, code: error.type, message: error.message, retryable: true }, + }); + const ended = seen.find((event) => event.type === "turn.terminal"); assert.equal(ended?.status, "failed"); assert.deepEqual(ended?.failure, { class: failureClass, @@ -5465,7 +5495,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { error: rateLimited, }); const retrying = { - class: "provider_error", + class: "usage_limit", message: rateLimited.message, code: rateLimited.type, retryable: true, @@ -5647,7 +5677,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ], gate, ); - const failed = { ...retrying, class: "usage_limit" as const, retryable: null }; + const failed = { ...retrying, retryable: null }; const lastRetry = { attempt: 2, maxAttempts: null, retryDelayMs: 4_000 }; const rows = errorRows(seen); assert.deepEqual( diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts index 3d7c859fe0ada..442726379df5c 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.test.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.test.ts @@ -48,6 +48,7 @@ const modelSelection = { model: "gpt-5.4", } satisfies ModelSelection; const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); const ITEM_ID = TurnItemId.make("turn-item:command"); function applyFrame( @@ -109,12 +110,15 @@ const writeCommandItem = ( fields: Pick< Extract, "status" | "output" | "exitCode" - >, + > & { + readonly driver?: ProviderDriverKind; + readonly startedAt?: DateTime.Utc; + }, ) => Effect.gen(function* () { const idAllocator = yield* IdAllocator.IdAllocatorV2; const eventSink = yield* EventSink.EventSinkV2; - const now = yield* DateTime.now; + const { driver = CODEX_DRIVER, startedAt = yield* DateTime.now, ...itemFields } = fields; const settled = fields.status !== "running"; const item: OrchestrationV2TurnItem = { id: ITEM_ID, @@ -123,16 +127,16 @@ const writeCommandItem = ( nodeId: null, providerThreadId: null, providerTurnId: null, - nativeItemRef: null, + nativeItemRef: { driver, nativeId: "tool-1", strength: "strong" }, parentItemId: null, ordinal: 1, title: null, - startedAt: now, - completedAt: settled ? now : null, - updatedAt: now, + startedAt, + completedAt: settled ? startedAt : null, + updatedAt: startedAt, type: "command_execution", input: "yes | head -n 1000000", - ...fields, + ...itemFields, }; yield* eventSink.write({ events: [ @@ -140,9 +144,9 @@ const writeCommandItem = ( id: yield* idAllocator.allocate.event({ threadId }), type: "turn-item.updated", threadId, - driver: CODEX_DRIVER, + driver, providerInstanceId: modelSelection.instanceId, - occurredAt: now, + occurredAt: startedAt, payload: item, }, ], @@ -294,13 +298,15 @@ layer("CommandOutputHub", (it) => { }), ); - it.effect("an old Claude row's raw Bash result shows as its stdout and stderr", () => + it.effect("an early Claude row's raw Bash result shows as its stdout and stderr", () => Effect.gen(function* () { const threadId = yield* seedThread; yield* writeCommandItem(threadId, { status: "completed", exitCode: 0, output: '{"stdout":"built","stderr":"1 warning","interrupted":false}', + driver: CLAUDE_DRIVER, + startedAt: DateTime.makeUnsafe("2026-10-03T12:00:00Z"), }); const frames = yield* subscribeFrames(threadId); const final = yield* takeFrame(frames); diff --git a/apps/server/src/orchestration-v2/CommandOutputHub.ts b/apps/server/src/orchestration-v2/CommandOutputHub.ts index c3f97eff335a8..b6b5b634c179c 100644 --- a/apps/server/src/orchestration-v2/CommandOutputHub.ts +++ b/apps/server/src/orchestration-v2/CommandOutputHub.ts @@ -13,12 +13,12 @@ import { TERMINAL_OUTPUT_TAIL_CHARS, terminalOutputResumeText, } from "@t3tools/shared/terminalOutput"; +import { commandItemOutput } from "@t3tools/shared/commandOutput"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -69,21 +69,6 @@ function commandKey(target: CommandOutputTarget): string { return `${target.threadId}\u0000${target.itemId}`; } -const decodeLegacyClaudeBashResult = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ stdout: Schema.String, stderr: Schema.String, interrupted: Schema.Boolean }), - ), -); - -function legacyClaudeBashOutput(output: string): string { - if (!output.trimStart().startsWith('{"stdout"')) return output; - const parsed = decodeLegacyClaudeBashResult(output); - if (Option.isNone(parsed)) return output; - const out = parsed.value.stdout.trim().length > 0 ? parsed.value.stdout : ""; - const err = parsed.value.stderr.trim().length > 0 ? parsed.value.stderr : ""; - return out.length > 0 && err.length > 0 && !out.endsWith("\n") ? `${out}\n${err}` : out + err; -} - function persistedCommandOutput(output: string | undefined): { readonly text: string; readonly truncated: boolean; @@ -269,8 +254,11 @@ export const make = Effect.gen(function* () { running: false, }; } - const output = settled.output && legacyClaudeBashOutput(settled.output); - return { kind: "replace", ...persistedCommandOutput(output), running: false }; + return { + kind: "replace", + ...persistedCommandOutput(commandItemOutput(settled)), + running: false, + }; } if (subscriber.needsReplace || tail === undefined) { if (subscriber.overflowed) throttle = COMMAND_OUTPUT_REPLACE_INTERVAL_MS; diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index d263a146a50fc..227fc531602fc 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -151,6 +151,10 @@ describe("handoff budget", () => { })?.text, "File change: src/a.ts, src/b.ts, src/new.ts", ); + assert.equal( + historicalMessage({ ...base, type: "file_change", fileName: "src/a.ts", changes: [] })?.text, + "File change: src/a.ts", + ); assert.isNull( historicalMessage({ ...base, diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index c9022ee76a807..ca6b5ff0fcdf1 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -215,7 +215,7 @@ function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { case "command_execution": return `- Command: ${compactText(item.input)}`; case "file_change": - return `- File change: ${compactText(item.changes?.map((change) => change.path).join(", ") ?? item.fileName)}`; + return `- File change: ${compactText(item.changes?.length ? item.changes.map((change) => change.path).join(", ") : item.fileName)}`; case "checkpoint": return `- Checkpoint: ${item.files.length} files`; case "handoff": diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index eb1209158cc74..0a250bb2b865c 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -117,6 +117,7 @@ export const OrchestrationEffectRequestV2 = Schema.Union([ Schema.Struct({ type: Schema.Literal("attachment.cleanup"), attachmentIds: Schema.Array(Schema.String), + skipReferenced: Schema.optional(Schema.Boolean), }), Schema.Struct({ type: Schema.Literal("thread-title.generate"), diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index cc43601543eec..fd598e95ec11a 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -16,17 +16,22 @@ import { import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as SqlError from "effect/sql/SqlError"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as CheckpointService from "./CheckpointService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import * as EffectWorker from "./EffectWorker.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; @@ -164,6 +169,7 @@ function layerExecutorFor(input: { Layer.mergeAll( layerDependencies, Layer.mock(ThreadManagementService.ThreadManagementService)(input.threads ?? {}), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), ServerSettings.layerTest( input.continueAfterRestart === true ? { continueThreadsAfterServerUpdate: true } : {}, ), @@ -396,6 +402,138 @@ it.effect("requeues a claim when a pre-execution worker check fails", () => }), ); +it.effect("keeps requeueing a failed claim while the database is locked", () => + Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); + const effectId = "effect:worker-requeue-lock-timeout"; + const workerId = "worker-requeue-lock-timeout"; + const claimedEffect: EffectOutbox.OrchestrationEffectV2 = { + id: effectId, + commandId: CommandId.make("command:worker-requeue-lock-timeout"), + threadId, + request: { type: "provider-runtime.continue", sourceRunId: runId }, + status: "running", + attemptCount: 1, + availableAt: now, + leaseOwner: workerId, + createdAt: now, + updatedAt: now, + completedAt: null, + lastError: null, + }; + const lockedWrites = yield* Ref.make(3); + const requeued = yield* Ref.make(false); + const firstLockTimeout = yield* Deferred.make(); + const layerOutbox = Layer.mock(EffectOutbox.EffectOutboxV2)({ + claimNext: () => Effect.succeedSome(claimedEffect), + get: () => Effect.succeedSome(claimedEffect), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + retry: () => + Effect.gen(function* () { + if ((yield* Ref.getAndUpdate(lockedWrites, (count) => count - 1)) > 0) { + yield* Deferred.succeed(firstLockTimeout, undefined); + return yield* new EffectOutbox.EffectOutboxError({ + operation: "retry", + effectId, + cause: new SqlError.SqlError({ + reason: new SqlError.LockTimeoutError({ cause: null }), + }), + }); + } + yield* Ref.set(requeued, true); + return true; + }), + }); + const layerExecutor = Layer.succeed( + EffectWorker.OrchestrationEffectExecutorV2, + EffectWorker.OrchestrationEffectExecutorV2.of({ + execute: () => + Effect.fail( + new EffectWorker.OrchestrationEffectExecutionError({ + effectId, + effectType: "provider-runtime.continue", + cause: "simulated database is locked", + }), + ), + }), + ); + const layerWorker = EffectWorker.layerWithOptions({ workerId }).pipe( + Layer.provide(Layer.merge(layerOutbox, layerExecutor)), + ); + + const workerFiber = yield* EffectWorker.OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce()), + Effect.provide(layerWorker), + Effect.forkChild, + ); + yield* Deferred.await(firstLockTimeout); + yield* TestClock.adjust(Duration.millis(99)); + assert.isFalse(yield* Ref.get(requeued)); + yield* TestClock.adjust(Duration.seconds(1)); + yield* Effect.exit(Fiber.join(workerFiber)); + + assert.isTrue(yield* Ref.get(requeued)); + }), +); + +it.effect.each([ + { name: "requeue", request: { type: "provider-runtime.continue", sourceRunId: runId } }, + { name: "terminalize", request: { type: "provider-turn.start", runId } }, +] as const)("interrupts $name settlement retries when the worker shuts down", ({ request }) => + Effect.gen(function* () { + const claimedEffect: EffectOutbox.OrchestrationEffectV2 = { + ...restartEffect(yield* DateTime.now, { type: "detach" }), + request, + }; + const settlementStarted = yield* Deferred.make(); + const releaseLock = yield* Ref.make(false); + const lockedWrite = Effect.gen(function* () { + yield* Deferred.succeed(settlementStarted, undefined); + // An in-flight database write can finish its own wait before noticing shutdown. + yield* Effect.sleep(Duration.millis(1)); + if (yield* Ref.get(releaseLock)) return true; + return yield* new EffectOutbox.EffectOutboxError({ + operation: "settlement", + effectId: claimedEffect.id, + cause: new SqlError.SqlError({ reason: new SqlError.LockTimeoutError({ cause: null }) }), + }); + }); + const layerOutbox = Layer.mock(EffectOutbox.EffectOutboxV2)({ + claimNext: () => Effect.succeedSome(claimedEffect), + get: () => Effect.succeedSome(claimedEffect), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + succeed: () => Effect.fail(new EffectOutbox.EffectOutboxError({ operation: "succeed" })), + retry: () => lockedWrite, + fail: () => lockedWrite, + }); + const layerExecutor = Layer.succeed( + EffectWorker.OrchestrationEffectExecutorV2, + EffectWorker.OrchestrationEffectExecutorV2.of({ execute: () => Effect.void }), + ); + const layerWorker = EffectWorker.layerWithOptions({ workerId: "test-worker" }).pipe( + Layer.provide(Layer.merge(layerOutbox, layerExecutor)), + ); + + const daemon = yield* EffectWorker.runDaemonWithOptions({ concurrency: 1 }).pipe( + Effect.provide(layerWorker), + Effect.forkChild, + ); + yield* Deferred.await(settlementStarted); + const shutdown = yield* Fiber.interrupt(daemon).pipe( + Effect.timeoutOption(Duration.millis(100)), + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.millis(100)); + const stopped = yield* Fiber.join(shutdown); + yield* Ref.set(releaseLock, true); + yield* TestClock.adjust(Duration.seconds(1)); + yield* Fiber.await(daemon); + assert.isTrue(Option.isSome(stopped)); + }), +); + it.effect("arms cancellation before the durable pre-execution check", () => Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 9dd5079805745..19a387eb8a954 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -9,7 +9,9 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as SqlError from "effect/sql/SqlError"; import { increment, @@ -20,6 +22,7 @@ import { import * as RunFinalizationService from "./RunFinalizationService.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; @@ -82,6 +85,7 @@ export const layerExecutor: Layer.Layer< | RuntimeRequestService.RuntimeRequestServiceV2 | ThreadTitleRegenerationService.ThreadTitleRegenerationService | ThreadManagementService.ThreadManagementService + | ProjectionStore.ProjectionStoreV2 | ServerSettings.ServerSettingsService > = Layer.effect( OrchestrationEffectExecutorV2, @@ -96,6 +100,7 @@ export const layerExecutor: Layer.Layer< const threadTitleRegeneration = yield* ThreadTitleRegenerationService.ThreadTitleRegenerationService; const threads = yield* ThreadManagementService.ThreadManagementService; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const settings = yield* ServerSettings.ServerSettingsService; return OrchestrationEffectExecutorV2.of({ execute: Effect.fn("OrchestrationEffectExecutorV2.execute")(function (effect, options) { @@ -582,8 +587,21 @@ export const layerExecutor: Layer.Layer< }), ), ); - case "attachment.cleanup": - return resourceCleanup.cleanupAttachments(effect.request.attachmentIds).pipe( + case "attachment.cleanup": { + const request = effect.request; + return Effect.gen(function* () { + // Another message or answer may share the file, or a stale queued edit may restore it. + const referenced = new Set( + request.skipReferenced + ? yield* projectionStore.getThreadAttachmentIds(effect.threadId, { + includeShownByLiveFork: true, + }) + : [], + ); + yield* resourceCleanup.cleanupAttachments( + request.attachmentIds.filter((id) => !referenced.has(id)), + ); + }).pipe( Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ @@ -593,6 +611,7 @@ export const layerExecutor: Layer.Layer< }), ), ); + } case "thread-title.generate": return threadTitleRegeneration .execute({ @@ -667,6 +686,16 @@ export class OrchestrationEffectWorkerError extends Schema.TaggedError + SqlError.isSqlError(error.cause) && error.cause.reason._tag === "LockTimeoutError"; +const lockRetrySchedule = Schedule.spaced(Duration.millis(100)); +const retryWhileLocked = (settlement: Effect.Effect) => + Effect.interruptible( + Effect.retry(settlement, { schedule: lockRetrySchedule, while: isLockTimeout }), + ); + type ClaimFilter = Omit[0], "workerId">; export interface OrchestrationEffectWorkerV2Shape { @@ -728,6 +757,7 @@ export const layerWithOptions = ( delayMs: 0, }) .pipe( + retryWhileLocked, Effect.flatMap((requeued) => requeued ? Effect.logWarning("Requeued effect after unexpected worker failure", { @@ -759,6 +789,7 @@ export const layerWithOptions = ( error: `Worker failed to settle a process-bound effect after execution started: ${Cause.pretty(cause)}`, }) .pipe( + retryWhileLocked, Effect.flatMap((failed) => failed ? Effect.logError("Terminalized process-bound effect after settlement failure", { diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 57f9db15d4247..4f502c55c2804 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -3,6 +3,7 @@ import { type OrchestrationV2Run, OrchestrationV2DomainEvent, type OrchestrationV2ProviderThread, + type OrchestrationV2Subagent, type OrchestrationV2TurnItem, OrchestrationV2StoredEvent, ProviderThreadId, @@ -181,6 +182,17 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; + /** Commits only if the stored subagent still equals expected. */ + readonly writeIfSubagentUnchanged: (input: { + readonly expected: OrchestrationV2Subagent; + readonly events: ReadonlyArray; + }) => Effect.Effect< + { + readonly committed: boolean; + readonly storedEvents: ReadonlyArray; + }, + EventSinkV2Error + >; readonly commitCommand: (input: { readonly commandId: CommandId; readonly threadId: ThreadId; @@ -713,6 +725,26 @@ const layerBase: Layer.Layer< ); }); + const writeIfSubagentUnchangedEffect = Effect.fn( + "orchestrationV2.EventSink.writeIfSubagentUnchanged", + )(function* (input: Parameters[0]) { + yield* Effect.annotateCurrentSpan({ + "orchestration_v2.event_count": input.events.length, + "orchestration_v2.subagent_id": input.expected.id, + }); + const expectedJson = yield* ProjectionStore.encodeSubagentPayload(input.expected); + return yield* writeIfStoredPayloadMatches( + sql<{ readonly payload_json: string }>` + SELECT payload_json + FROM orchestration_v2_projection_subagents + WHERE subagent_id = ${input.expected.id} + LIMIT 1 + `, + expectedJson, + input.events, + ); + }); + const existingCommandResult = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ Effect.fnUntraced(function* (commandId: CommandId) { @@ -1119,6 +1151,12 @@ const layerBase: Layer.Layer< (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }), ), ), + writeIfSubagentUnchanged: (input) => + writeIfSubagentUnchangedEffect(input).pipe( + Effect.mapError( + (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }), + ), + ), commitCommand: (input) => commitCommandEffect(input).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index f36ce5bdc564b..2845860efac20 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1254,6 +1254,7 @@ it.effect("keeps delegated child pull-request links independent of the parent", creationSource: "mcp", }); const updatedParent = yield* projections.getThreadProjection(parentThreadId); + assert.deepEqual(updatedParent.subagents[0]!.modelSelection, modelSelection); const childThreadId = updatedParent.subagents[0]!.childThreadId!; const child = yield* projections.getThreadProjection(childThreadId); assert.isNull(child.thread.linkedPullRequest); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 391379fc6b649..a93561149ebfb 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -802,6 +802,18 @@ function cancelledAppOwnedTask( }; } +function askedForLinkedMachine( + card: Extract, + linked: Extract, +): boolean { + if (card.requestedAccess !== linked.requestedAccess) return false; + if (card.environmentId !== undefined) return card.environmentId === linked.linkedEnvironmentId; + if (card.url !== undefined) return linked.url !== undefined && card.url === linked.url; + const normalize = (text: string | undefined) => text?.trim().toLowerCase() ?? ""; + const hint = normalize(card.hint); + return hint !== "" && hint === normalize(linked.hint) && hint === normalize(linked.linkedLabel); +} + // A workspace change detaches the session, which can settle the calling run at once. Queuing its // follow-up in a later command would let a delegated task report its result before that exists. function metadataUpdateContinuation( @@ -822,6 +834,28 @@ function metadataUpdateContinuation( }; } +// A later command for the first message would leave the new thread committed with no run, +// and every client would show it idle until that command landed. +function threadCreateInitialMessage( + command: Extract, +): Extract | undefined { + if (command.initialMessage === undefined) return undefined; + const { workspaceStrategy, ...message } = command.initialMessage; + return { + ...message, + type: "message.dispatch", + commandId: command.commandId, + threadId: command.threadId, + modelSelection: command.modelSelection, + dispatchMode: + workspaceStrategy === undefined + ? { type: "start_immediately" } + : { type: "defer_start", workspaceStrategy }, + createdBy: command.createdBy, + creationSource: command.creationSource, + }; +} + // Provider wakes can start follow-up work; monitor updates only report progress. // Dispatch takes the parent's lock for follow-ups because intake writes the parent. function mayStartSubagentFollowup( @@ -4566,10 +4600,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "The reset time has passed. Retry the thread manually.", }); } + if (thread.settledOverride === "settled") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The thread is settled. Un-settle it to resume at the reset.", + }); + } if ( !Number.isFinite(resetMs) || thread.archivedAt !== null || - thread.settledOverride === "settled" || run?.id !== command.limitRecovery.runId || failure?.class !== "usage_limit" || failure.resetAt !== command.limitRecovery.resetAt || @@ -4689,6 +4729,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), + // Settled threads refuse recovery, so an armed resume would stay hidden until un-settled. + limitRecovery: thread.limitRecovery + ? { ...thread.limitRecovery, autoResume: false, snooze: false } + : thread.limitRecovery, pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), unsettledAt: null, pinnedAt: null, @@ -7009,6 +7053,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? projectionStore .getThreadRecords(command.threadId, ["messages"], { messageRoles: ["user"] }) .pipe( + // A thread this command creates has no stored messages yet. + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed({ messages: [] }), + }), Effect.map(({ messages }) => [...messages, ...userMessages]), Effect.map((all) => all.length > 0 && all.every(isNativeMaintenanceCommand)), mapDispatchError(command), @@ -7273,10 +7321,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } } + const notification = + command.manualContinuationOfRunId === undefined + ? command.notification + : { + source: { kind: "system" as const }, + outcome: "updated" as const, + summary: "Resumed thread", + }; const dispatchText = - delegatedCompletion === undefined - ? command.text - : delegatedCompletionWakeDetail(delegatedCompletion.taskIds, projection.subagents); + command.manualContinuationOfRunId !== undefined + ? "" + : delegatedCompletion === undefined + ? command.text + : delegatedCompletionWakeDetail(delegatedCompletion.taskIds, projection.subagents); const sourcePlanProjection = command.sourcePlanRef === undefined ? null @@ -7365,12 +7423,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio effects, projection, modelSelection: - delegatedCompletion === undefined && command.notification === undefined + delegatedCompletion === undefined && notification === undefined ? modelSelection : (projection.runs.find((run) => run.id === dispatchMode.targetRunId)?.modelSelection ?? modelSelection), delegatedCompletion, - ...(command.notification === undefined ? {} : { notification: command.notification }), + ...(notification === undefined ? {} : { notification }), targetRunId: dispatchMode.targetRunId, messageId: command.messageId, text: dispatchText, @@ -7644,6 +7702,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(command.manualContinuationOfRunId === undefined + ? {} + : { manualContinuationOfRunId: command.manualContinuationOfRunId }), ...(command.usageLimitContinuationOfRunId === undefined ? {} : { usageLimitContinuationOfRunId: command.usageLimitContinuationOfRunId }), @@ -7704,7 +7765,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio createdAt: now, updatedAt: now, ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), - ...(command.notification === undefined ? {} : { notification: command.notification }), + ...(notification === undefined ? {} : { notification }), }; const emitEvent = emit(events, command); if (targetProviderThread === undefined) { @@ -8055,6 +8116,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(command.manualContinuationOfRunId === undefined + ? {} + : { manualContinuationOfRunId: command.manualContinuationOfRunId }), ...(command.usageLimitContinuationOfRunId === undefined ? {} : { usageLimitContinuationOfRunId: command.usageLimitContinuationOfRunId }), @@ -8119,7 +8183,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio createdAt: now, updatedAt: now, ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), - ...(command.notification === undefined ? {} : { notification: command.notification }), + ...(notification === undefined ? {} : { notification }), }; const turnItem: OrchestrationV2TurnItem = { createdBy: command.createdBy, @@ -8850,6 +8914,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.restartContinuationOfRunId === undefined ? {} : { restartContinuationOfRunId: command.restartContinuationOfRunId }), + ...(command.manualContinuationOfRunId === undefined + ? {} + : { manualContinuationOfRunId: command.manualContinuationOfRunId }), ...(command.usageLimitContinuationOfRunId === undefined ? {} : { usageLimitContinuationOfRunId: command.usageLimitContinuationOfRunId }), @@ -8907,7 +8974,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio createdAt: now, updatedAt: now, ...(delegatedCompletion === undefined ? {} : { delegatedCompletion }), - ...(command.notification === undefined ? {} : { notification: command.notification }), + ...(notification === undefined ? {} : { notification }), }; const turnItem: OrchestrationV2TurnItem = { createdBy: command.createdBy, @@ -9446,6 +9513,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio prompt: command.task, title: command.title ?? null, model: command.modelSelection.model, + modelSelection: command.modelSelection, ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }), status: "running", result: null, @@ -9909,6 +9977,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio prompt: command.task, title: command.title ?? null, model: command.modelSelection.model, + modelSelection: command.modelSelection, ...(command.completionWake === undefined ? {} : { completionWake: command.completionWake }), status: "running", result: null, @@ -10580,6 +10649,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: _type, commandId: _commandId, turnItemId: _turnItemId, + answeringTurnItemIds, wake, ...fields } = command; @@ -10639,6 +10709,39 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio occurredAt: now, payload: turnItem, }); + // The link answers the older cards that asked for the same machine and + // access. They end without waking anyone: the agent hears this link. + // A card still being answered stays open for its own answer to end. + if (!unchanged && command.linkStatus === "linked") { + for (const item of projection.turnItems) { + if ( + item.type !== "link_request" || + item.linkStatus !== "pending" || + item.ordinal >= turnItem.ordinal || + answeringTurnItemIds?.includes(item.id) === true || + !askedForLinkedMachine(item, command) + ) { + continue; + } + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + ...(item.runId === null ? {} : { runId: item.runId }), + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + occurredAt: now, + payload: { + ...item, + status: "cancelled", + linkStatus: "cancelled", + completedAt: now, + updatedAt: now, + }, + }); + } + } // The agent asked without waiting, so the answer reaches it as a // notification: it starts a turn, or follows the one running. Only the // answer that closes the card wakes it, and never a closed thread. @@ -11589,6 +11692,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchQueuedRunEdit = Effect.fn("Orchestrator.dispatchQueuedRunEdit")(function* ( command: Extract, events: Ref.Ref>, + effects: Ref.Ref>, ) { if (command.text.trim().length === 0) { return yield* new OrchestratorDispatchError({ @@ -11682,6 +11786,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), }); } + // Thread deletion only cleans files the thread still references. + const keptAttachmentIds = new Set( + (command.attachments ?? queuedMessage.attachments).map((attachment) => attachment.id), + ); + const droppedAttachmentIds = queuedMessage.attachments + .map((attachment) => attachment.id) + .filter((id) => !keptAttachmentIds.has(id)); + if (droppedAttachmentIds.length > 0) { + yield* Ref.update(effects, (existing) => [ + ...existing, + { + id: `effect:${command.commandId}:attachment.cleanup`, + commandId: command.commandId, + threadId: command.threadId, + request: { + type: "attachment.cleanup", + attachmentIds: droppedAttachmentIds, + skipReferenced: true, + }, + } satisfies PendingOrchestrationEffectV2, + ]); + } }); const subagentCount = (threadId: ThreadId) => @@ -14877,9 +15003,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | undefined; let updateDelegatedCompletionOnlyRunId: RunId | undefined; switch (command.type) { - case "thread.create": + case "thread.create": { yield* dispatchThreadCreate(command, events); + const initialMessage = threadCreateInitialMessage(command); + if (initialMessage !== undefined) { + cancelUnsettledEffects = yield* dispatchMessage( + withoutGuestAttribution(initialMessage), + events, + effects, + ); + } break; + } case "thread.visit": yield* dispatchThreadVisit(command, events); break; @@ -15201,7 +15336,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* dispatchQueuedRunCancel(command, events); break; case "queued-run.edit": - yield* dispatchQueuedRunEdit(command, events); + yield* dispatchQueuedRunEdit(command, events, effects); break; case "checkpoint.rollback": yield* dispatchCheckpointRollback(command, events, effects); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 8b967f648165f..50f2a0a12d1ae 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -10562,6 +10562,26 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { ["local", targetThreadId, "command_execution"], ], ); + + const sourceThread = (yield* projectionStore.getThreadProjection(sourceThreadId)).thread; + yield* projectionStore.apply({ + id: EventId.make("event:projection-fork-source-rollback:source-deleted"), + type: "thread.deleted", + threadId: sourceThreadId, + occurredAt: now, + payload: { ...sourceThread, deletedAt: now }, + }); + const afterSourceDeleted = yield* projectionStore.getShellSnapshot(); + assert.isUndefined(afterSourceDeleted.threads.find((thread) => thread.id === sourceThreadId)); + for (const forkId of [targetThreadId, nestedThreadId]) { + const expected = (yield* projectionStore.getThreadProjection(forkId)).visibleTurnItems + .length; + assert.equal((yield* projectionStore.getThreadShell(forkId))?.visibleItemCount, expected); + assert.equal( + afterSourceDeleted.threads.find((thread) => thread.id === forkId)?.visibleItemCount, + expected, + ); + } }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3bdbbffc0373c..431426270f228 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5,6 +5,7 @@ import { projectTurnItemForWire } from "./WireProjection.ts"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { makeThreadFind } from "./ThreadFind.ts"; +import { unsentManualContinuationSource } from "./manualContinuation.ts"; import type { OrchestrationV2ThreadLinkMetadata, OrchestrationV2SearchThreadInput, @@ -50,6 +51,7 @@ import type { import { IDLE_COMPACTION_TEXT, idleCompactionOf, + isQueuedProviderThreadPlaceholder, OrchestrationV2AppThreadJson as OrchestrationV2AppThreadJsonSchema, OrchestrationV2CheckpointJson as OrchestrationV2CheckpointJsonSchema, OrchestrationV2CheckpointScopeJson as OrchestrationV2CheckpointScopeJsonSchema, @@ -499,8 +501,10 @@ export interface ProjectionStoreV2Shape { readonly searchThread: ( input: OrchestrationV2SearchThreadInput, ) => Effect.Effect; + /** Files thread deletion may remove; `includeShownByLiveFork` adds ones a live fork shows. */ readonly getThreadAttachmentIds: ( threadId: ThreadId, + options?: { readonly includeShownByLiveFork?: boolean }, ) => Effect.Effect, ProjectionStoreV2Error>; readonly getThreadHistoryPage: ( threadId: ThreadId, @@ -879,15 +883,6 @@ export function emptyProjection( }; } -function isQueuedProviderThreadPlaceholder(providerThread: OrchestrationV2ProviderThread): boolean { - return ( - providerThread.status === "not_loaded" && - providerThread.firstRunOrdinal === null && - providerThread.nativeThreadRef === null && - providerThread.providerSessionId === null - ); -} - export function applyToProjection( projection: OrchestrationV2ThreadProjection, event: OrchestrationV2DomainEvent, @@ -1088,6 +1083,9 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & | "creationSource" | "latestRunCompletedAt" | "archivedAt" + | "snoozedUntil" + | "snoozedAt" + | "pendingGuestMessageCount" | "activeRunId" | "latestRunId" | "hasActionableProposedPlan" @@ -1204,7 +1202,7 @@ const encodeRunAttemptPayload = Schema.encodeEffect( const encodeNodePayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2ExecutionNodeJsonSchema), ); -const encodeSubagentPayload = Schema.encodeEffect( +export const encodeSubagentPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2SubagentJsonSchema), ); const encodeProviderSessionPayload = Schema.encodeEffect( @@ -1530,7 +1528,7 @@ function isRolledBackBeforeFork( return rolledBackAt !== null && DateTime.isLessThan(rolledBackAt, forkCreatedAt); } -function forkInheritedRunOrdinals( +export function forkInheritedRunOrdinals( runs: ReadonlyArray, forkCreatedAt: DateTime.Utc, ): Map { @@ -5128,11 +5126,19 @@ export const layer: Layer.Layer = WHERE bindings.thread_id = ${threadId} ORDER BY sessions.updated_at ASC, sessions.provider_session_id ASC `.pipe(Effect.flatMap(decodeRows(decodeProviderSessionPayload, threadId))); + const run = runs.find((source) => source.id === runId); + const unsentSource = unsentManualContinuationSource(run, { + runs, + attempts, + providerTurns, + }); + const messageIds = [run, unsentSource].flatMap((source) => + source === undefined ? [] : [source.userMessageId], + ); const messages = yield* sql` SELECT payload_json FROM orchestration_v2_projection_messages WHERE thread_id = ${threadId} AND ( - message_id IN (SELECT json_extract(payload_json, '$.userMessageId') FROM orchestration_v2_projection_runs - WHERE thread_id = ${threadId} AND run_id = ${runId}) + message_id IN ${sql.in(messageIds)} OR (role = 'user' AND lower(trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace})) = '/compact') -- Earlier /goal turns defer context too; parseGoalCommand narrows this prefix match. OR (role = 'user' AND trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace}) GLOB '/goal*') @@ -6251,14 +6257,27 @@ export const layer: Layer.Layer = ) `; - const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) => + // Thread deletion keeps what a live fork still shows; a reference check must count it. + const exceptShownByLiveFork = ( + threadId: ThreadId, + runIdColumn: string, + options: { readonly includeShownByLiveFork?: boolean } | undefined, + ) => + options?.includeShownByLiveFork === true + ? sql`` + : sql`AND NOT ${shownByLiveFork(threadId, runIdColumn)}`; + + const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = ( + threadId, + options, + ) => Effect.all([ sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id FROM orchestration_v2_projection_messages AS message, json_each(message.payload_json, '$.attachments') AS attachment WHERE message.thread_id = ${threadId} - AND NOT ${shownByLiveFork(threadId, "message.run_id")} + ${exceptShownByLiveFork(threadId, "message.run_id", options)} `, sql<{ payload_json: string }>` SELECT item.payload_json @@ -6268,7 +6287,7 @@ export const layer: Layer.Layer = AND (item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} OR item.payload_json LIKE '%preview_recording_stop%') - AND NOT ${shownByLiveFork(threadId, "item.run_id")} + ${exceptShownByLiveFork(threadId, "item.run_id", options)} `, sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id @@ -6277,7 +6296,7 @@ export const layer: Layer.Layer = json_each(question.value) AS attachment WHERE item.thread_id = ${threadId} AND item.type = 'user_input_request' - AND NOT ${shownByLiveFork(threadId, "item.run_id")} + ${exceptShownByLiveFork(threadId, "item.run_id", options)} `, // A guest's waiting files are pending uploads that no message row holds yet. sql<{ id: string }>` @@ -7651,7 +7670,12 @@ export const layer: Layer.Layer = worktreePath, threadIds, listedOnly = false, - }: ShellSnapshotOptions & { readonly listedOnly?: boolean } = {}, + includeDeleted = false, + }: ShellSnapshotOptions & { + readonly listedOnly?: boolean; + // A retained fork still shows the history of a deleted source run. + readonly includeDeleted?: boolean; + } = {}, ) => sql` SELECT @@ -7830,7 +7854,7 @@ export const layer: Layer.Layer = candidate.ordinal DESC, candidate.run_id DESC LIMIT 1 ) AND blocked.status = 'failed' - WHERE t.deleted_at IS NULL${ + WHERE ${includeDeleted ? sql`1 = 1` : sql`t.deleted_at IS NULL`}${ listedOnly ? sql` AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1` @@ -8487,7 +8511,7 @@ export const layer: Layer.Layer = while (pendingSourceIds.length > 0) { const sourceId = pendingSourceIds.pop(); if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; - const source = (yield* selectShellThreadRows(sourceId))[0]; + const source = (yield* selectShellThreadRows(sourceId, { includeDeleted: true }))[0]; if (source === undefined) continue; rowsByThreadId.set(sourceId, source); if (source.forked_from_run_source_thread_id !== null) { @@ -8679,6 +8703,9 @@ export const layer: Layer.Layer = projectId: state.thread.projectId, creationSource: state.thread.creationSource, archivedAt: state.thread.archivedAt, + snoozedUntil: state.thread.snoozedUntil ?? null, + snoozedAt: state.thread.snoozedAt ?? null, + pendingGuestMessageCount: pendingGuestMessageCount(state.thread.guestShare), activeRunId: state.activeRunId, latestRunId: state.latestRunId, hasActionableProposedPlan: state.hasActionableProposedPlan, @@ -8967,7 +8994,9 @@ export const layer: Layer.Layer = if (nextId === undefined || rowsByThreadId.has(nextId)) { continue; } - const rows = yield* selectShellThreadRows(nextId); + const rows = yield* selectShellThreadRows(nextId, { + includeDeleted: nextId !== threadId, + }); const row = rows[0]; if (row === undefined) { continue; diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 5740faf8f8757..09441e2659166 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3309,7 +3309,53 @@ const openSessionWithBackgroundCommand = (fixtureName: string) => }); }); yield* Effect.forEach(Object.values(seededToolCalls), writeToolCall); + // A background native subagent that outlived its completed run; Claude records no provider thread on it. + const nativeSubagentRunId = idAllocator.derive.run({ threadId, ordinal: 1 }); + const nativeSubagentId = idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_DRIVER, + nativeItemId: "background-agent", + }); + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "subagent.updated", + threadId, + occurredAt: now, + payload: { + id: nativeSubagentId, + threadId, + runId: nativeSubagentRunId, + parentNodeId: idAllocator.derive.rootNode({ runId: nativeSubagentRunId }), + origin: "provider_native", + createdBy: "agent", + driver: CODEX_DRIVER, + providerInstanceId: modelSelection.instanceId, + providerThreadId: null, + childThreadId: null, + nativeTaskRef: { + driver: CODEX_DRIVER, + nativeId: "background-agent", + strength: "strong", + }, + prompt: "sleep 150; echo done", + title: "Background agent", + model: null, + status: "running", + result: null, + startedAt: now, + completedAt: null, + updatedAt: now, + }, + }, + ], + }); yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const nativeSubagentStatus = Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const projection = yield* projectionStore.getThreadProjection(threadId); + return projection.subagents.find((subagent) => subagent.id === nativeSubagentId)?.status; + }); const toolCallStatus = (id: OrchestrationV2TurnItem["id"]) => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; @@ -3345,6 +3391,7 @@ const openSessionWithBackgroundCommand = (fixtureName: string) => writeToolCall, toolCalls, toolCallStatus, + nativeSubagentStatus, }; }); @@ -3356,8 +3403,15 @@ it.effect.each(["detach", "stream failure", "provider stop", "early close"] as c const state = yield* Ref.make(emptyState); const effect = Effect.gen(function* () { const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { threadId, providerSessionId, rosters, toolCalls, seededToolCalls, writeToolCall } = - yield* openSessionWithBackgroundCommand("provider-session-manager-detach-roster"); + const { + threadId, + providerSessionId, + rosters, + toolCalls, + seededToolCalls, + writeToolCall, + nativeSubagentStatus, + } = yield* openSessionWithBackgroundCommand("provider-session-manager-detach-roster"); const runtime = Option.getOrThrow(yield* manager.get(providerSessionId)); const subscription = yield* runtime.subscribeEvents!; const consuming = yield* Deferred.make(); @@ -3433,6 +3487,8 @@ it.effect.each(["detach", "stream failure", "provider stop", "early close"] as c otherSession: "running", replacement: "running", }); + // regression: #36548 a native subagent the dead process ran stayed running after its run completed + assert.notInclude(["pending", "running", "waiting"], yield* nativeSubagentStatus); }); yield* effect.pipe( diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index eb56df9d049ac..ca54c14b2cb95 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -59,6 +59,10 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { + endOrphanedNativeSubagent, + endRunlessRootTurns, +} from "@t3tools/provider-core/server/subagentProjection"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -995,7 +999,7 @@ export const layerWithOptions = ( // Tool calls the dead process started never report an end either, and run finalization // keeps them open for a late result. Items started after the release belong to a - // replacement session. Subagent rows are left out: run finalization ends run-owned ones. + // replacement session. const writeReleasedToolCallEvents = Effect.fn( "ProviderSessionManager.writeReleasedToolCallEvents", )(function* (released: ReleasedProviderThread, releasedAt: DateTime.Utc) { @@ -1047,6 +1051,96 @@ export const layerWithOptions = ( }); }); + // Background native subagents die with the process too, often after their run completed, + // so run finalization does not end them. One started or resumed after the release belongs + // to a replacement session. + const writeReleasedSubagentEvents = Effect.fn( + "ProviderSessionManager.writeReleasedSubagentEvents", + )(function* (released: ReleasedProviderThread, releasedAt: DateTime.Utc) { + const { threadId, providerSessionId, providerThreadId, driver } = released; + const readCutOff = Effect.gen(function* () { + const { subagents, turnItems } = yield* projectionStore.getThreadRecords( + threadId, + ["subagents", "turnItems"], + { + omitSettledSubagents: true, + turnItemTypes: ["subagent"], + turnItemStatuses: activeTurnItemStatuses, + }, + ); + const cutOff = subagents.filter((subagent) => { + if ( + subagent.origin !== "provider_native" || + subagent.driver !== driver || + !isOrchestrationV2WorkActive(subagent.status) || + DateTime.isGreaterThan(subagent.startedAt ?? subagent.updatedAt, releasedAt) + ) { + return false; + } + // Record and item are written together; a record without a live item lost its end. + const item = turnItems.find( + (candidate) => candidate.type === "subagent" && candidate.subagentId === subagent.id, + ); + return item === undefined || item.providerThreadId === providerThreadId; + }); + return { subagents: cutOff, turnItems }; + }); + let pending = yield* readCutOff; + for (let pass = 0; pass < 3 && pending.subagents.length > 0; pass++) { + const { nodes } = yield* projectionStore.getThreadRecords(threadId, ["nodes"], { + nodeIds: pending.subagents.map((subagent) => subagent.id), + }); + const projection = { ...pending, nodes }; + for (const subagent of pending.subagents) { + const now = yield* DateTime.now; + const allocateEventId = () => + idAllocator.allocate.event({ threadId, providerSessionId }); + const events = yield* endOrphanedNativeSubagent({ + projection, + subagentId: subagent.id, + status: "interrupted", + now, + emitted: [], + allocateEventId, + }); + if (subagent.childThreadId !== null) { + const child = yield* projectionStore + .getThreadRecords(subagent.childThreadId, ["nodes", "providerTurns", "turnItems"], { + turnItemRunIds: [null], + turnItemStatuses: activeTurnItemStatuses, + }) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed(null), + }), + ); + if (child !== null) { + events.push( + ...(yield* endRunlessRootTurns({ + threadId: subagent.childThreadId, + providerInstanceId: subagent.providerInstanceId, + projection: child, + status: "interrupted", + now, + emitted: events, + allocateEventId, + })), + ); + } + } + // A late update from the dead session's queued events or a resume rejects only this write. + yield* eventSink.writeIfSubagentUnchanged({ expected: subagent, events }); + } + pending = yield* readCutOff; + } + if (pending.subagents.length === 0) return; + yield* Effect.logWarning("orchestration-v2.provider-session-release-subagent-conflict", { + providerSessionId, + providerThreadId, + openSubagents: pending.subagents.length, + }); + }); + const writeReleasedBackgroundWorkEvents = ( released: ReadonlyArray, releasedAt: DateTime.Utc, @@ -1056,6 +1150,7 @@ export const layerWithOptions = ( (providerThread) => writeReleasedRosterEvents(providerThread).pipe( Effect.andThen(writeReleasedToolCallEvents(providerThread, releasedAt)), + Effect.andThen(writeReleasedSubagentEvents(providerThread, releasedAt)), ), { discard: true }, ); @@ -1269,7 +1364,10 @@ export const layerWithOptions = ( // Queued events can persist tool calls after the release-time scan. const afterDrain = Effect.forEach( releasedProviderThreads, - (thread) => writeReleasedToolCallEvents(thread, releasedAt), + (thread) => + writeReleasedToolCallEvents(thread, releasedAt).pipe( + Effect.andThen(writeReleasedSubagentEvents(thread, releasedAt)), + ), { discard: true }, ).pipe(logReleaseFailure(input.providerSessionId)); if (input.gracefulSubscribers === true) { diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d228922520926..257ed98c2fe8b 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -373,6 +373,9 @@ it.each([ function makeLocalCommandHarness(input: { readonly text: string; + readonly manualContinuation?: boolean; + readonly nativeResumeUnsupported?: boolean; + readonly steeringRestart?: boolean; readonly nativeGoals?: boolean; readonly goalDriver?: "opencode" | "antigravity"; readonly worktreePath?: string; @@ -407,6 +410,10 @@ function makeLocalCommandHarness(input: { */ readonly sideChat?: { readonly contextRun: "current" | "failed" | "cancelled" | "delivered"; + readonly unsentResume?: boolean; + readonly repeatedResume?: boolean; + readonly acceptedResume?: boolean; + readonly restartedResume?: boolean; readonly nativeForkFrom?: "idle" | "busy"; readonly switchedProvider?: boolean; readonly threadRuntimeMode?: OrchestrationV2ThreadProjection["thread"]["runtimeMode"]; @@ -426,6 +433,7 @@ function makeLocalCommandHarness(input: { const checkpointScopeId = CheckpointScopeId.make("scope-native-account-command"); const messageId = MessageId.make("message-native-account-command"); const run: OrchestrationV2ThreadProjection["runs"][number] = { + ...(input.manualContinuation ? { manualContinuationOfRunId: RunId.make("stopped-run") } : {}), id: runId, threadId, ordinal: 2, @@ -508,7 +516,7 @@ function makeLocalCommandHarness(input: { providerInstanceId: newInstanceId, providerThreadId, providerTurnId: null, - reason: "initial", + reason: input.steeringRestart ? "steering_restart" : "initial", status: "pending", startedAt: null, completedAt: null, @@ -759,6 +767,83 @@ function makeLocalCommandHarness(input: { ], }; } + if (input.sideChat?.unsentResume) { + const originalMessageId = MessageId.make("original-unsent-message"); + projection = { + ...projection, + runs: [ + { + ...run, + id: RunId.make("stopped-run"), + manualContinuationOfRunId: undefined, + userMessageId: originalMessageId, + status: "interrupted", + ordinal: 1, + }, + ...projection.runs, + ], + messages: [ + { ...message, id: originalMessageId, text: "Original request" }, + ...projection.messages, + ], + }; + } + if (input.sideChat?.repeatedResume) { + const retry = RunId.make("stopped-resume"); + projection = { + ...projection, + runs: projection.runs + .map((r) => (r.id === runId ? { ...r, manualContinuationOfRunId: retry } : r)) + .concat({ + ...run, + id: retry, + manualContinuationOfRunId: RunId.make("stopped-run"), + status: "interrupted", + }), + }; + } + if (input.sideChat?.acceptedResume) { + const acceptedId = RunAttemptId.make("accepted-original"); + projection = { + ...projection, + attempts: [ + ...projection.attempts, + { + ...projection.attempts[0]!, + id: acceptedId, + runId: RunId.make("stopped-run"), + providerThreadId: oldProviderThreadId, + status: "interrupted", + }, + ...(input.sideChat.restartedResume + ? [ + { + ...projection.attempts[0]!, + id: RunAttemptId.make("unaccepted-replacement"), + runId: RunId.make("stopped-run"), + attemptOrdinal: 2, + reason: "steering_restart" as const, + status: "interrupted" as const, + }, + ] + : []), + ], + providerTurns: [ + ...projection.providerTurns, + { + id: ProviderTurnId.make("accepted-original-turn"), + runAttemptId: acceptedId, + providerThreadId: oldProviderThreadId, + nodeId: rootNodeId, + ordinal: 1, + nativeTurnRef: null, + status: "interrupted", + startedAt: now, + completedAt: now, + }, + ], + }; + } if ("historyReadFailureAfterFallback" in input || "resumeFailure" in input) { const nativeThreadRef = { driver: providerThread.driver, @@ -916,6 +1001,10 @@ function makeLocalCommandHarness(input: { resumeThread: () => Effect.succeed(sideChatThread), forkThread, startTurn, + resumeTurn: input.nativeResumeUnsupported + ? undefined + : (input: ProviderAdapter.ProviderAdapterV2ResumeTurnInput) => + startTurn(ProviderAdapter.resumeTurnInput(input)), }; const open = vi.fn(() => input.sideChat !== undefined @@ -953,7 +1042,13 @@ function makeLocalCommandHarness(input: { (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect >((runInput) => input.sideChat !== undefined - ? runInput.session.startTurn(runInput as never).pipe(Effect.orDie) + ? (runInput.resume + ? runInput.session.resumeTurn!({ + ...runInput, + messageId: runInput.message.messageId, + } as never) + : runInput.session.startTurn(runInput as never) + ).pipe(Effect.orDie) : input.failReadsAfterRunning === true || input.previousTurn !== undefined ? Effect.suspend(() => providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, @@ -1436,6 +1531,47 @@ effectIt.effect.each(["failed", "interrupted"] as const)( }), ); +effectIt.effect("sends new steering text when restarting a manual continuation", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "New steering instruction", + manualContinuation: true, + steeringRestart: true, + nativeResumeUnsupported: true, + sideChat: { contextRun: "delivered" }, + }); + yield* harness.start; + expect(harness.startTurn.mock.calls[0]?.[0].message.text).toBe("New steering instruction"); + expect(harness.startRootRun.mock.calls[0]?.[0].resume).toBe(false); + }), +); + +effectIt.effect("refuses unsupported resume instead of manufacturing a user prompt", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "", + manualContinuation: true, + nativeResumeUnsupported: true, + sideChat: { contextRun: "delivered" }, + }); + yield* harness.start; + expect(harness.startTurn).not.toHaveBeenCalled(); + expect(harness.startRootRun).not.toHaveBeenCalled(); + expect(harness.settledRuns).toEqual(["failed"]); + expect(harness.projection().turnItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "error", + failure: expect.objectContaining({ + message: + "This provider cannot resume without a new message. Send a message to continue.", + }), + }), + ]), + ); + }), +); + effectIt.effect.each([ { contextRun: "current", runtimeMode: "full-access", context: "SIDE_CHAT_CONTEXT" }, { contextRun: "failed", runtimeMode: "full-access", context: "SIDE_CHAT_CONTEXT" }, @@ -1447,6 +1583,29 @@ effectIt.effect.each([ context: "SIDE_CHAT_CONTEXT", }, { contextRun: "delivered", runtimeMode: "full-access", context: "" }, + { contextRun: "delivered", runtimeMode: "full-access", context: "", unsentResume: true }, + { + contextRun: "delivered", + runtimeMode: "full-access", + context: "", + unsentResume: true, + repeatedResume: true, + }, + { + contextRun: "delivered", + runtimeMode: "full-access", + context: "", + unsentResume: true, + acceptedResume: true, + }, + { + contextRun: "delivered", + runtimeMode: "full-access", + context: "", + unsentResume: true, + acceptedResume: true, + restartedResume: true, + }, { contextRun: "current", threadRuntimeMode: "approval-required", @@ -1458,7 +1617,8 @@ effectIt.effect.each([ ({ runtimeMode, context, ...sideChat }) => Effect.gen(function* () { const harness = makeLocalCommandHarness({ - text: "What is the main thread doing?", + text: "", + manualContinuation: true, sideChat, }); @@ -1467,9 +1627,15 @@ effectIt.effect.each([ expect(harness.open).toHaveBeenCalledWith( expect.objectContaining({ runtimePolicy: expect.objectContaining({ runtimeMode }) }), ); + const resendsOriginal = + "unsentResume" in sideChat && + (!("acceptedResume" in sideChat) || "restartedResume" in sideChat); expect(harness.startTurn.mock.calls.map(([turn]) => turn.message.text)).toEqual([ - withInlineContext(context, "What is the main thread doing?"), + withInlineContext(context, resendsOriginal ? "Original request" : ""), ]); + expect(harness.startTurn.mock.calls[0]?.[0].resume === true).toBe( + context === "" && !resendsOriginal, + ); expect(harness.projection().contextHandoffs).toMatchObject([ { id: "side-chat-context", @@ -1514,26 +1680,47 @@ effectIt.effect.each([ }), ); -effectIt.effect.each([ - { goalDriver: "opencode", nativeGoals: false }, - { goalDriver: "opencode", nativeGoals: true }, - { goalDriver: "antigravity", nativeGoals: false }, - { goalDriver: "antigravity", nativeGoals: true }, -] as const)( - "$goalDriver goal prompts defer switched-provider history only with native support: $nativeGoals", - ({ goalDriver, nativeGoals }) => +effectIt.effect.each( + ( + [ + { goalDriver: "opencode", nativeGoals: false }, + { goalDriver: "opencode", nativeGoals: true }, + { goalDriver: "antigravity", nativeGoals: false }, + { goalDriver: "antigravity", nativeGoals: true }, + ] as const + ).flatMap((choice) => [ + { ...choice, resuming: false }, + { ...choice, resuming: true }, + ]), +)( + "$goalDriver goal prompts defer switched-provider history only with native support: $nativeGoals, resuming: $resuming", + ({ goalDriver, nativeGoals, resuming }) => Effect.gen(function* () { const harness = makeLocalCommandHarness({ - text: "/goal all tests pass", + text: resuming ? "" : "/goal all tests pass", + manualContinuation: resuming, goalDriver, nativeGoals, - sideChat: { contextRun: "failed", switchedProvider: true }, + sideChat: { contextRun: "failed", switchedProvider: true, unsentResume: resuming }, }); + if (resuming) { + const projection = harness.projection(); + harness.setProjection({ + ...projection, + messages: projection.messages.map((message) => + message.id === "original-unsent-message" + ? { ...message, text: "/goal all tests pass" } + : message, + ), + }); + } harness.tryHandlePromptCommand.mockReturnValue(Effect.succeed(false)); yield* harness.start; expect(harness.startTurn.mock.calls[0]?.[0].message.text.includes("SIDE_CHAT_CONTEXT")).toBe( !nativeGoals, ); + if (nativeGoals) + expect(harness.startTurn.mock.calls[0]?.[0].message.text).toBe("/goal all tests pass"); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 47dbcc06da089..e95cc770cc9e6 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -57,6 +57,7 @@ import { deliverContextHandoffs, withInlineContext } from "./ContextHandoffDeliv import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; +import { unsentManualContinuationSource } from "./manualContinuation.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import { ContextHandoffBackgroundWorkError, @@ -283,6 +284,12 @@ export const layer: Layer.Layer< return { ...session, startTurn: (input: Parameters[0]) => start(input), + ...(session.resumeTurn === undefined + ? {} + : { + resumeTurn: (input: ProviderAdapter.ProviderAdapterV2ResumeTurnInput) => + start(ProviderAdapter.resumeTurnInput(input)), + }), ...(session.compactThread === undefined ? {} : { @@ -378,6 +385,16 @@ export const layer: Layer.Layer< cause: `Run ${runId} is missing its execution projection state.`, }); } + const manualResume = + run.manualContinuationOfRunId !== undefined && attempt.reason !== "steering_restart"; + const resumeSource = manualResume + ? unsentManualContinuationSource(run, projection) + : undefined; + const unsentResumeMessage = + resumeSource !== undefined + ? projection.messages.find((source) => source.id === resumeSource.userMessageId) + : undefined; + const providerMessage = unsentResumeMessage ?? message; const settleRunBeforeStart = Effect.fn("orchestrationV2.providerTurnStart.settleBeforeStart")( function* (input: { readonly signal: string; @@ -494,9 +511,12 @@ export const layer: Layer.Layer< }, ); if (input.startFailure !== undefined) return yield* settleStartFailure(input.startFailure); - if (message.attachments.length === 0 && message.text.trimStart().startsWith("/")) { + if ( + providerMessage.attachments.length === 0 && + providerMessage.text.trimStart().startsWith("/") + ) { const isEmptyCompaction = - message.text.trim().toLowerCase() === "/compact" && !projection.hasConversation; + providerMessage.text.trim().toLowerCase() === "/compact" && !projection.hasConversation; // Preparation may select a new provider; account commands still target the last native session. const nativeThreads = new Map( projection.providerThreads @@ -527,8 +547,8 @@ export const layer: Layer.Layer< providerAuth.tryHandlePromptCommand({ instanceId: authInstanceId, text: projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], + text: providerMessage.text, + records: providerMessage.context?.records ?? [], }), hasAttachments: false, }), @@ -566,7 +586,7 @@ export const layer: Layer.Layer< : { type: "command_execution", title: "Provider signed out", - input: message.text.trim(), + input: providerMessage.text.trim(), output: "Provider signed out", exitCode: 0, }, @@ -771,15 +791,15 @@ export const layer: Layer.Layer< let effectiveHandoffs = handoffs; let resumeFellBack = false; const userText = projectComposerContextForProvider({ - text: message.text, - records: message.context?.records ?? [], + text: providerMessage.text, + records: providerMessage.context?.records ?? [], }); // Native goal commands go verbatim; handoff history waits a turn. const goalDriver = session.driver === "codex" || session.driver === "claudeAgent" || session.providerSession.capabilities.turns.supportsNativeGoals === true; - const goalCommand = goalDriver && parseGoalCommand(message) !== null; + const goalCommand = goalDriver && parseGoalCommand(providerMessage) !== null; // The provider turn marks cancellation work delivered; restart continuations use their own text or resume natively. const noteContinuation = isRestartNoteContinuation( run, @@ -952,7 +972,7 @@ export const layer: Layer.Layer< tokenCap, modelContextWindow, userText: restartNote === "" ? userText : `${restartNote}\n\n${userText}`, - attachments: message.attachments, + attachments: providerMessage.attachments, providerThread: budgetProviderThread, nativeContextEstimate: budgetProviderThread.contextUsage?.usedTokens === undefined @@ -1290,7 +1310,7 @@ export const layer: Layer.Layer< handoffs: [...handoffs, ...resumedRetryHandoff], providerThread: { ...resumed.success, id: providerThread.id }, budget: context.budget, - deferInline: isCompactionCommandMessage(message) || goalCommand, + deferInline: isCompactionCommandMessage(providerMessage) || goalCommand, alreadyDeliveredItemIds: context.deliveredItemIds, persist: () => Effect.void, }).pipe( @@ -1473,6 +1493,17 @@ export const layer: Layer.Layer< legacyInputRunIds, budget, } = handoffContext; + if (manualResume && unsentResumeMessage === undefined && session.resumeTurn === undefined) { + yield* settleStartFailure({ + signal: "resume-unsupported", + title: "Native resume is unavailable", + error: new ProviderAdapter.ProviderResumeUnsupportedError({ + driver: session.driver, + providerThreadId: providerThread.id, + }), + }); + return; + } const retryHandoff = resumedRetryHandoff ?? (yield* prepareRetryHandoff(handoffContext)); const runningProviderThread: OrchestrationV2ProviderThread = { ...loadedProviderThread, @@ -1674,11 +1705,23 @@ export const layer: Layer.Layer< .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; const text = goalCommand ? userText : withInlineContext(context, userText); - yield* start({ - ...(noteContinuation ? promptedInput : turnInput), - message: { ...turnInput.message, text }, - ...(text === userText ? {} : { inlineContextLength: text.length - userText.length }), - }).pipe( + const startInput = noteContinuation ? promptedInput : turnInput; + const { resume: _resume, ...contextInput } = startInput; + const { message: resumeMessage, resume: _resumeIntent, ...resumeInput } = startInput; + const operation = + turnInput.resume && context === "" && session.resumeTurn !== undefined + ? session.resumeTurn({ ...resumeInput, messageId: resumeMessage.messageId }) + : start({ + ...(context === "" ? startInput : contextInput), + message: { + ...turnInput.message, + text, + }, + ...(text === userText + ? {} + : { inlineContextLength: text.length - userText.length }), + }); + yield* operation.pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. Effect.tapError((error) => refusedBeforePrompt(error) @@ -1765,6 +1808,7 @@ export const layer: Layer.Layer< .filter((turn) => turn.providerThreadId === providerThread.id) .map((turn) => turn.ordinal), ) + 1, + resume: manualResume && unsentResumeMessage === undefined, nativeThreadHasTurns: nativeInputRunIds.size > 0 || (legacyInputRunIds.size > 0 && @@ -1778,7 +1822,7 @@ export const layer: Layer.Layer< message: { messageId: message.id, text: userText, - attachments: message.attachments, + attachments: providerMessage.attachments, createdBy: message.createdBy, creationSource: message.creationSource, ...(message.scheduledTaskId === undefined diff --git a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts index f075b43d9f882..292882d0f894b 100644 --- a/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts +++ b/apps/server/src/orchestration-v2/RemoteDelegatedTask.test.ts @@ -217,6 +217,7 @@ it.layer(layerTest)("a delegated task in a linked environment", (it) => { const [task] = projection.subagents; assert.equal(task?.childThreadId, null); assert.deepEqual(task?.remoteChild, remoteChild); + assert.deepEqual(task?.modelSelection, modelSelection); assert.equal(task?.status, "running"); const item = projection.turnItems.find((candidate) => candidate.type === "subagent"); assert.deepEqual(item?.type === "subagent" ? item.remoteChild : undefined, remoteChild); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 10f10a98de0a4..49cdaf04406b7 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -582,6 +582,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly baseline?: Effect.Effect; readonly providerTurnOrdinal: number; readonly nativeThreadHasTurns?: boolean; + readonly resume?: boolean; readonly loadInheritedBackgroundTurnItems?: () => Effect.Effect< ReadonlyArray, unknown @@ -1962,21 +1963,37 @@ export const layer: Layer.Layer< const compact = input.message.attachments.length === 0 && input.message.text.trim().toLowerCase() === "/compact"; - const startTurn = compact - ? (input.session.compactThread?.(turnInput) ?? - Effect.fail( - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: input.session.driver, - threadId: input.run.threadId, - providerThreadId: input.providerThread.id, - runId: input.run.id, - cause: new ProviderAdapter.ProviderCompactionUnsupportedError({ + const { message: _message, ...resumeInput } = turnInput; + const startTurn = + input.resume === true + ? (input.session.resumeTurn?.({ ...resumeInput, messageId: input.message.messageId }) ?? + Effect.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ driver: input.session.driver, + threadId: input.run.threadId, providerThreadId: input.providerThread.id, + runId: input.run.id, + cause: new ProviderAdapter.ProviderResumeUnsupportedError({ + driver: input.session.driver, + providerThreadId: input.providerThread.id, + }), }), - }), - )) - : input.session.startTurn(turnInput); + )) + : compact + ? (input.session.compactThread?.(turnInput) ?? + Effect.fail( + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: input.session.driver, + threadId: input.run.threadId, + providerThreadId: input.providerThread.id, + runId: input.run.id, + cause: new ProviderAdapter.ProviderCompactionUnsupportedError({ + driver: input.session.driver, + providerThreadId: input.providerThread.id, + }), + }), + )) + : input.session.startTurn(turnInput); yield* Effect.andThen(shouldStart, startTurn).pipe( // Wait for acceptance so abandoned startup cannot leave a refresher // on the already-detached ingestion fiber. diff --git a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts index a5e852d0e133f..3ca71f384eba1 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationQueue.test.ts @@ -97,6 +97,7 @@ it.effect.each([ Layer.mock(ThreadManagement.ThreadManagementService)({ recoverDelegatedTask: () => Effect.void, }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({}), ServerSettings.layerTest(), Layer.mock(ProviderTurnStart.ProviderTurnStartServiceV2)({ start: () => Effect.sync(() => calls.push("start")).pipe(Effect.asVoid), diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index c6a5384fa3e73..a6067b87398ca 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -634,7 +634,7 @@ it.effect.each([" /COMPACT "])( ); assert.isNull((yield* threads.getThreadProjection(launched.threadId)).thread.lastVisitedAt); assert.isFalse( - (yield* outbox.listByCommandId(CommandId.make("compact-title:initial-message"))).some( + (yield* outbox.listByCommandId(CommandId.make("compact-title"))).some( (effect) => effect.request.type === "thread-title.generate", ), ); @@ -777,7 +777,7 @@ it.effect("arms durable title generation after accepting the first message", () generateTitle: true, }; const launched = yield* launches.launch(input); - const generationCommandId = CommandId.make("command:launch:title-generation:initial-message"); + const generationCommandId = input.commandId; const projection = yield* threads.getThreadProjection(launched.threadId); assert.equal(projection.thread.title, "Generate my title"); @@ -851,9 +851,9 @@ it.effect("arms durable title generation after accepting the first message", () }), ); -it.effect("does not update a reused thread title when the initial message is rejected", () => +it.effect("a rejected initial message leaves no title change and no new thread", () => Effect.gen(function* () { - const harness = makeHarness(); + const harness = makeHarness({ adapters: [adapter, claudeAdapter] }); yield* Effect.gen(function* () { const launches = yield* ThreadLaunch.ThreadLaunchService; const threads = yield* ThreadManagement.ThreadManagementService; @@ -898,6 +898,24 @@ it.effect("does not update a reused thread title when the initial message is rej assert.isUndefined(projection.thread.titleRegeneration); assert.isEmpty(projection.messages); assert.isEmpty(yield* outbox.listByCommandId(CommandId.make(`${commandId}:initial-message`))); + + // The orchestrator refuses this goal command, after the launch's own checks pass. + const newThreadId = ThreadId.make("thread:launch:rejected-new-thread"); + const rejected = yield* launches + .launch({ + ...launchInput({ + command: "command:launch:rejected-new-thread", + thread: newThreadId, + message: "/goal pause", + }), + modelSelection: claudeModelSelection, + }) + .pipe(Effect.flip); + if (rejected._tag !== "ThreadLaunchError") { + assert.fail(`expected ThreadLaunchError, got ${rejected._tag}`); + } + assert.equal(rejected.operation, "create-thread"); + assert.isNull(yield* threads.getThreadShell(newThreadId)); }).pipe(Effect.provide(harness.layer)); }), ); @@ -1682,6 +1700,21 @@ it.effect("replays a server-allocated launch", () => workspace: { type: "worktree", baseRef: "main" }, }); const first = yield* launches.launch(rest); + // One commit holds the thread and its first run, so no client sees the thread idle between. + const launchCommits = yield* threads + .streamStoredEventsFrom({ threadId: first.threadId }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "thread.created" || stored.event.type === "run.created", + ), + Stream.take(2), + Stream.runCollect, + ); + assert.deepEqual( + Array.from(launchCommits, (stored) => stored.commandId), + [rest.commandId, rest.commandId], + ); yield* Deferred.await(setupEntered); const retry = yield* launches.launch(rest); assert.equal(first.threadId, retry.threadId); diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 3ecba211c5fc9..5f62dc9cc495f 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -1288,6 +1288,26 @@ const make = Effect.gen(function* () { const initialBranch = yield* launchBranch(workspaceStrategy, project.workspaceRoot); const initialWorktreePath = workspaceStrategy.type === "existing_worktree" ? workspaceStrategy.worktreePath : null; + const initialMessage = + input.initialMessage === undefined + ? undefined + : { + messageId: + input.initialMessage.messageId ?? + (yield* ids.allocate + .message({ threadId: candidateThreadId, ordinal: 1 }) + .pipe(Effect.mapError(mapError(input, "dispatch-message", candidateThreadId)))), + text: input.initialMessage.text, + ...(input.initialMessage.scheduledTaskId === undefined + ? {} + : { scheduledTaskId: input.initialMessage.scheduledTaskId }), + ...(input.initialMessage.senderThreadId === undefined + ? {} + : { senderThreadId: input.initialMessage.senderThreadId }), + attachments: input.initialMessage.attachments, + ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), + ...(input.generateTitle === true ? { titleSeed: input.title } : {}), + }; const claimDispatch = input.reuseExistingThread === true ? threads.dispatch({ @@ -1316,6 +1336,9 @@ const make = Effect.gen(function* () { : { delegatedFrom: input.delegatedFrom }), createdBy: input.createdBy, creationSource: input.creationSource, + ...(initialMessage === undefined + ? {} + : { initialMessage: { ...initialMessage, workspaceStrategy } }), }); const claimed = yield* claimDispatch.pipe( Effect.mapError( @@ -1335,53 +1358,43 @@ const make = Effect.gen(function* () { let runId: RunId | null = null; let messageSequence: number | undefined; let messageWasAlreadyAccepted = false; - if (input.initialMessage !== undefined) { - const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); - const messageReceipt = yield* readReceipt(input, messageCommandId); - messageWasAlreadyAccepted = Option.isSome(messageReceipt); - const messageId = - input.initialMessage.messageId ?? - (yield* ids.allocate - .message({ threadId, ordinal: 1 }) - .pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)))); - const dispatched = yield* threads - .dispatch({ - type: "message.dispatch", - commandId: messageCommandId, - threadId, - messageId, - text: input.initialMessage.text, - ...(input.initialMessage.scheduledTaskId === undefined - ? {} - : { scheduledTaskId: input.initialMessage.scheduledTaskId }), - ...(input.initialMessage.senderThreadId === undefined - ? {} - : { senderThreadId: input.initialMessage.senderThreadId }), - attachments: input.initialMessage.attachments, - ...(input.initialMessage.context ? { context: input.initialMessage.context } : {}), - ...(input.generateTitle === true ? { titleSeed: input.title } : {}), - modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start", workspaceStrategy }, - createdBy: input.createdBy, - creationSource: input.creationSource, - }) - .pipe( - // An agent's launch can land on a thread that a replay, or a concurrent - // launch with the same key, created with broader modes. The orchestrator - // refuses the message under the thread's lock; the user's own clients - // launch into their own drafts, whatever modes those hold. - input.createdBy === "agent" - ? Effect.provideService(DispatchModeLimit, { - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - }) - : (effect) => effect, - Effect.mapError(mapError(input, "dispatch-message", threadId)), - ); + if (initialMessage !== undefined) { + // A new thread's claim carries its first message. A reused thread, or a replayed + // claim that carried none, takes it in a command of its own. + let accepted = claimed; + if (!claimed.storedEvents.some((stored) => stored.event.type === "run.created")) { + const messageCommandId = CommandId.make(`${input.commandId}:initial-message`); + const messageReceipt = yield* readReceipt(input, messageCommandId); + messageWasAlreadyAccepted = Option.isSome(messageReceipt); + accepted = yield* threads + .dispatch({ + ...initialMessage, + type: "message.dispatch", + commandId: messageCommandId, + threadId, + modelSelection: input.modelSelection, + dispatchMode: { type: "defer_start", workspaceStrategy }, + createdBy: input.createdBy, + creationSource: input.creationSource, + }) + .pipe( + // An agent's launch can land on a thread that a replay, or a concurrent + // launch with the same key, created with broader modes. The orchestrator + // refuses the message under the thread's lock; the user's own clients + // launch into their own drafts, whatever modes those hold. + input.createdBy === "agent" + ? Effect.provideService(DispatchModeLimit, { + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }) + : (effect) => effect, + Effect.mapError(mapError(input, "dispatch-message", threadId)), + ); + } // A concurrent retry can read no receipt and still replay the dispatch. - messageWasAlreadyAccepted ||= dispatched.replayed === true; - messageSequence = dispatched.sequence; - const runCreated = dispatched.storedEvents.find( + messageWasAlreadyAccepted ||= accepted.replayed === true; + messageSequence = accepted.sequence; + const runCreated = accepted.storedEvents.find( (stored) => stored.event.type === "run.created", ); runId = runCreated?.event.type === "run.created" ? runCreated.event.payload.id : null; diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index c2502902ef8e3..d1ae8ab555161 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -68,6 +68,12 @@ it("stamps authoritative provenance on commands that create threads or messages" threadId: ThreadId.make("thread:elsewhere"), title: "Somewhere else", }, + // A client's message goes through intake; only the server folds one into the creation. + initialMessage: { + messageId: MessageId.make("message:thread-management:forged"), + text: "Forged", + attachments: [], + }, }; const stamped = ThreadManagementService.withCreationProvenance(command, { @@ -80,6 +86,7 @@ it("stamps authoritative provenance on commands that create threads or messages" }); expect(stamped).not.toHaveProperty("linkOrigin"); expect(stamped).not.toHaveProperty("delegatedFrom"); + expect(stamped).not.toHaveProperty("initialMessage"); }); const layerReceiptsTest = CommandReceiptStore.layer.pipe( diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 914afb54fda14..fa07fb71bb439 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -85,8 +85,13 @@ export function withCreationProvenance( ): OrchestrationV2Command { switch (command.type) { case "thread.create": { - // Only the server stamps a link's origin, so a client cannot set one. - const { linkOrigin: _linkOrigin, delegatedFrom: _delegatedFrom, ...rest } = command; + // Only the server stamps a link's origin or folds in a first message, so a client cannot. + const { + linkOrigin: _linkOrigin, + delegatedFrom: _delegatedFrom, + initialMessage: _initialMessage, + ...rest + } = command; return { ...rest, ...provenance }; } case "message.dispatch": diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts index bec9c60c7a014..a92cdd22aa515 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.test.ts @@ -10,6 +10,7 @@ import { EventId, GuestMessageId, MessageId, + RunId, RuntimeRequestId, ThreadId, TurnItemId, @@ -426,3 +427,51 @@ it.effect("sends a guest message with the guest's files, never ones the client n expect(command.attachments).toEqual([guestFile]); }).pipe(Effect.provide(layerIntakeTest)), ); + +it.effect("refuses a queued edit that keeps a file another edit already deleted", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const storedFile = (name: string) => ({ + type: "image" as const, + id: ChatAttachmentId.make(createAttachmentId("thread-queued-edit")!), + name, + mimeType: "image/png", + sizeBytes: 3, + }); + const kept = storedFile("kept.png"); + const deleted = storedFile("deleted.png"); + NodeFS.writeFileSync( + NodePath.join(config.attachmentsDir, `${kept.id}.png`), + new Uint8Array([1, 2, 3]), + ); + const captured: OrchestrationV2ServerCommand[] = []; + const edit = (attachments: ReadonlyArray) => + dispatchCommand({ + type: "queued-run.edit", + commandId: CommandId.make(`queued-edit-${attachments.length}`), + threadId: ThreadId.make("thread-queued-edit"), + runId: RunId.make("run-queued-edit"), + text: "Edited from a stale view.", + attachments, + }).pipe( + Effect.provide( + Layer.mock(ThreadManagementService.ThreadManagementService)({ + dispatch: layerFailingDispatchHandler(captured), + hasReceipt: (commandId) => + Effect.succeed(captured.some((command) => command.commandId === commandId)), + }), + ), + Effect.result, + ); + + yield* edit([kept]); + expect(captured).toHaveLength(1); + const result = yield* edit([kept, deleted]); + expect(result._tag).toBe("Failure"); + expect(captured).toHaveLength(1); + // A retry of the accepted edit replays its receipt even after a later cleanup removed its file. + NodeFS.rmSync(NodePath.join(config.attachmentsDir, `${kept.id}.png`)); + yield* edit([kept]); + expect(captured).toHaveLength(2); + }).pipe(Effect.provide(layerIntakeTest)), +); diff --git a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts index c6a6cdbf7ac1e..6e9fd503baec5 100644 --- a/apps/server/src/orchestration-v2/ThreadMessageIntake.ts +++ b/apps/server/src/orchestration-v2/ThreadMessageIntake.ts @@ -2,6 +2,7 @@ import { remapComposerContextAttachments } from "@t3tools/shared/composerContext import { appendUserInputAttachmentPaths } from "../provider/userInputAttachments.ts"; import type { ChatAttachment, OrchestrationV2Command } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; @@ -159,6 +160,24 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")( (command.type !== "queued-run.edit" || command.attachments === undefined) ) return yield* threads.dispatch(command); + // An accepted edit's retry replays its receipt even if a later cleanup removed its files. + if (command.type === "queued-run.edit" && !(yield* threads.hasReceipt(command.commandId))) { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + // A stale view can keep a file that another client's edit dropped and deleted. + for (const attachment of command.attachments ?? []) { + if (AttachmentClaims.attachmentIsPendingUpload(attachment)) continue; + const path = resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }); + if ( + path !== null && + !(yield* fileSystem.exists(path).pipe(Effect.orElseSucceed(() => false))) + ) { + return yield* new AttachmentClaims.AttachmentClaimError({ + message: `Attachment '${attachment.name}' cannot be sent: attachment not found.`, + }); + } + } + } const claimed = yield* AttachmentClaims.claimPendingAttachments({ threadId: command.threadId, attachments: command.attachments ?? [], diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 421e79f5a0904..ca93dfe46ead3 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -3,9 +3,12 @@ import { EnvironmentId, EventId, MessageId, + NodeId, ProjectId, ProviderInstanceId, + RunId, ThreadId, + TurnItemId, type OrchestrationV2DomainEvent, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; @@ -130,6 +133,7 @@ const message = ( readonly minute?: number; readonly streaming?: boolean; readonly createdBy?: "user" | "agent"; + readonly runId?: RunId; } = {}, ): OrchestrationV2DomainEvent => ({ id: EventId.make(`message:${id}`), @@ -142,7 +146,7 @@ const message = ( creationSource: role === "user" ? "web" : "provider", id: MessageId.make(id), threadId, - runId: null, + runId: options.runId ?? null, nodeId: null, role, text, @@ -757,22 +761,120 @@ it.layer(layerTest)("ThreadSearch filters before it ranks", (it) => { }), ); - it.effect("leaves out answers to an agent's question, which show in its card", () => + it.effect("leaves out answers in their question card and turns the thread hides", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const search = yield* ThreadSearch.ThreadSearch; const project = ProjectId.make("project:answers"); const asked = ThreadId.make("thread:answers"); + const rewound = ThreadId.make("thread:rewound"); + const queued = ThreadId.make("thread:queued"); + const stopped = ThreadId.make("thread:stopped"); + const recovered = ThreadId.make("thread:recovered"); + const run = (owner: ThreadId, status: "rolled_back" | "cancelled") => + ({ + id: EventId.make(`run:${owner}`), + type: "run.created", + threadId: owner, + runId: RunId.make(`run:${owner}`), + nodeId: NodeId.make(`node:${owner}`), + providerInstanceId, + occurredAt: at(2), + payload: { + id: RunId.make(`run:${owner}`), + threadId: owner, + ordinal: 1, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5" }, + providerThreadId: null, + userMessageId: MessageId.make(`${owner}:prompt`), + rootNodeId: NodeId.make(`node:${owner}`), + activeAttemptId: null, + status, + requestedAt: at(1), + startedAt: at(1), + completedAt: at(2), + checkpointId: null, + contextHandoffId: null, + }, + }) satisfies OrchestrationV2DomainEvent; + const shown = (owner: ThreadId, id: string, as: "turn_start" | "queued_turn" | "reply") => + ({ + id: EventId.make(`item:${id}`), + type: "turn-item.updated", + threadId: owner, + occurredAt: at(2), + payload: { + id: TurnItemId.make(id), + threadId: owner, + runId: RunId.make(`run:${owner}`), + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: at(1), + completedAt: at(1), + updatedAt: at(1), + messageId: MessageId.make(id), + text: "", + ...(as === "reply" + ? { type: "assistant_message", streaming: false } + : { + type: "user_message", + inputIntent: as, + attachments: [], + createdBy: "user", + creationSource: "web", + }), + }, + }) satisfies OrchestrationV2DomainEvent; yield* createProject(project); yield* Effect.forEach( [ thread(asked, project), message(asked, "async-answer:request-1", "user", "use the albatross drive"), + thread(rewound, project), + run(rewound, "rolled_back"), + message(rewound, "rewound-reply", "assistant", "the albatross drive is ready", { + runId: RunId.make(`run:${rewound}`), + }), + thread(queued, project), + run(queued, "cancelled"), + message(queued, "queued-prompt", "user", "then mount the albatross drive", { + runId: RunId.make(`run:${queued}`), + }), + // A queued prompt usually has no item; an edited one has a queued item. + message(queued, "requeued-prompt", "user", "then eject the albatross drive", { + runId: RunId.make(`run:${queued}`), + }), + shown(queued, "requeued-prompt", "queued_turn"), + // A stopped turn keeps its prompt and whatever the agent already said. + thread(stopped, project), + run(stopped, "cancelled"), + message(stopped, "stopped-prompt", "user", "format the albatross drive", { + runId: RunId.make(`run:${stopped}`), + }), + shown(stopped, "stopped-prompt", "turn_start"), + thread(recovered, project), + run(recovered, "cancelled"), + message(recovered, "recovered-reply", "assistant", "the albatross drive is formatted", { + runId: RunId.make(`run:${recovered}`), + }), + shown(recovered, "recovered-reply", "reply"), ], projections.apply, { discard: true }, ); - assert.deepEqual((yield* search.search({ query: "albatross" })).matches, []); + assert.deepEqual( + (yield* search.search({ query: "albatross" })).matches + .map((match) => match.messageId) + .sort(), + [MessageId.make("recovered-reply"), MessageId.make("stopped-prompt")], + ); }), ); @@ -840,7 +942,7 @@ it.layer(layerTest)("ThreadSearch filters before it ranks", (it) => { }); it.effect.each([true, false])( - "ThreadSearch narrows short words outside ASCII before the hit limit (indexed: %s)", + "ThreadSearch narrows Cyrillic and accent-folded Latin short words before the hit limit (indexed: %s)", (indexed) => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -848,12 +950,15 @@ it.effect.each([true, false])( const sql = yield* SqlClient.SqlClient; const project = ProjectId.make("project:short-limit"); const older = ThreadId.make("thread:short-older"); + const olderFrench = ThreadId.make("thread:short-older-french"); const newer = ThreadId.make("thread:short-newer"); yield* createProject(project); yield* Effect.forEach( [ thread(older, project), message(older, "older", "user", "В логах ошибка"), + thread(olderFrench, project), + message(olderFrench, "older-french", "user", "École fermée à Lyon"), thread(newer, project), ], projections.apply, @@ -870,15 +975,14 @@ it.effect.each([true, false])( WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) INSERT INTO orchestration_v2_projection_messages (message_id, thread_id, role, streaming, created_at, updated_at, payload_json) - SELECT 'short-newer-' || i, ${newer}, 'assistant', 0, ${DateTime.formatIso(at(5))}, - ${DateTime.formatIso(at(5))}, json_object('text', 'ошибка здесь') - FROM n + SELECT 'short-newer-' || i || '-' || word, ${newer}, 'assistant', 0, + ${DateTime.formatIso(at(5))}, ${DateTime.formatIso(at(5))}, json_object('text', text) + FROM n, (SELECT 'ru' AS word, 'ошибка здесь' AS text UNION ALL SELECT 'fr', 'fermée ici') `; - const { matches } = yield* search.search({ query: "ошибка в" }); - assert.deepEqual( - matches.map((match) => match.threadId), - [older], - ); + const threadIds = (query: string) => + search.search({ query }).pipe(Effect.map(({ matches }) => matches.map((m) => m.threadId))); + assert.deepEqual(yield* threadIds("ошибка в"), [older]); + assert.deepEqual(yield* threadIds("fermée ec"), [olderFrench]); }).pipe(Effect.provide(layerTest)), ); @@ -898,6 +1002,9 @@ it.layer(layerTest)("ThreadSearch short words outside ASCII", (it) => { message(russian, "russian", "user", "В логах ошибка"), thread(french, project), message(french, "french", "user", "École fermée à Lyon"), + message(french, "french-shout", "user", "Météo école"), + message(french, "romanian", "user", "Școala închisă"), + message(french, "vietnamese", "user", "Hồ Chí Minh"), ], projections.apply, { discard: true }, @@ -908,8 +1015,13 @@ it.layer(layerTest)("ThreadSearch short words outside ASCII", (it) => { const assertMatches = Effect.gen(function* () { assert.deepEqual(yield* threadIds("ошибка в"), [russian]); assert.deepEqual(yield* threadIds("fermée éc"), [french]); + assert.deepEqual(yield* threadIds("ecole fermée"), [french]); + assert.deepEqual(yield* threadIds("école me"), [french]); + assert.deepEqual(yield* threadIds("închisă sc"), [french]); + assert.deepEqual(yield* threadIds("chí ho"), [french]); // They still narrow: a word the message lacks rules it out. assert.deepEqual(yield* threadIds("ошибка я"), []); + assert.deepEqual(yield* threadIds("fermée ce"), []); }); yield* assertMatches; yield* sql`DROP TABLE orchestration_v2_message_search`; diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index d3c96a40ce2b8..8679dad2d070b 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -69,8 +69,10 @@ const TokenList = Schema.fromJsonString(Schema.Array(Schema.String)); const SearchRequest = Schema.Struct({ /** The FTS5 query, or null to scan messages instead. */ match: Schema.NullOr(Schema.String), - /** Tokens every hit must contain, checked on the text. */ - substrings: TokenList, + /** Tokens every hit must contain, as typed or as their GLOB folding case and Latin accents. */ + substrings: Schema.fromJsonString( + Schema.Array(Schema.Struct({ token: Schema.String, accentInsensitive: Schema.String })), + ), /** For each short word outside ASCII, the spellings a hit must contain one of. */ shortSpellings: Schema.fromJsonString(Schema.Array(Schema.Array(Schema.String))), projectId: Schema.NullOr(ProjectId), @@ -120,6 +122,37 @@ function shortTokenSpellings(token: string): ReadonlyArray { return [...new Set(spellings)]; } +// Latin-1 Supplement and Extended-A/B/Additional letters that fold to ASCII, +// including Romanian and Vietnamese letters SQLite's lower() leaves alone. +const accentedForms = Map.groupBy( + [ + ...Array.from({ length: 0x250 - 0xc0 }, (_, offset) => String.fromCharCode(0xc0 + offset)), + ...Array.from({ length: 0x1f00 - 0x1e00 }, (_, offset) => String.fromCharCode(0x1e00 + offset)), + ].filter((char) => /^[a-z]$/.test(foldSearchQuery(char))), + (char) => foldSearchQuery(char), +); +// Only text that may hold one of those letters (by UTF-8 lead byte) needs the GLOB: +// lower() already folds ASCII case. +const MESSAGE_HOLDS_ACCENTED_LATIN = [ + ...new Set( + [...accentedForms.values()].flat().map((char) => Buffer.from(char).toString("hex", 0, 1)), + ), +] + .map((lead) => `instr(CAST(message.text AS BLOB), x'${lead}') > 0`) + .join(" OR "); + +/** A GLOB pattern for `token` anywhere, in any case and with or without Latin accents. */ +function accentInsensitiveGlob(token: string): string { + const pattern = Array.from(token, (char) => { + const letter = foldSearchQuery(char); + if (/^[a-z]$/.test(letter)) { + return `[${letter}${letter.toUpperCase()}${accentedForms.get(letter)?.join("") ?? ""}]`; + } + return /[*?[]/.test(char) ? `[${char}]` : char; + }); + return `*${pattern.join("")}*`; +} + /** * The SQL inputs for a message search, or null when the query has no token * long enough to search. Tokens of three or more characters go to the @@ -137,7 +170,10 @@ function buildMessageSearch( const narrowing = short.filter((token) => !NON_ASCII.test(token)); return { match: indexed ? long.map((token) => `"${token.replaceAll('"', '""')}"`).join(" ") : null, - substrings: indexed ? narrowing : [...long, ...narrowing], + substrings: (indexed ? narrowing : [...long, ...narrowing]).map((token) => ({ + token, + accentInsensitive: accentInsensitiveGlob(token), + })), shortSpellings: short.filter((token) => NON_ASCII.test(token)).map(shortTokenSpellings), }; } @@ -176,10 +212,10 @@ export function buildSearchSnippet(text: string, matchStart: number): string { return `${start > 0 ? "…" : ""}${shown.slice(0, end)}${cut ? "…" : ""}`; } -// Filter hidden subagents and side chats before the hit limit so visible matches aren't lost. -// Word starts and ranking follow the title rules in JS. -// Without FTS5 trigrams, or for two-character CJK words, scan the same eligible messages. -// The scan folds ASCII case only, so accent variants and non-ASCII case variants may not match. +// Filter hidden subagents and side chats before the hit limit; JS checks word starts and ranking. +// Both paths narrow ASCII tokens with Latin-1 and Extended-A/B/Additional accent variants. +// Without FTS5 trigrams, or for two-character CJK words, scan the same eligible messages; +// accented queries and non-Latin case can still miss: "fermée"/"fermee", "ОШИБКА"/"ошибка". export const messageHitsQuery = ( sql: SqlClient.SqlClient, { match, substrings, shortSpellings, projectId, excludedThreadIds }: typeof SearchRequest.Encoded, @@ -243,9 +279,33 @@ export const messageHitsQuery = ( -- Answers to an agent's question show inside its question card, where -- neither find nor a jump can reach them. AND messages.message_id NOT LIKE 'async-answer:%' + -- Nor can they reach a rolled-back turn, or a message of a stopped turn + -- that no shown item carries, such as a queued prompt that never ran. + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS runs + WHERE runs.run_id = messages.run_id + AND ( + runs.status = 'rolled_back' + OR ( + runs.status = 'cancelled' + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items AS items + WHERE items.run_id = runs.run_id + AND json_extract(items.payload_json, '$.messageId') = messages.message_id + AND json_extract(items.payload_json, '$.inputIntent') IS NOT 'queued_turn' + ) + ) + ) + ) AND NOT EXISTS ( - SELECT 1 FROM json_each(${substrings}) AS token - WHERE instr(lower(${messageSearchText(sql)}), token.value) = 0 + -- The text read once, not once per check. + SELECT 1 FROM (SELECT ${messageSearchText(sql)} AS text) AS message, + json_each(${substrings}) AS substring + WHERE instr(lower(message.text), json_extract(substring.value, '$.token')) = 0 + AND NOT ( + (${sql.literal(MESSAGE_HOLDS_ACCENTED_LATIN)}) + AND message.text GLOB json_extract(substring.value, '$.accentInsensitive') + ) ) AND NOT EXISTS ( SELECT 1 FROM json_each(${shortSpellings}) AS token diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index d56256372f73e..325d4e4b7aab2 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -19,6 +19,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as TestClock from "effect/testing/TestClock"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -271,6 +272,53 @@ function armRegeneration(input: { readonly command: string; readonly threadId: T }); } +/** Forks a source thread at its middle run, between a rolled-back run and later work. */ +function forkSeededSource(prefix: string, sideChat = false, rollbackPriorRun = true) { + return Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const sourceThreadId = yield* createThread({ + command: `${prefix}:create`, + thread: `${prefix}:source`, + }); + yield* recordRun({ + threadId: sourceThreadId, + runId: RunId.make(`${prefix}:run:0`), + ordinal: 1, + user: "Rolled back attempt", + assistant: "Discarded.", + status: rollbackPriorRun ? "rolled_back" : "completed", + }); + yield* TestClock.adjust(1); + const forkRunId = RunId.make(`${prefix}:run:1`); + yield* recordRun({ + threadId: sourceThreadId, + runId: forkRunId, + ordinal: 2, + user: "Move session storage from Redis to Postgres", + assistant: "Sessions now persist in the Postgres sessions table.", + }); + yield* recordRun({ + threadId: sourceThreadId, + runId: RunId.make(`${prefix}:run:2`), + ordinal: 3, + user: "Later work the fork never saw", + assistant: "Done.", + }); + const forkThreadId = ThreadId.make(`${prefix}:target`); + yield* threads.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`${prefix}:fork`), + sourceThreadId, + targetThreadId: forkThreadId, + sourcePoint: { type: "run", runId: forkRunId }, + sideChat, + createdBy: "user", + creationSource: "web", + }); + return { sourceThreadId, forkRunId, forkThreadId }; + }); +} + describe("formatThreadTitleContext", () => { it("pins the first user message ahead of the retained tail once content stops fitting", () => { const context = ThreadTitleRegeneration.formatThreadTitleContext([ @@ -443,80 +491,251 @@ describe("ThreadTitleRegenerationService", () => { }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("titles a fork's first message with the source history up to the fork point", () => + it.effect.each([ + { name: "its first message", followUp: true }, + { name: "no message of its own yet", followUp: false }, + ])("titles a fork with $name from the source history up to the fork point", ({ followUp }) => Effect.gen(function* () { const harness = makeHarness(); yield* Effect.gen(function* () { const threads = yield* ThreadManagement.ThreadManagementService; const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; - const sourceThreadId = yield* createThread({ - command: "command:title:fork:create", - thread: "thread:title:fork:source", + const { forkThreadId } = yield* forkSeededSource("thread:title:fork"); + const requestId = CommandId.make("command:title:fork:request"); + const messageId = MessageId.make("command:title:fork:request:message"); + if (followUp) { + yield* threads.dispatch({ + type: "message.dispatch", + commandId: requestId, + threadId: forkThreadId, + messageId, + text: "Try SQLite instead", + titleSeed: "Try SQLite instead", + attachments: [], + modelSelection, + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + } else { + yield* armRegeneration({ command: requestId, threadId: forkThreadId }); + } + + yield* titleRegeneration.execute({ + threadId: forkThreadId, + requestId, + kind: followUp ? { type: "initial", messageId } : { type: "regenerate" }, }); - yield* recordRun({ - threadId: sourceThreadId, - runId: RunId.make("run:title:fork:0"), - ordinal: 1, - user: "Rolled back attempt", - assistant: "Discarded.", - status: "rolled_back", + + const call = harness.generateThreadTitle.mock.calls[0]?.[0]; + assert.equal(call?.message, followUp ? "Try SQLite instead" : ""); + assert.equal(call?.forkSource?.title, "Seed title"); + assert.include(call?.forkSource?.history, "Move session storage from Redis to Postgres"); + assert.include(call?.forkSource?.history, "Sessions now persist in the Postgres"); + assert.notInclude(call?.forkSource?.history, "Later work the fork never saw"); + assert.notInclude(call?.forkSource?.history, "Rolled back attempt"); + const fork = yield* threads.getThreadRecords(forkThreadId, []); + assert.equal(fork.thread.title, "Generated title"); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect.each(["plain", "deleted source"] as const)( + "refuses to title an empty thread with %s history", + (history) => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const { forkThreadId } = yield* forkSeededSource("thread:title:empty"); + const { thread } = yield* threads.getThreadProjection(forkThreadId); + const eventSink = yield* EventSink.EventSinkV2; + yield* eventSink.write({ + events: [ + { + id: EventId.make("empty:lineage"), + type: "thread.metadata-updated", + threadId: forkThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...thread, + forkedFrom: + history === "plain" + ? null + : { + type: "run", + threadId: ThreadId.make("absent"), + runId: RunId.make("absent"), + }, + }, + }, + ], + }); + const requestId = yield* armRegeneration({ + command: "empty:request", + threadId: forkThreadId, + }); + yield* titleRegeneration.execute({ + threadId: forkThreadId, + requestId, + kind: { type: "regenerate" }, + }); + assert.equal(harness.generateThreadTitle.mock.calls.length, 0); + const fork = yield* threads.getThreadRecords(forkThreadId, []); + assert.deepInclude(fork.thread.lastTitleRegeneration, { + requestId, + status: "failed", + message: "This thread has no messages to title yet.", + }); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("keeps the fork point when the source rolls it back after forking", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const { sourceThreadId, forkRunId, forkThreadId } = yield* forkSeededSource( + "thread:title:rollback", + false, + false, + ); + yield* TestClock.adjust(1); + const source = yield* threads.getThreadRecords(sourceThreadId, ["runs"]); + const run = source.runs.find((run) => run.id === forkRunId); + assert.isDefined(run); + const now = yield* DateTime.now; + const eventSink = yield* EventSink.EventSinkV2; + yield* eventSink.write({ + events: [ + { + id: EventId.make("source:rollback"), + type: "run.updated", + threadId: sourceThreadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "rolled_back", completedAt: now }, + }, + ], }); - const forkRunId = RunId.make("run:title:fork:1"); - yield* recordRun({ - threadId: sourceThreadId, - runId: forkRunId, - ordinal: 2, - user: "Move session storage from Redis to Postgres", - assistant: "Sessions now persist in the Postgres sessions table.", + const requestId = yield* armRegeneration({ + command: "rollback:request", + threadId: forkThreadId, }); - yield* recordRun({ + yield* titleRegeneration.execute({ + threadId: forkThreadId, + requestId, + kind: { type: "regenerate" }, + }); + const call = harness.generateThreadTitle.mock.calls[0]?.[0]; + assert.include(call?.forkSource?.history, "Move session storage from Redis to Postgres"); + assert.include(call?.forkSource?.history, "Rolled back attempt"); + assert.notInclude(call?.forkSource?.history, "Later work the fork never saw"); + const fork = yield* threads.getThreadRecords(forkThreadId, []); + assert.equal(fork.thread.title, "Generated title"); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("does not inherit imported history when the fork point is missing", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const { sourceThreadId, forkThreadId } = yield* forkSeededSource("thread:title:invalid"); + yield* dispatchUserMessage({ + command: "imported:message", threadId: sourceThreadId, - runId: RunId.make("run:title:fork:2"), - ordinal: 3, - user: "Later work the fork never saw", - assistant: "Done.", + text: "Imported history", }); - const forkThreadId = ThreadId.make("thread:title:fork:target"); - yield* threads.dispatch({ - type: "thread.fork", - commandId: CommandId.make("command:title:fork:fork"), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "run", runId: forkRunId }, - createdBy: "user", - creationSource: "web", + const source = yield* threads.getThreadRecords(sourceThreadId, []); + const fork = yield* threads.getThreadRecords(forkThreadId, []); + const eventSink = yield* EventSink.EventSinkV2; + yield* eventSink.write({ + events: [ + { + id: EventId.make("source:imported"), + type: "thread.metadata-updated", + threadId: sourceThreadId, + occurredAt: yield* DateTime.now, + payload: { ...source.thread, historyOrigin: "v1_import" }, + }, + { + id: EventId.make("fork:missing-run"), + type: "thread.metadata-updated", + threadId: forkThreadId, + occurredAt: yield* DateTime.now, + payload: { + ...fork.thread, + forkedFrom: { + type: "run", + threadId: sourceThreadId, + runId: RunId.make("absent:run"), + }, + }, + }, + ], }); - const requestId = CommandId.make("command:title:fork:message"); - const messageId = MessageId.make("command:title:fork:message:message"); - yield* threads.dispatch({ - type: "message.dispatch", - commandId: requestId, + const requestId = yield* armRegeneration({ + command: "invalid:request", threadId: forkThreadId, - messageId, - text: "Try SQLite instead", - titleSeed: "Try SQLite instead", - attachments: [], - modelSelection, - dispatchMode: { type: "defer_start" }, - createdBy: "user", - creationSource: "web", }); - yield* titleRegeneration.execute({ threadId: forkThreadId, requestId, - kind: { type: "initial", messageId }, + kind: { type: "regenerate" }, + }); + assert.equal(harness.generateThreadTitle.mock.calls.length, 0); + const updated = yield* threads.getThreadRecords(forkThreadId, []); + assert.deepInclude(updated.thread.lastTitleRegeneration, { + requestId, + status: "failed", + message: "This thread has no messages to title yet.", }); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("inherits a side chat's own exchange without its parent's history", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const threads = yield* ThreadManagement.ThreadManagementService; + const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService; + const { forkThreadId: sideChatId } = yield* forkSeededSource( + "thread:title:side-ancestor", + true, + ); + const runId = RunId.make("side:run"); + yield* recordRun({ + threadId: sideChatId, + runId, + ordinal: 1, + user: "Side topic", + assistant: "Side answer", + }); + const threadId = ThreadId.make("side:fork"); + yield* threads.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side:fork"), + sourceThreadId: sideChatId, + targetThreadId: threadId, + sourcePoint: { type: "run", runId }, + title: "Side fork", + createdBy: "user", + creationSource: "web", + }); + const requestId = yield* armRegeneration({ command: "side:request", threadId }); + yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } }); const call = harness.generateThreadTitle.mock.calls[0]?.[0]; - assert.equal(call?.message, "Try SQLite instead"); - assert.equal(call?.forkSource?.title, "Seed title"); - assert.include(call?.forkSource?.history, "Move session storage from Redis to Postgres"); - assert.include(call?.forkSource?.history, "Sessions now persist in the Postgres"); - assert.notInclude(call?.forkSource?.history, "Later work the fork never saw"); - assert.notInclude(call?.forkSource?.history, "Rolled back attempt"); - const fork = yield* threads.getThreadProjection(forkThreadId); - assert.equal(fork.thread.title, "Generated title"); + assert.equal(call?.forkSource?.title, "Seed title fork"); + assert.include(call?.forkSource?.history, "Side topic"); + assert.include(call?.forkSource?.history, "Side answer"); + assert.notInclude(call?.forkSource?.history, "Move session storage"); }).pipe(Effect.provide(harness.layer)); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index d3216b1cbd938..725a15aa136e0 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -14,6 +14,7 @@ import { import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import type * as DateTime from "effect/DateTime"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -24,6 +25,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import type { OrchestratorV2Error } from "./Orchestrator.ts"; import * as ProjectStore from "./ProjectStore.ts"; +import { forkInheritedRunOrdinals, ProjectionStoreThreadNotFoundError } from "./ProjectionStore.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; import { formatThreadTitleContext } from "@t3tools/provider-core/server/threadTitleContext"; @@ -55,6 +57,7 @@ type Outcome = const MAX_FAILURE_MESSAGE_LENGTH = 300; const isTextGenerationError = Schema.is(TextGenerationError); +const isProjectionStoreThreadNotFoundError = Schema.is(ProjectionStoreThreadNotFoundError); function failureMessage(cause: Cause.Cause): string { const error = Cause.squash(cause); @@ -103,16 +106,13 @@ const make = Effect.gen(function* () { .pipe(Effect.asVoid); const messagesThroughRun = Effect.fn("ThreadTitleRegenerationService.messagesThroughRun")( - function* (threadId: ThreadId, runId: RunId) { + function* (threadId: ThreadId, runId: RunId, forkCreatedAt: DateTime.Utc) { const source = yield* threads.getThreadRecords(threadId, ["messages", "runs"], { messageRoles: ["user", "assistant"], }); - const ordinals = new Map( - source.runs - .filter((run) => run.status !== "rolled_back") - .map((run) => [run.id, run.ordinal]), - ); - const forkOrdinal = ordinals.get(runId) ?? -1; + const ordinals = forkInheritedRunOrdinals(source.runs, forkCreatedAt); + const forkOrdinal = ordinals.get(runId); + if (forkOrdinal === undefined) return undefined; const messages = source.messages.filter((message) => { if (message.streaming) return false; if (message.runId === null) return source.thread.historyOrigin === "v1_import"; @@ -128,12 +128,20 @@ const make = Effect.gen(function* () { ) { let title: string | undefined; const history: Array> = []; - let forkedFrom = thread.forkedFrom; - while (forkedFrom?.type === "run") { - const source = yield* messagesThroughRun(forkedFrom.threadId, forkedFrom.runId); + const seen = new Set([thread.id]); + let current = thread; + while ( + current.forkedFrom?.type === "run" && + current.lineage.sideChat !== true && + !seen.has(current.forkedFrom.threadId) + ) { + const { threadId, runId } = current.forkedFrom; + seen.add(threadId); + const source = yield* messagesThroughRun(threadId, runId, current.createdAt); + if (source === undefined) break; history.push(source.messages); title ??= source.thread.title; - forkedFrom = source.thread.forkedFrom; + current = source.thread; } const context = formatThreadTitleContext(history.reverse().flat()); return title === undefined || context.message.length === 0 @@ -182,17 +190,25 @@ const make = Effect.gen(function* () { projection.messages.filter((message) => !message.streaming), ); } - if (context.message.length === 0 && context.attachments.length === 0) { + // A deleted source should not cost the fork its title. + const inherited = yield* forkSource(projection.thread).pipe( + Effect.catchTags({ + OrchestratorProjectionError: (error) => + isProjectionStoreThreadNotFoundError(error.cause) + ? Effect.undefined + : Effect.fail(error), + }), + ); + if ( + context.message.length === 0 && + context.attachments.length === 0 && + inherited === undefined + ) { return { status: "failed" as const, message: "This thread has no messages to title yet.", }; } - - // A deleted source should not cost the fork its title. - const inherited = yield* forkSource(projection.thread).pipe( - Effect.orElseSucceed(() => undefined), - ); const settings = resolveProjectSettings( yield* serverSettings.getSettings, projection.thread.projectId, diff --git a/apps/server/src/orchestration-v2/TurnStartReads.test.ts b/apps/server/src/orchestration-v2/TurnStartReads.test.ts index cfc362c9db080..ed2484ae9b19e 100644 --- a/apps/server/src/orchestration-v2/TurnStartReads.test.ts +++ b/apps/server/src/orchestration-v2/TurnStartReads.test.ts @@ -352,6 +352,37 @@ it.effect.each(["sqlite"] as const)( (yield* store.getTurnStartContext(threadId, runId)).messages.map((m) => m.id), marker.id, ); + const originalId = MessageId.make("unsent-original"); + yield* store.apply({ + id: EventId.make("unsent-original-event"), + type: "message.updated", + threadId, + runId: oldRunId, + occurredAt: now, + payload: { ...message, id: originalId, runId: oldRunId, text: "Original request" }, + }); + yield* putRun({ + ...run, + id: oldRunId, + ordinal: 1, + status: "interrupted", + userMessageId: originalId, + }); + const retryId = RunId.make("unsent-retry"); + yield* putRun({ ...run, ordinal: 3, manualContinuationOfRunId: retryId }); + yield* putRun({ + ...run, + id: retryId, + ordinal: 2, + status: "interrupted", + manualContinuationOfRunId: oldRunId, + }); + assert.equal( + (yield* store.getTurnStartContext(threadId, runId)).messages.find( + (m) => m.id === originalId, + )?.text, + "Original request", + ); const other = ThreadId.make("other"); yield* putThread({ ...thread, id: other }); assert.deepEqual((yield* store.getTurnStartContext(other, runId)).messages, []); diff --git a/apps/server/src/orchestration-v2/manualContinuation.ts b/apps/server/src/orchestration-v2/manualContinuation.ts new file mode 100644 index 0000000000000..9119e2aa7dad5 --- /dev/null +++ b/apps/server/src/orchestration-v2/manualContinuation.ts @@ -0,0 +1,35 @@ +import type { OrchestrationV2Run, OrchestrationV2ThreadProjection } from "@t3tools/contracts"; + +export function unsentManualContinuationSource( + run: OrchestrationV2Run | undefined, + projection: Pick, +) { + let sourceId = run?.manualContinuationOfRunId; + if (sourceId === undefined) return undefined; + const runs = new Map(projection.runs.map((source) => [source.id, source])); + const acceptedAttempts = new Set(projection.providerTurns.map((turn) => turn.runAttemptId)); + const latestAttempt = ( + runId: OrchestrationV2Run["id"], + matches: (attempt: (typeof projection.attempts)[number]) => boolean, + ) => + Math.max( + 0, + ...projection.attempts + .filter((attempt) => attempt.runId === runId && matches(attempt)) + .map((attempt) => attempt.attemptOrdinal), + ); + const visited = new Set(); + while (sourceId !== undefined && !visited.has(sourceId)) { + visited.add(sourceId); + const source = runs.get(sourceId); + if (source === undefined) return undefined; + const accepted = latestAttempt(sourceId, (attempt) => acceptedAttempts.has(attempt.id)); + // An accepted attempt delivered everything before it; only a later steering restart's message is unsent. + const restarted = latestAttempt(sourceId, (attempt) => attempt.reason === "steering_restart"); + if (restarted > accepted) return source; + if (accepted > 0) return undefined; + if (source.manualContinuationOfRunId === undefined) return source; + sourceId = source.manualContinuationOfRunId; + } + return undefined; +} diff --git a/apps/server/src/orchestration-v2/messageSearchIndex.ts b/apps/server/src/orchestration-v2/messageSearchIndex.ts index 87d7256b6809d..c35f35eac37d1 100644 --- a/apps/server/src/orchestration-v2/messageSearchIndex.ts +++ b/apps/server/src/orchestration-v2/messageSearchIndex.ts @@ -18,7 +18,7 @@ export const nonPlaceholderMessageCondition = (sql: SqlClient.SqlClient, row = " /** * Exclude attachment placeholders, generated notices and subagent threads. ACP subagents - * rewrite their finished answer on every streamed chunk. + * rewrite their finished answer while it streams. * Migration triggers, backfill, rebuild and scans share this predicate; `row` may be `new`. */ export const indexedMessageCondition = (sql: SqlClient.SqlClient, row = "messages") => diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 5678cb1325d5e..5827cc8bff27b 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -491,7 +491,8 @@ const layerProjectDeletionTest = Layer.mergeAll( Layer.provide(McpProviderSessions.layer), Layer.provide(SqlitePersistence.layerMemory), Layer.provide(layerCheckpointStoreTest), - Layer.provide(layerServerConfig), + // Exposed so tests can reach the attachments directory that resource cleanup removes from. + Layer.provideMerge(layerServerConfig), Layer.provide(ServerSettings.layerTest()), Layer.provide(layerTestProviderInstanceRegistry), Layer.provide(layerGitWorkflowTest), @@ -680,6 +681,147 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }), ); + it.effect.each(["no fork", "a live fork"] as const)( + "a queued edit's cleanup keeps a file earlier history still shows, with %s", + (fork) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const key = `queued-edit-shared-file-${fork.replaceAll(" ", "-")}`; + const threadId = ThreadId.make(key); + const storedFile = (name: string) => + ({ + type: "image", + id: createAttachmentId(threadId)!, + name: `${name}.png`, + mimeType: "image/png", + sizeBytes: 5, + }) as const; + const shared = storedFile("shared"); + const dropped = storedFile("dropped"); + const filePath = (file: typeof shared) => + path.join(config.attachmentsDir, `${file.id}.png`); + yield* fileSystem.writeFileString(filePath(shared), "bytes"); + yield* fileSystem.writeFileString(filePath(dropped), "bytes"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${key}:create`), + createdBy: "user", + creationSource: "web", + threadId, + projectId: ProjectId.make("queued-edit-shared-file-project"), + title: "Shared file", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const writeRun = Effect.fn("test.writeQueuedEditRun")(function* (input: { + readonly name: string; + readonly ordinal: number; + readonly status: "completed" | "queued"; + readonly attachments: ReadonlyArray; + }) { + const now = yield* DateTime.now; + const runId = RunId.make(`${key}:${input.name}`); + const messageId = MessageId.make(`${key}:${input.name}`); + const completed = input.status === "completed"; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${key}:${input.name}:run`), + type: "run.created", + threadId, + runId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: input.ordinal, + providerInstanceId: modelSelection.instanceId, + modelSelection, + providerThreadId: null, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: input.status, + requestedAt: now, + startedAt: completed ? now : null, + completedAt: completed ? now : null, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + id: EventId.make(`${key}:${input.name}:message`), + type: "message.updated", + threadId, + runId, + occurredAt: now, + payload: { + id: messageId, + threadId, + runId, + nodeId: null, + role: "user", + text: `Send ${input.name} work`, + attachments: input.attachments, + streaming: false, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + }, + }, + ], + }); + return runId; + }); + + const sourceRunId = yield* writeRun({ + name: "source", + ordinal: 1, + status: "completed", + attachments: [shared], + }); + if (fork !== "no fork") { + const forkThreadId = ThreadId.make(`${key}:fork`); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`${key}:fork`), + sourceThreadId: threadId, + targetThreadId: forkThreadId, + sourcePoint: { type: "run", runId: sourceRunId }, + createdBy: "user", + creationSource: "web", + }); + } + const queuedRunId = yield* writeRun({ + name: "queued", + ordinal: 2, + status: "queued", + attachments: [shared, dropped], + }); + yield* orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make(`${key}:edit`), + threadId, + runId: queuedRunId, + text: "Send queued work without files", + attachments: [], + }); + yield* worker.drain(); + + assert.isFalse(yield* fileSystem.exists(filePath(dropped))); + assert.equal(yield* fileSystem.readFileString(filePath(shared)), "bytes"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("waits for an in-flight thread update before planning deletion", () => Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; @@ -9520,7 +9662,23 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect("edits and removes queued runs", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; const threadId = ThreadId.make("runtime-layer-queued-edit-thread"); + const attachment = (name: string) => + ({ + type: "image", + id: `runtime-layer-queued-edit-${name}`, + name: `${name}.png`, + mimeType: "image/png", + sizeBytes: 128, + }) as const; + const kept = attachment("kept"); + const shared = attachment("shared"); + const dropped = attachment("dropped"); + const effectRequests = (commandId: string) => + Effect.map(outbox.listByCommandId(CommandId.make(commandId)), (effects) => + effects.map((effect) => effect.request), + ); yield* orchestrator.dispatch({ type: "thread.create", @@ -9544,7 +9702,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, messageId: MessageId.make("runtime-layer-queued-edit-active-message"), text: "Keep the provider occupied.", - attachments: [], + attachments: [shared], modelSelection, dispatchMode: { type: "start_immediately" }, }); @@ -9589,22 +9747,33 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, runId: queuedRun.id, text: "Updated queued text with an attachment.", - attachments: [ - { - type: "image", - id: "runtime-layer-queued-edit-attachment", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 128, - }, - ], + attachments: [kept, shared, dropped], + }); + yield* orchestrator.dispatch({ + type: "queued-run.edit", + commandId: CommandId.make("runtime-layer-queued-edit-drop-attachments"), + threadId, + runId: queuedRun.id, + text: "Updated queued text with an attachment.", + attachments: [kept], }); const afterAttachmentEdit = yield* orchestrator.getThreadProjection(threadId); assert.deepEqual( afterAttachmentEdit.messages .find((message) => message.id === queuedRun.userMessageId) ?.attachments.map((attachment) => attachment.id), - ["runtime-layer-queued-edit-attachment"], + [kept.id], + ); + assert.deepEqual( + yield* effectRequests("runtime-layer-queued-edit-drop-attachments"), + [ + { + type: "attachment.cleanup", + attachmentIds: [shared.id, dropped.id], + skipReferenced: true, + }, + ], + "dropped files are queued for a cleanup that skips files the thread still references", ); yield* orchestrator.dispatch({ @@ -9619,9 +9788,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { afterTextOnlyEdit.messages .find((message) => message.id === queuedRun.userMessageId) ?.attachments.map((attachment) => attachment.id), - ["runtime-layer-queued-edit-attachment"], + [kept.id], "an edit without attachments must leave the stored attachments untouched", ); + assert.deepEqual(yield* effectRequests("runtime-layer-queued-edit-text-only"), []); const emptyEditError = yield* orchestrator .dispatch({ @@ -11000,6 +11170,17 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.lengthOf(after.runs, 3); assert.equal(after.runs[1]?.status, "queued"); assert.equal(after.runs[2]?.status, "starting"); + assert.equal(after.runs[2]?.manualContinuationOfRunId, source.id); + const resumeMessage = after.messages.find( + (message) => message.id === resume("first").messageId, + ); + assert.equal(resumeMessage?.text, ""); + assert.equal(resumeMessage?.notification?.summary, "Resumed thread"); + assert.isFalse( + after.turnItems.some( + (item) => item.runId === after.runs[2]?.id && item.type === "user_message", + ), + ); assert.equal( (yield* orchestrator.dispatch(resume("second")).pipe(Effect.exit))._tag, "Failure", @@ -11023,6 +11204,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { "replacement", "manual-snooze", "invalid-snooze", + "settled", "cancel-resume-keep-snooze", "independent-patches", "expired-snooze", @@ -11176,6 +11358,38 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.isNull(current.thread.snoozedUntil); return; } + if (scenario === "settled") { + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:arm-before-settle:settled"), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true, snooze: true }, + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("recovery:settle:settled"), + threadId, + }); + const error = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:arm:settled"), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }) + .pipe(Effect.flip); + assert(error._tag === "OrchestratorDispatchError"); + assert.equal(error.cause, "The thread is settled. Un-settle it to resume at the reset."); + assert.deepInclude( + (yield* orchestrator.getThreadProjection(threadId)).thread.limitRecovery, + { + runId: run.id, + autoResume: false, + snooze: false, + }, + ); + return; + } const snooze = scenario === "wake" || scenario === "cancel-resume-keep-snooze"; const autoResume = scenario !== "wake"; const arm = limitRecoveryCommand(shell, autoResume, DateTime.toEpochMillis(now), snooze); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 4ff907cfe01fb..cf95be1e4350f 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -344,6 +344,7 @@ const layerEffectExecutorProvided = EffectWorker.layerExecutor.pipe( layerRuntimeRequestServiceProvided, layerThreadTitleRegenerationProvided, layerThreadManagementProvided, + ProjectionStore.layer, ), ), ); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 9f035b7e039b7..0ba9137e45dbc 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -552,6 +552,7 @@ export function layerWithRegistry( layerThreadTitleRegenerationTest, layerServerSettings, layerThreadManagementProvided, + layerStores, ), ), ); diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index ab16197ba05b7..2190e9a50e6a9 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -7,6 +7,7 @@ import { MessageId, NodeId, OrchestratorMcpFailure, + type PeerLinkCreateInput, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -125,74 +126,86 @@ const scope: McpInvocationContext.McpInvocationScope = { issuedAt: 1, }; -/** The laptop: a real orchestrator, its orchestrator toolkit, and its links. */ -const makeLaptop = Effect.gen(function* () { - const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); - // The orchestrator's own database, apart from the one links are kept in. - const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); - const layerDatabase = Layer.succeedContext(database); - const layerOrchestrator = Layer.mergeAll( - layerDatabase, - ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), - ProviderReplayHarness.layerWithRegistry( - { name: "peer-link-requests" }, - ProviderAdapterRegistry.layerFromAdapters([adapter]), - { databaseLayer: layerDatabase, runEffectWorker: false }, - ), - ).pipe( - Layer.provide( - Layer.succeed(ProviderContinuationRequests.ProviderContinuationRequests, { - offer: () => Effect.void, - take: Effect.never, - }), - ), - ); - const layerThreads = ThreadManagement.layer.pipe( - Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), - Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), - ); - const layerHere = McpHttpServer.layerOrchestratorToolkit.pipe( - Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), - Layer.provideMerge(PeerLinkRequests.layer), - Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), - Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), - Layer.provide(Layer.succeedContext(linking)), - Layer.provide(NodeCrypto.layer), - Layer.provideMerge(layerThreads), - Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), - Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), - Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), - Layer.provide(Layer.mock(ProjectService.ProjectService)({})), - Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), - Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), - Layer.provide( - ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-requests-" }).pipe( - Layer.provide(NodeServices.layer), +/** A real orchestrator and links; the wrapper can hold a link in progress. */ +const makeLaptopWith = ( + wrapLinks?: (links: PeerLinks.PeerLinks["Service"]) => PeerLinks.PeerLinks["Service"], +) => + Effect.gen(function* () { + const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); + const answering = + wrapLinks === undefined + ? linking + : Context.add( + linking, + PeerLinks.PeerLinks, + wrapLinks(Context.get(linking, PeerLinks.PeerLinks)), + ); + // The orchestrator's own database, apart from the one links are kept in. + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const layerDatabase = Layer.succeedContext(database); + const layerOrchestrator = Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "peer-link-requests" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, ), - ), - Layer.provide(NodeServices.layer), - Layer.fresh, - ); - const here = yield* Layer.build(layerHere); - const server = Context.get(here, McpServer.McpServer); - return { - links: Context.get(linking, PeerLinks.PeerLinks), - linksSql: Context.get(linking, SqlClient.SqlClient), - requests: Context.get(here, PeerLinkRequests.PeerLinkRequests), - orchestrator: Context.get(here, Orchestrator.OrchestratorV2), - sink: Context.get(here, EventSink.EventSinkV2), - projections: Context.get(here, ProjectionStore.ProjectionStoreV2), - sql: Context.get(database, SqlClient.SqlClient), - call: (args: Record, callScope = scope) => - server - .callTool({ name: "t3_environment_link", arguments: args }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, callScope), - Effect.provideService(McpSchema.McpServerClient, mcpClient), + ).pipe( + Layer.provide( + Layer.succeed(ProviderContinuationRequests.ProviderContinuationRequests, { + offer: () => Effect.void, + take: Effect.never, + }), + ), + ); + const layerThreads = ThreadManagement.layer.pipe( + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), + ); + const layerHere = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provideMerge(PeerLinkRequests.layer), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})), + Layer.provide(Layer.succeedContext(answering)), + Layer.provide(NodeCrypto.layer), + Layer.provideMerge(layerThreads), + Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), + Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), + Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-peer-link-requests-" }).pipe( + Layer.provide(NodeServices.layer), ), - }; -}); + ), + Layer.provide(NodeServices.layer), + Layer.fresh, + ); + const here = yield* Layer.build(layerHere); + const server = Context.get(here, McpServer.McpServer); + return { + links: Context.get(linking, PeerLinks.PeerLinks), + linksSql: Context.get(linking, SqlClient.SqlClient), + requests: Context.get(here, PeerLinkRequests.PeerLinkRequests), + orchestrator: Context.get(here, Orchestrator.OrchestratorV2), + sink: Context.get(here, EventSink.EventSinkV2), + projections: Context.get(here, ProjectionStore.ProjectionStoreV2), + sql: Context.get(database, SqlClient.SqlClient), + call: (args: Record, callScope = scope) => + server + .callTool({ name: "t3_environment_link", arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, callScope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ), + }; + }); +const makeLaptop = makeLaptopWith(); type Laptop = Effect.Success; /** The laptop's thread, mid-turn when its agent asks, in `runtimeMode`. */ @@ -1118,3 +1131,344 @@ it.live( }), ), ); + +const linkCards = (a: Laptop) => + a.orchestrator + .getThreadProjection(threadId) + .pipe( + Effect.map((projection) => + projection.turnItems.flatMap((item) => (item.type === "link_request" ? [item] : [])), + ), + ); + +const ask = (a: Laptop, args: Record) => + Effect.gen(function* () { + const asked = yield* a.call(args); + const { turnItemId } = asked.structuredContent as { readonly turnItemId: TurnItemId }; + return (yield* linkCards(a)).find((card) => card.id === turnItemId)!; + }); + +const linkBox = (a: Laptop, b: ServedPeer, cardId: TurnItemId) => + Effect.gen(function* () { + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.requests.answer({ + threadId, + turnItemId: cardId, + answer: { + type: "link", + urls: [b.url], + access: "full-access", + pairingCode: pairing.credential, + }, + }); + return pairing.credential; + }); + +const recordCard = ( + a: Laptop, + key: string, + fields: { + readonly url?: string; + readonly environmentId?: typeof box.environmentId; + readonly hint?: string; + readonly requestedAccess?: "read-only" | "full-access"; + }, +) => + Effect.gen(function* () { + const id = TurnItemId.make(`turn-item:link-request:${key}`); + yield* a.orchestrator.dispatch({ + type: "link_request.record", + commandId: CommandId.make(`command:pending:${key}`), + threadId, + runId, + nodeId: rootNode, + turnItemId: id, + reason: "", + linkStatus: "pending", + ...fields, + }); + return id; + }); + +it.live("a link ends older cards that asked for the same machine by hint, id or address", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + // The agent first asks by the user's word, then again by the box's address. + const older = yield* ask(a, { + reason: "Delegate to the box.", + hint: "Box", + requestedAccess: "full-access", + clientRequestId: "by-hint", + }); + const byId = yield* ask(a, { + environmentId: box.environmentId, + requestedAccess: "full-access", + clientRequestId: "by-id", + }); + const byAddress = yield* recordCard(a, "by-address", { + url: b.url, + requestedAccess: "full-access", + }); + const newer = yield* ask(a, { + url: b.url, + reason: "Delegate to the box.", + hint: "Box", + requestedAccess: "full-access", + clientRequestId: "by-url", + }); + const credential = yield* linkBox(a, b, newer.id); + + const cards = yield* linkCards(a); + expect(cards.find((card) => card.id === newer.id)).toMatchObject({ + status: "completed", + linkStatus: "linked", + linkedEnvironmentId: box.environmentId, + }); + // The older card keeps what it asked and ends; it no longer asks the user. + expect(cards.find((card) => card.id === older.id)).toMatchObject({ + status: "cancelled", + linkStatus: "cancelled", + hint: "Box", + reason: "Delegate to the box.", + requestedAccess: "full-access", + }); + for (const id of [byId.id, byAddress]) { + expect(cards.find((card) => card.id === id)).toMatchObject({ linkStatus: "cancelled" }); + } + expect((yield* a.orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest).toBeNull(); + expect( + ProjectionStore.threadShellFromProjection( + yield* a.orchestrator.getThreadProjection(threadId), + ).pendingRuntimeRequest, + ).toBeNull(); + // The agent hears of the one link it made, and nothing about the card that link ended. + expect((yield* outcomeMessages(a)).messages).toHaveLength(1); + expect(yield* storedText(a)).not.toContain(credential); + }), + ), +); + +it.live("a link leaves open the cards that asked for something else, or asked later", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + const other = descriptorOf("environment-other", "Other"); + const left = [ + // Another machine, and a word that names neither. + yield* recordCard(a, "other-hint", { hint: "vps", requestedAccess: "full-access" }), + yield* recordCard(a, "no-hint", { requestedAccess: "full-access" }), + // The same word, but for less access than the link was asked with. + yield* recordCard(a, "less-access", { hint: "Box", requestedAccess: "read-only" }), + // The same word, on a card that named another machine. + yield* recordCard(a, "other-id", { + environmentId: other.environmentId, + hint: "Box", + requestedAccess: "full-access", + }), + yield* recordCard(a, "other-address", { + url: "http://127.0.0.1:9", + hint: "Box", + requestedAccess: "full-access", + }), + // A word the label only contains. + yield* recordCard(a, "partial-hint", { hint: "Bo", requestedAccess: "full-access" }), + ]; + const linked = yield* ask(a, { url: b.url, hint: "Box", requestedAccess: "full-access" }); + const later = yield* recordCard(a, "later", { hint: "Box", requestedAccess: "full-access" }); + yield* linkBox(a, b, linked.id); + + const cards = yield* linkCards(a); + expect(cards.find((card) => card.id === linked.id)).toMatchObject({ linkStatus: "linked" }); + for (const id of [...left, later]) { + expect(cards.find((card) => card.id === id)).toMatchObject({ + status: "waiting", + linkStatus: "pending", + }); + } + }), + ), +); + +it.live("a later card's failed link leaves the older card open", () => + withBoth((a, b) => + Effect.gen(function* () { + yield* seedThread(a); + const older = yield* ask(a, { hint: "Box", requestedAccess: "full-access" }); + const byAddress = yield* recordCard(a, "by-address", { + url: b.url, + requestedAccess: "full-access", + }); + const newer = yield* ask(a, { url: b.url, hint: "Box", requestedAccess: "full-access" }); + yield* a.requests.answer({ + threadId, + turnItemId: newer.id, + answer: { type: "link", urls: [b.url], access: "full-access", pairingCode: "not-a-code" }, + }); + + const cards = yield* linkCards(a); + expect(cards.find((card) => card.id === newer.id)).toMatchObject({ linkStatus: "failed" }); + for (const id of [older.id, byAddress]) { + expect(cards.find((card) => card.id === id)).toMatchObject({ + status: "waiting", + linkStatus: "pending", + }); + } + expect((yield* a.orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest).toMatchObject( + { kind: "user_input" }, + ); + + // The older card still takes its answer, and the failed one stays as it ended. + yield* linkBox(a, b, older.id); + const after = yield* linkCards(a); + expect(after.find((card) => card.id === older.id)).toMatchObject({ linkStatus: "linked" }); + expect(after.find((card) => card.id === newer.id)).toMatchObject({ linkStatus: "failed" }); + }), + ), +); + +const withHeldLinks = ( + hold: (access: PeerLinkCreateInput["access"]) => Effect.Effect | undefined, + body: (a: Laptop, b: ServedPeer) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveBox; + const a = yield* makeLaptopWith((links) => ({ + ...links, + link: (input) => + (hold(input.access) ?? Effect.void).pipe(Effect.andThen(links.link(input))), + })); + return yield* body(a, b); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)); + +it.live("an older card answered while a newer one links the same machine keeps its own link", () => + Effect.gen(function* () { + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const fullAccessHeld = Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + yield* withHeldLinks( + (access) => (access === "full-access" ? fullAccessHeld : undefined), + (a, b) => + Effect.gen(function* () { + yield* seedThread(a); + // Both cards suggest the same machine and access. + const older = yield* ask(a, { + url: b.url, + requestedAccess: "full-access", + clientRequestId: "older", + }); + const newer = yield* ask(a, { + url: b.url, + requestedAccess: "full-access", + clientRequestId: "newer", + }); + const olderCode = yield* b.auth.issuePairingCredential(); + const newerCode = yield* b.auth.issuePairingCredential(); + + // The user grants full access on the older card, which stalls while linking. + const olderAnswer = yield* a.requests + .answer({ + threadId, + turnItemId: older.id, + answer: { + type: "link", + urls: [b.url], + access: "full-access", + pairingCode: olderCode.credential, + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(reached); + + // Meanwhile the newer card links with read-only access and finishes. + yield* a.requests.answer({ + threadId, + turnItemId: newer.id, + answer: { + type: "link", + urls: [b.url], + access: "read-only", + pairingCode: newerCode.credential, + }, + }); + // The older card is still being answered, so the newer link leaves it open. + expect((yield* linkCards(a)).find((card) => card.id === older.id)).toMatchObject({ + status: "waiting", + linkStatus: "pending", + }); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(olderAnswer); + + const cards = yield* linkCards(a); + const stored = Option.getOrThrow(yield* a.links.get(box.environmentId)); + expect(cards.find((card) => card.id === newer.id)).toMatchObject({ + status: "completed", + linkStatus: "linked", + linkedAccess: "read-only", + }); + // The older card says what the link the box now has grants, and the agent hears it. + expect(stored.access).toBe("full-access"); + expect(cards.find((card) => card.id === older.id)).toMatchObject({ + status: "completed", + linkStatus: "linked", + linkedEnvironmentId: stored.environmentId, + linkedAccess: stored.access, + }); + const { messages } = yield* outcomeMessages(a); + expect(messages.map((message) => message.notification?.outcome)).toEqual([ + "completed", + "completed", + ]); + }), + ); + }), +); + +it.live("a card a newer link ended links nothing when answered after", () => + Effect.gen(function* () { + let links = 0; + yield* withHeldLinks( + () => Effect.sync(() => void links++), + (a, b) => + Effect.gen(function* () { + yield* seedThread(a); + const older = yield* ask(a, { + url: b.url, + requestedAccess: "full-access", + clientRequestId: "older", + }); + const newer = yield* ask(a, { + url: b.url, + requestedAccess: "full-access", + clientRequestId: "newer", + }); + yield* linkBox(a, b, newer.id); + expect(links).toBe(1); + + const pairing = yield* b.auth.issuePairingCredential(); + const late = yield* a.requests + .answer({ + threadId, + turnItemId: older.id, + answer: { + type: "link", + urls: [b.url], + access: "read-only", + pairingCode: pairing.credential, + }, + }) + .pipe(Effect.flip); + expect(late.reason).toBe("already_answered"); + expect(links).toBe(1); + expect(Option.getOrThrow(yield* a.links.get(box.environmentId)).access).toBe( + "full-access", + ); + }), + ); + }), +); diff --git a/apps/server/src/peer/PeerLinkRequests.ts b/apps/server/src/peer/PeerLinkRequests.ts index 3d1129a599f84..a33bb47d7ec17 100644 --- a/apps/server/src/peer/PeerLinkRequests.ts +++ b/apps/server/src/peer/PeerLinkRequests.ts @@ -118,6 +118,7 @@ const make = Effect.gen(function* () { outcome: LinkRequestOutcome, commandId: CommandId, wake?: LinkRequestWake, + answeringTurnItemIds?: ReadonlyArray, ) => threadManagement.dispatch({ type: "link_request.record", @@ -133,6 +134,7 @@ const make = Effect.gen(function* () { ...(card.requestedAccess === undefined ? {} : { requestedAccess: card.requestedAccess }), ...outcome, ...(wake === undefined ? {} : { wake }), + ...(answeringTurnItemIds === undefined ? {} : { answeringTurnItemIds }), }); const linkedOutcome = (linked: PeerLink): LinkRequestOutcome => ({ @@ -145,6 +147,11 @@ const make = Effect.gen(function* () { // One answer per card at a time: two answers racing on one card must not // both link. Other cards go on while a slow peer holds one. const answerLocks = yield* KeyedLock.make(); + // Per thread, held only to read a card before answering and to record the + // answer, never while linking. A link ends older cards for the same + // machine, but not one already being answered (its key is in answerLocks); + // an answer that starts after that reads the ended card and links nothing. + const lifecycleLocks = yield* KeyedLock.make(); /** * Closes a card still waiting when its agent retries naming a machine linked @@ -153,22 +160,27 @@ const make = Effect.gen(function* () { const closeAsLinked = (threadId: ThreadId, turnItemId: TurnItemId, linked: PeerLink) => answerLocks.withLock( turnItemId, - Effect.gen(function* () { - const card = yield* readCard(threadId, turnItemId); - if ( - card === undefined || - card.linkStatus !== "pending" || - card.runId === null || - card.nodeId === null - ) { - return; - } - yield* record( - { ...card, runId: card.runId, nodeId: card.nodeId }, - linkedOutcome(linked), - CommandId.make(`link-request:${card.id}:linked-on-retry`), - ); - }), + lifecycleLocks.withLock( + threadId, + Effect.gen(function* () { + const card = yield* readCard(threadId, turnItemId); + if ( + card === undefined || + card.linkStatus !== "pending" || + card.runId === null || + card.nodeId === null + ) { + return; + } + yield* record( + { ...card, runId: card.runId, nodeId: card.nodeId }, + linkedOutcome(linked), + CommandId.make(`link-request:${card.id}:linked-on-retry`), + undefined, + yield* answerLocks.activeKeys, + ); + }), + ), ); const request: PeerLinkRequests["Service"]["request"] = Effect.fn("PeerLinkRequests.request")( @@ -340,17 +352,23 @@ const make = Effect.gen(function* () { "orchestration_v2.thread_id": input.threadId, "link_request.answer": input.answer.type, }); - const card = yield* readCard(input.threadId, input.turnItemId).pipe( - Effect.mapError((cause) => new PeerLinkRequestError({ reason: "load_failed", cause })), + const card = yield* lifecycleLocks.withLock( + input.threadId, + Effect.gen(function* () { + const card = yield* readCard(input.threadId, input.turnItemId).pipe( + Effect.mapError((cause) => new PeerLinkRequestError({ reason: "load_failed", cause })), + ); + if (card === undefined || card.runId === null || card.nodeId === null) { + return yield* new PeerLinkRequestError({ reason: "not_found" }); + } + // Any time while the card is open: the agent hears the answer as a + // notification, whether or not its run is still going. + if (card.linkStatus !== "pending") { + return yield* new PeerLinkRequestError({ reason: "already_answered" }); + } + return { ...card, runId: card.runId, nodeId: card.nodeId }; + }), ); - if (card === undefined || card.runId === null || card.nodeId === null) { - return yield* new PeerLinkRequestError({ reason: "not_found" }); - } - // Any time while the card is open: the agent hears the answer as a - // notification, whether or not its run is still going. - if (card.linkStatus !== "pending") { - return yield* new PeerLinkRequestError({ reason: "already_answered" }); - } // An id beside an address is what answered there, recorded by an older // server. Linking that machine could send its pairing code to the address. const outcome: LinkRequestOutcome = @@ -365,16 +383,22 @@ const make = Effect.gen(function* () { : yield* answerOutcome(input.answer); // If Stop closed the card while linking, the card keeps that; the link // itself stands and Settings lists it. - yield* record( - { ...card, runId: card.runId, nodeId: card.nodeId }, - outcome, - CommandId.make(`link-request:${card.id}:${outcome.linkStatus}`), - { - messageId: MessageId.make(`message:link-request:${card.id}`), - ...outcomeNotice(card, outcome), - }, - ).pipe( - Effect.mapError((cause) => new PeerLinkRequestError({ reason: "record_failed", cause })), + yield* lifecycleLocks.withLock( + input.threadId, + Effect.flatMap(answerLocks.activeKeys, (answering) => + record( + card, + outcome, + CommandId.make(`link-request:${card.id}:${outcome.linkStatus}`), + { + messageId: MessageId.make(`message:link-request:${card.id}`), + ...outcomeNotice(card, outcome), + }, + answering, + ), + ).pipe( + Effect.mapError((cause) => new PeerLinkRequestError({ reason: "record_failed", cause })), + ), ); }, (effect, input) => diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b92eaea482e02..f983f29f70ac5 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -32,7 +32,7 @@ import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import type { BrowserContext, Page } from "playwright-core"; +import { chromium, type BrowserContext, type Page } from "playwright-core"; import { beforeEach, expect, vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; @@ -53,6 +53,7 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ this.onClose = options.onContextClose; } async contextFor(profileId: string, isolationKey?: string) { + if (realContextOverride) return realContextOverride; contextRequests.push({ profileId, isolated: isolationKey !== undefined }); if (contextFailure) throw contextFailure; await contextGate?.promise; @@ -123,15 +124,18 @@ function makeSession( if (method === "Runtime.callFunctionOn") return { result: { value: PNG_HEADER } }; if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { + const worldContexts: Record = { + "t3-preview-viewport": 9, + "t3-preview-title": 11, + "t3-preview": 1, + }; return { - executionContextId: - (input as { worldName?: string }).worldName === "t3-preview-viewport" - ? 9 - : (input as { worldName?: string }).worldName === "t3-preview" - ? 1 - : 7, + executionContextId: worldContexts[(input as { worldName: string }).worldName] ?? 7, }; } + // A title watch settles only when the document's title changes. + if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 11) + return new Promise(() => {}); if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { return { result: { value: [] } }; } @@ -384,6 +388,7 @@ function makeEncoderPage() { const contexts: ReturnType[] = []; let encoderPageOverride: Page | undefined; +let realContextOverride: BrowserContext | undefined; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; let decodeGate: PromiseWithResolvers | null = null; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; @@ -579,6 +584,7 @@ const openDesktopViewer = Effect.gen(function* () { beforeEach(() => { contexts.length = 0; encoderPageOverride = undefined; + realContextOverride = undefined; encoderStreams.length = 0; decodeGate = null; contextRequests.length = 0; @@ -1587,6 +1593,30 @@ it.effect("a first still that never returns does not stall the tab's later captu ).pipe(Effect.provide(layer)), ); +it.effect("keeps a tab's zoom when its viewer resizes", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const { tabId } = yield* manager.open({ + threadId: scope.thread.threadId, + runtime: "server", + zoomFactor: 1.5, + }); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + const cdp = contexts[0]!.sessions[0]!; + cdp.send.mockClear(); + // Resizing the page resets Chromium's device metrics, so zoom must be applied again. + yield* viewer.input({ type: "resize", width: 390, height: 844 }); + expect(cdp.send).toHaveBeenCalledWith( + "Emulation.setDeviceMetricsOverride", + expect.objectContaining({ deviceScaleFactor: 3 }), + ); + }), + ).pipe(Effect.provide(layer)), +); + it.effect( "streams to a read-only viewer without allowing takeover, input, or viewport changes", () => @@ -2036,47 +2066,50 @@ it.effect( ).pipe(Effect.provide(layer)), ); -it.effect("a navigation that fails while a load reads its title keeps its failure", () => - Effect.scoped( - Effect.gen(function* () { - const { tabId } = yield* ready; - const manager = yield* Manager.PreviewManager; - const events = yield* manager.subscribeEvents; - const page = contexts[0]!.page; - const heldTitle = Promise.withResolvers(); - page.title.mockImplementationOnce(() => heldTitle.promise); - yield* Effect.promise(() => page.goto("https://example.com/first")); - const failing = { - url: () => "https://example.com/missing", - method: () => "GET", - isNavigationRequest: () => true, - frame: () => page, - failure: () => ({ errorText: "net::ERR_NAME_NOT_RESOLVED" }), - }; - page.emit("request", failing); - page.emit("requestfailed", failing); - heldTitle.resolve("Held"); - // A load at the same await depth reports after the held one, so its Success marks the end. - page.emit("load"); - - const statuses: Array = []; - while (!statuses.at(-1)?.startsWith("Success")) { - const event = yield* PubSub.take(events); - if (event.type === "failed" && event.tabId === tabId) { - statuses.push(`LoadFailed ${event.url}`); - } else if (event.type === "navigated" && event.tabId === tabId) { - const status = event.snapshot.navStatus; - if (status._tag === "Success") statuses.push(`Success ${status.title}`); - else if (status._tag === "Loading") statuses.push(`Loading ${status.url}`); +it.effect.each(["before", "during"] as const)( + "a navigation that fails while a load reads its title keeps its failure (request %s read)", + (requestTiming) => + Effect.scoped( + Effect.gen(function* () { + const { tabId } = yield* ready; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + const page = contexts[0]!.page; + const heldTitle = Promise.withResolvers(); + page.title.mockImplementationOnce(() => heldTitle.promise); + const failing = { + url: () => "https://example.com/missing", + method: () => "GET", + isNavigationRequest: () => true, + frame: () => page, + failure: () => ({ errorText: "net::ERR_NAME_NOT_RESOLVED" }), + }; + if (requestTiming === "before") page.emit("request", failing); + yield* Effect.promise(() => page.goto("https://example.com/first")); + if (requestTiming === "during") page.emit("request", failing); + page.emit("requestfailed", failing); + heldTitle.resolve("Held"); + // A load at the same await depth reports after the held one, so its Success marks the end. + page.emit("load"); + + const statuses: Array = []; + while (!statuses.at(-1)?.startsWith("Success")) { + const event = yield* PubSub.take(events); + if (event.type === "failed" && event.tabId === tabId) { + statuses.push(`LoadFailed ${event.url}`); + } else if (event.type === "navigated" && event.tabId === tabId) { + const status = event.snapshot.navStatus; + if (status._tag === "Success") statuses.push(`Success ${status.title}`); + else if (status._tag === "Loading") statuses.push(`Loading ${status.url}`); + } } - } - expect(statuses).toEqual([ - "Loading https://example.com/missing", - "LoadFailed https://example.com/missing", - "Success test page", - ]); - }), - ).pipe(Effect.provide(layer)), + expect(statuses).toEqual([ + "Loading https://example.com/missing", + "LoadFailed https://example.com/missing", + "Success test page", + ]); + }), + ).pipe(Effect.provide(layer)), ); it.effect.each([ @@ -3983,3 +4016,118 @@ it.effect("a popup opens in its opener's appearance", () => }), ).pipe(Effect.provide(layer)), ); + +it.live("a page's title change without navigation reaches its session", () => + Effect.gen(function* () { + const native = yield* Effect.acquireRelease( + Effect.promise(() => chromium.launch({ headless: true })), + (browser) => Effect.promise(() => browser.close()), + ); + const context = yield* Effect.promise(() => native.newContext()); + yield* Effect.promise(() => + context.route("**/*", (route) => + route.fulfill({ contentType: "text/html", body: "Original" }), + ), + ); + let watched = Promise.withResolvers(); + const newSession = context.newCDPSession.bind(context); + vi.spyOn(context, "newCDPSession").mockImplementation(async (page) => { + const cdp = await newSession(page); + const send = cdp.send.bind(cdp); + vi.spyOn(cdp, "send").mockImplementation((...args) => { + const result = send(...args); + if ( + args[0] === "Runtime.evaluate" && + args[1] && + "awaitPromise" in args[1] && + args[1].awaitPromise + ) { + void send("Runtime.evaluate", { expression: "0" }).then( + () => watched.resolve(), + // The browser can close while the last probe is pending. + () => undefined, + ); + } + return result; + }); + return cdp; + }); + realContextOverride = context; + yield* Effect.gen(function* () { + yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const events = yield* manager.subscribeEvents; + yield* manager.open({ + threadId: testThread.threadId, + runtime: "server", + url: "https://fixture.test/", + }); + const successes: Array<{ title: string; url: string; back: boolean; forward: boolean }> = []; + const navigatedTo = Effect.fn(function* (title: string) { + for (;;) { + const event = yield* PubSub.take(events); + if (event.type !== "navigated") continue; + const { navStatus } = event.snapshot; + if (navStatus._tag === "Success") { + successes.push({ + title: navStatus.title, + url: navStatus.url, + back: event.snapshot.canGoBack, + forward: event.snapshot.canGoForward, + }); + if (navStatus.title === title) return navStatus; + } + } + }); + expect((yield* navigatedTo("Original"))._tag).toBe("Success"); + const page = context.pages()[0]!; + yield* Effect.promise(() => watched.promise); + watched = Promise.withResolvers(); + yield* Effect.promise(() => + page.evaluate("document.head.replaceWith(document.head.cloneNode(true))"), + ); + for (const title of ["Replaced", "Updated", "Final"]) { + if (title === "Final") + yield* Effect.promise(() => + page.evaluate( + "document.documentElement.replaceWith(document.documentElement.cloneNode(true))", + ), + ); + yield* Effect.promise(() => + page.evaluate( + `document.title = ${JSON.stringify(title)}; ${title === "Updated" ? "history.pushState({}, '', '/next')" : ""}`, + ), + ); + expect(yield* navigatedTo(title)).toEqual({ + _tag: "Success", + url: title === "Replaced" ? "https://fixture.test/" : "https://fixture.test/next", + title, + }); + yield* Effect.promise(() => watched.promise); + watched = Promise.withResolvers(); + } + expect(successes.filter(({ title }) => title === "Updated")).toEqual([ + { title: "Updated", url: "https://fixture.test/next", back: true, forward: false }, + ]); + yield* Effect.promise(() => + context.route("**/drawing.svg", (route) => + route.fulfill({ + contentType: "image/svg+xml", + body: 'Drawing', + }), + ), + ); + yield* Effect.promise(() => page.goto("https://fixture.test/drawing.svg")); + yield* navigatedTo("Drawing"); + yield* Effect.promise(() => watched.promise); + yield* Effect.promise(() => + page.evaluate("document.querySelector('title').textContent = 'Renamed drawing'"), + ); + expect(yield* navigatedTo("Renamed drawing")).toEqual({ + _tag: "Success", + url: "https://fixture.test/drawing.svg", + title: "Renamed drawing", + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 82d3138bd7a75..e514a8b20af10 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -13,6 +13,7 @@ import { isBuiltInBrowserProfileId, resolveBrowserProfiles, type PreviewReportProfilesInput, + type PreviewReportStatusInput, PREVIEW_AUTOMATION_SERVER_OPERATIONS, PreviewViewportSetting as PreviewViewportSettingSchema, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -55,6 +56,7 @@ import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; import * as NodeCrypto from "node:crypto"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -388,6 +390,9 @@ interface ServerTab { pendingInitialRequestUrl: string | null; navigationGeneration: number; stoppedGeneration: number | null; + /** The reported title a watch in the main document's isolated world waits to change from. */ + titleWatch: { readonly contextId: number; readonly title: string } | null; + lastReport: PreviewReportStatusInput | null; abortedNavigation: { readonly url: string; @@ -478,6 +483,41 @@ const editingCommand = (key: string, modifiers: number) => { return command ? { commands: [command] } : null; }; +const TITLE_LIMIT = 512; +const TITLE_WORLD = "t3-preview-title"; +// Observe root replacement without subscribing to body subtree mutations. +const titleChange = (reported: string) => `new Promise((resolve) => { + const changed = () => document.title.slice(0, ${TITLE_LIMIT}) !== ${JSON.stringify(reported)}; + if (changed()) return resolve(); + let head; + let root; + let title; + const observer = new MutationObserver(() => { + if (changed()) { + observer.disconnect(); + resolve(); + } else if (head !== document.head || root !== document.documentElement || title !== document.querySelector('title')) { + observe(); + } + }); + const observe = () => { + observer.disconnect(); + observer.observe(document, { childList: true }); + root = document.documentElement; + head = document.head; + title = document.querySelector('title'); + if (root) observer.observe(root, { childList: true }); + if (head) observer.observe(head, { subtree: true, childList: true, characterData: true }); + if (title && !head?.contains(title)) { + observer.observe(title, { subtree: true, childList: true, characterData: true }); + for (let ancestor = title.parentElement; ancestor; ancestor = ancestor.parentElement) { + observer.observe(ancestor, { childList: true }); + } + } + }; + observe(); +})`; + const CLIPBOARD_TEXT_LIMIT = 1024 * 1024; const CLIPBOARD_WORLD = "t3-preview-clipboard"; // Chromium shares its clipboard across contexts; capture real copy/cut keys in a page-inaccessible world instead. @@ -684,26 +724,32 @@ const make = Effect.gen(function* () { }; const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { + const generation = tab.navigationGeneration; + const loadError = tab.loadError; void tab.cdp .send("Page.getNavigationHistory") .catch(() => null) .then((history) => { // A status the dead page still had in flight must not replace the crash. if (tab.crash) return; + if ( + navStatus._tag === "Success" && + (tab.navigationGeneration !== generation || tab.loadError !== loadError) + ) + return; const index = history?.currentIndex ?? 0; const count = history?.entries.length ?? 0; - runFork( - manager - .reportStatus({ - threadId: tab.threadId, - tabId: tab.tabId, - serverControlled: true, - navStatus, - canGoBack: index > 0, - canGoForward: index < count - 1, - }) - .pipe(Effect.ignore), - ); + const input = { + threadId: tab.threadId, + tabId: tab.tabId, + serverControlled: true, + navStatus, + canGoBack: index > 0, + canGoForward: index < count - 1, + }; + if (NodeUtil.isDeepStrictEqual(tab.lastReport, input)) return; + tab.lastReport = input; + runFork(manager.reportStatus(input).pipe(Effect.ignore)); }); }; @@ -713,13 +759,53 @@ const make = Effect.gen(function* () { if (url === "about:blank" || url.startsWith("chrome-error://")) return; // A navigation that starts and fails during the title read leaves the URL unchanged. const generation = tab.navigationGeneration; - const title = (await tab.page.title().catch(() => "")).slice(0, 512); - if (tab.navigationGeneration !== generation || tab.loading || tab.page.url() !== url) return; + const loadError = tab.loadError; + const title = (await tab.page.title().catch(() => "")).slice(0, TITLE_LIMIT); + if ( + tab.navigationGeneration !== generation || + tab.loadError !== loadError || + tab.loading || + tab.page.url() !== url + ) + return; report(tab, { _tag: "Success", url: ServerBrowserPage.withoutDataPayload(url).slice(0, 2048), title, }); + void watchTitle(tab, title); + }; + + // A title change fires no page event, so one watch per document reports the next one. + const watchTitle = async (tab: ServerTab, title: string) => { + // Chromium keeps one world per name and document, so a new document gets a new context. + const world = await tab.cdp + .send("Page.getFrameTree") + .then(({ frameTree }) => + tab.cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: TITLE_WORLD, + }), + ) + .catch(() => null); + if (!world) return; + const watch = { contextId: world.executionContextId, title }; + if (tab.titleWatch?.contextId === watch.contextId && tab.titleWatch.title === title) return; + tab.titleWatch = watch; + const changed = await tab.cdp + .send("Runtime.evaluate", { + contextId: watch.contextId, + expression: titleChange(title), + awaitPromise: true, + }) + .then( + ({ exceptionDetails }) => exceptionDetails === undefined, + () => false, + ); + if (tab.titleWatch !== watch) return; + tab.titleWatch = null; + // A dropped tab's page can still change its title while it unloads. + if (changed && !tab.closing && !tab.loadError) await reportLoaded(tab); }; const reportLiveTabs = () => { @@ -1084,6 +1170,8 @@ const make = Effect.gen(function* () { pendingInitialRequestUrl: null, navigationGeneration: 0, stoppedGeneration: null, + titleWatch: null, + lastReport: null, abortedNavigation: null, captureLock: Promise.resolve(), capturing: 0, @@ -2959,6 +3047,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); + await applyZoom(tab); await broadcastViewport(tab); return; } diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 36b133cf185d8..97aaf9c91f2fe 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -765,6 +765,52 @@ describe("server browser drag", () => { }); }); +describe("server browser action timeout", () => { + type StepOptions = { readonly timeout?: number }; + it.each([ + { + action: "hover", + act: (page: Page) => ServerBrowserPage.hover(page, { selector: "#a", timeoutMs: 9_000 }), + last: ["#a.hover 1"], + }, + { + action: "drag", + act: (page: Page) => + ServerBrowserPage.drag(page, { source: "#a", target: "#b", timeoutMs: 9_000 }), + last: ["#a.dragTo 1", "#b.boundingBox 1"], + }, + ])("gives the $action's steps one shared timeout", async ({ act, last }) => { + let now = 0; + using _now = vi.spyOn(Date, "now").mockImplementation(() => now); + const steps: Array = []; + const box = { x: 0, y: 0, width: 20, height: 20 }; + const page = { + locator: (selector: string) => { + const record = (step: string, options: StepOptions) => + steps.push(`${selector}.${step} ${options.timeout}`); + return { + // Each step that finds the target takes 5 of the 9 seconds. + scrollIntoViewIfNeeded: async (options: StepOptions) => { + record("scrollIntoViewIfNeeded", options); + now += 5_000; + }, + boundingBox: async (options: StepOptions) => { + record("boundingBox", options); + now += 5_000; + return box; + }, + hover: async (options: StepOptions) => record("hover", options), + dragTo: async (_target: unknown, options: StepOptions) => record("dragTo", options), + }; + }, + } as unknown as Page; + + await act(page); + + expect(steps).toEqual(["#a.scrollIntoViewIfNeeded 9000", "#a.boundingBox 4000", ...last]); + }); +}); + describe("server browser click", () => { it("clicks a target the page replaced while it was being scrolled into view", async () => { let scrolls = 0; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index bcfbc296a2cb0..657bed9b98822 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -917,14 +917,15 @@ export const hover = async ( input: PreviewAutomationHoverInput, pointer: PointerReporter = noPointer, ) => { - const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const remaining = () => Math.max(1, deadline - Date.now()); const locator = targetLocator(page, input); - await pointer(await targetPoint(page, locator, input, () => timeout), "move"); + await pointer(await targetPoint(page, locator, input, remaining), "move"); if (locator === null) { await page.mouse.move(input.x ?? 0, input.y ?? 0); return; } - await locator.hover({ timeout }); + await locator.hover({ timeout: remaining() }); }; export const select = async ( @@ -967,17 +968,18 @@ export const drag = async ( input: PreviewAutomationDragInput, pointer: PointerReporter = noPointer, ) => { - const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const deadline = Date.now() + (input.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const remaining = () => Math.max(1, deadline - Date.now()); const source = targetLocator(page, { locator: input.source })!; const target = targetLocator(page, { locator: input.target })!; - await pointer(await targetPoint(page, source, {}, () => timeout), "move"); + await pointer(await targetPoint(page, source, {}, remaining), "move"); // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. // Chromium delivers a single move before dragstart; steps give the page mousemoves during the drag. - const dragFailure = source.dragTo(target, { timeout, steps: 5 }).then( + const dragFailure = source.dragTo(target, { timeout: remaining(), steps: 5 }).then( () => null, (error: unknown) => ({ error }), ); - const end = await target.boundingBox({ timeout }).catch(() => null); + const end = await target.boundingBox({ timeout: remaining() }).catch(() => null); if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move"); const failure = await dragFailure; if (failure) throw failure.error; diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index a610a3b1709a3..9b7ec2a1b713f 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -195,6 +195,8 @@ it.effect.skipIf(windowsHost)( const codePath = path.join(binDir, "code"); yield* fileSystem.writeFileString(codePath, "#!/bin/sh\n"); yield* fileSystem.chmod(codePath, 0o755); + const colonProject = path.join(project, "build:2026"); + yield* fileSystem.makeDirectory(colonProject); const file = `${path.join(project, "file.ts")}:3:7`; const spawned: ChildProcess.StandardCommand[] = []; @@ -202,6 +204,7 @@ it.effect.skipIf(windowsHost)( const launcher = yield* ExternalLauncher.ExternalLauncher; yield* launcher.launchEditor({ editor: "vscode", cwd: project }); yield* launcher.launchEditor({ editor: "vscode", cwd: file }); + yield* launcher.launchEditor({ editor: "vscode", cwd: colonProject }); }).pipe( Effect.provide( layerTest({ @@ -218,6 +221,7 @@ it.effect.skipIf(windowsHost)( [ ["--new-window", project], ["--goto", file], + ["--new-window", colonProject], ], ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), @@ -792,17 +796,32 @@ it.effect.skipIf(windowsHost)("does not offer the OpenZFS zed daemon as Zed on L ); yield* fs.chmod(zfsZed, 0o755); const editor = EDITORS.find((editor) => editor.id === "zed")!; - const resolve = resolveEditorCommand(editor, { HOME: home, PATH: binDir }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(Path.Path, withSystemBin(path, systemBin)), - ); - assert.deepEqual(yield* resolve, Option.none()); + const laterBin = path.join(home, "later-bin"); + const resolveOn = (platform: NodeJS.Platform) => + resolveEditorCommand(editor, { HOME: home, PATH: `${binDir}:${laterBin}` }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(Path.Path, withSystemBin(path, systemBin)), + ); + assert.deepEqual(yield* resolveOn("linux"), Option.none()); + assert.deepEqual(yield* resolveOn("darwin"), Option.none()); const zedEditor = path.join(home, ".local/bin/zed"); yield* fs.makeDirectory(path.dirname(zedEditor), { recursive: true }); yield* fs.writeFileString(zedEditor, "#!/bin/sh\necho 'Zed 0.200.0'\n"); yield* fs.chmod(zedEditor, 0o755); - assert.deepEqual(yield* resolve, Option.some({ command: zedEditor, baseArgs: [] })); + assert.deepEqual(yield* resolveOn("linux"), Option.some({ command: zedEditor, baseArgs: [] })); + + yield* fs.remove(zedEditor); + const laterZed = path.join(laterBin, "zed"); + yield* fs.makeDirectory(laterBin, { recursive: true }); + yield* fs.writeFileString(laterZed, "#!/bin/sh\necho 'Zed 0.200.0'\n"); + yield* fs.chmod(laterZed, 0o755); + for (const platform of ["linux", "darwin"] as const) { + assert.deepEqual( + yield* resolveOn(platform), + Option.some({ command: laterZed, baseArgs: [] }), + ); + } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 4eecaf2c5cb2c..bb38a9d56afd4 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -149,9 +149,10 @@ function resolveCommandEditorArgs( case "direct-path": return [target]; case "goto": - if (Option.isSome(parsedTarget)) return ["--goto", target]; + // An existing folder wins over a position: `build:2026` is a legal folder name. // Without a window flag, VS Code's window settings may replace the user's last active window. - return isDirectory ? ["--new-window", target] : [target]; + if (isDirectory) return ["--new-window", target]; + return Option.isSome(parsedTarget) ? ["--goto", target] : [target]; case "line-column": return Option.match(parsedTarget, { onNone: () => [target], diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 3754f25a4c775..6768fa23c763b 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -2,6 +2,8 @@ import { isWindowsCommandNotFound } from "@t3tools/provider-core/server/snapshot import { describe, expect, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; @@ -143,6 +145,29 @@ describe("runProcess", () => { }), ); + it.effect("reports child startup before delivering cancellation during acquisition", () => + Effect.gen(function* () { + let started = false; + const spawner = makeSpawner(() => + Effect.withFiber((fiber) => + Effect.sync(() => { + fiber.interruptUnsafe(); + return makeHandle({}); + }), + ), + ); + const result = yield* runWith(spawner)({ + command: "git", + args: ["restore"], + onSpawn: () => { + started = true; + }, + }).pipe(Effect.exit); + expect(Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)).toBe(true); + expect(started).toBe(true); + }).pipe(Effect.scoped), + ); + it.effect("fails when output exceeds max buffer in default mode", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stdout: "x".repeat(2048) }))); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index a345c67eca881..4c7efd3cc9942 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -26,6 +26,8 @@ export interface ProcessRunInput { readonly stdin?: string | undefined; /** Receives every stdout chunk, including bytes beyond the buffered output limit. */ readonly onStdoutChunk?: ((chunk: Uint8Array) => void) | undefined; + /** Runs after successful spawn, before cancellation can interrupt acquisition. */ + readonly onSpawn?: (() => void) | undefined; readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; @@ -290,7 +292,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( cause, }); - const child = yield* spawner + const spawn = spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { ...((input.spawnCwd ?? input.cwd) ? { cwd: input.spawnCwd ?? input.cwd } : {}), @@ -309,6 +311,12 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( // Synchronous spawn throws arrive as defects; recover them too so callers see typed spawn failures. Effect.catchDefect((cause) => Effect.fail(toSpawnError(cause))), ); + const child = yield* input.onSpawn === undefined + ? spawn + : spawn.pipe( + Effect.tap(() => Effect.sync(() => input.onSpawn?.())), + Effect.uninterruptible, + ); const stdin = input.stdin; const onStdoutChunk = input.onStdoutChunk; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4378a7fde8470..4c6109412d961 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { type AgentSessionSource, @@ -22,17 +23,21 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/sql/SqlClient"; import * as TestClock from "effect/testing/TestClock"; +import * as ServerConfig from "../config.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as EventStore from "../orchestration-v2/EventStore.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { @@ -188,6 +193,10 @@ const makeImporter = Effect.fn(function* ( ServerSettings.ServerSettingsService, ServerSettingsError > = ServerSettings.layerTest(), + realScan?: { + readonly workspaceRoot: string; + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + }, ) { const sql = yield* SqlClient.SqlClient; const events = yield* EventStore.EventStoreV2; @@ -229,15 +238,17 @@ const makeImporter = Effect.fn(function* ( ProviderSessionRuntime.ProviderSessionRuntimeRepository, yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository, ), - Layer.mock(AgentSessionScanner.AgentSessionScanner)({ - recentThreads: () => Stream.fromIterable(candidates), - }), + realScan === undefined + ? Layer.mock(AgentSessionScanner.AgentSessionScanner)({ + recentThreads: () => Stream.fromIterable(candidates), + }) + : Layer.succeed(AgentSessionScanner.AgentSessionScanner, realScan.scanner), Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedSome({ id: projectId, title: "Project", - workspaceRoot: "/workspace/project", + workspaceRoot: realScan?.workspaceRoot ?? "/workspace/project", defaultModelSelection: null, scripts: [], createdAt: timestamp, @@ -377,6 +388,43 @@ it.effect( }).pipe(Effect.scoped, Effect.provide(persistence)), ); +it.effect("tolerates a missing transcript owner but propagates failed ownership reads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = candidate(CODEX_MODEL_SELECTION, "ownership-read"); + const threadId = importId(source); + yield* runtimes.upsert({ + threadId: ThreadId.make("missing-owner"), + providerName: "codex", + providerInstanceId: source.source.providerInstanceId, + adapterKey: "codex", + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: timestamp, + resumeCursor: null, + runtimePayload: { importedTranscripts: [source.source] }, + }); + yield* runtimes.recordImportedTranscript({ + threadId: ThreadId.make("missing-owner"), + source: source.source, + }); + const importer = yield* makeImporter([source]); + expect(yield* importer.run).toEqual({ importedCount: 1, skippedCount: 0 }); + + yield* sql`UPDATE orchestration_v2_projection_threads SET payload_json = '{}' WHERE thread_id = ${threadId}`; + expect(yield* Effect.flip(importer.run)).toMatchObject({ + _tag: "AgentSessionScanError", + operation: "read-projects", + cause: { + _tag: "OrchestratorProjectionError", + threadId, + cause: { _tag: "ProjectionStoreReadError" }, + }, + }); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + it.effect("rechecks ownership after the import reaches the commit transaction", () => Effect.gen(function* () { const enteringTransaction = yield* Deferred.make(); @@ -453,6 +501,39 @@ it.effect( }).pipe(Effect.scoped, Effect.provide(persistence)), ); +it.effect("does not certify new native history after a partial import retry", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const original = candidate(CODEX_MODEL_SELECTION, "partial-retry"); + const threadId = importId(original); + const importer = yield* makeImporter([original]); + yield* sql`CREATE TEMP TRIGGER fail_runtime BEFORE INSERT ON provider_session_runtime BEGIN SELECT RAISE(ABORT, 'test runtime failure'); END`; + expect(yield* importer.run).toEqual({ importedCount: 0, skippedCount: 1 }); + yield* sql`DROP TRIGGER fail_runtime`; + expect(yield* runtimes.list()).toEqual([]); + const continued = candidate(CODEX_MODEL_SELECTION, original.source.providerSessionId); + continued.source.size = 200; + continued.source.mtimeMs = 3; + continued.thread.messages.push( + { role: "user", text: "Continue outside T3", createdAt: timestamp }, + { role: "assistant", text: "Native continuation", createdAt: timestamp }, + ); + // The thread holds only the original messages, so the continued transcript stays unrecorded. + expect(yield* (yield* makeImporter([continued])).run).toEqual({ + importedCount: 0, + skippedCount: 1, + }); + expect(yield* runtimes.list()).toEqual([]); + expect( + (yield* projections.getThreadRecords(threadId, ["messages"])).messages.map( + (message) => message.text, + ), + ).toEqual(["Fix it", "Fixed"]); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); + it.effect("does not record a changed transcript as imported without its new messages", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; @@ -836,3 +917,95 @@ it.effect("imports under a policy ceiling in the project's default, kept under t ); }).pipe(Effect.scoped, Effect.provide(persistence)), ); + +it.effect("repeated imports reach linked-worktree sessions behind the scan budget", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-import-worktree-budget-" }); + const repo = path.join(root, "repo"); + const worktree = path.join(root, "feature"); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "feature"); + yield* fs.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fs.makeDirectory(worktree); + yield* fs.writeFileString(path.join(repo, ".git", "config"), "[core]\n"); + yield* fs.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fs.writeFileString(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature\n"); + yield* fs.writeFileString(path.join(worktree, ".git"), `gitdir: ${worktreeGitDir}\n`); + + const claudeHome = path.join(root, "claude"); + const codexHome = path.join(root, "codex"); + const sessions = path.join(codexHome, "sessions", "2026", "09", "01"); + yield* fs.makeDirectory(sessions, { recursive: true }); + const nowMs = DateTime.toEpochMillis(now); + yield* TestClock.setTime(nowMs); + // One more session than the 100-transcript scan budget, all run in the linked worktree. + for (let index = 0; index < 101; index++) { + const file = path.join(sessions, `rollout-${String(index).padStart(3, "0")}.jsonl`); + const records = [ + { type: "session_meta", payload: { id: `session-${index}`, cwd: worktree } }, + { type: "event_msg", payload: { type: "user_message", message: `Request ${index}` } }, + { + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Done" }], + }, + }, + ]; + yield* fs.writeFileString( + file, + records.map((record) => JSON.stringify(record) + "\n").join(""), + ); + const modifiedAtSeconds = nowMs / 1000 - (index + 1); + yield* fs.utimes(file, modifiedAtSeconds, modifiedAtSeconds); + } + + const scannerContext = yield* Layer.build( + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerConfig.layerTest(claudeHome, { prefix: "t3-import-budget-config-" }), + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + // Keeps the default Pi instance off the developer's real ~/.pi. + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: false, + config: {}, + }, + }, + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed([]) }), + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository, + ), + ), + ), + ), + ); + const importer = yield* makeImporter([], Effect.void, undefined, undefined, undefined, { + workspaceRoot: repo, + scanner: Context.get(scannerContext, AgentSessionScanner.AgentSessionScanner), + }); + + expect(yield* importer.run).toEqual({ importedCount: 100, skippedCount: 1 }); + expect(yield* importer.run).toEqual({ importedCount: 101, skippedCount: 0 }); + const oldest = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.flatMap((projections) => + projections.getThread(ThreadId.make("import:codex:session-100")), + ), + ); + expect(oldest.worktreePath).toBe(worktree); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(persistence, NodeServices.layer))), +); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 6200fc11188fb..0ebd6688a28be 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -35,6 +35,7 @@ import * as Stream from "effect/Stream"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; @@ -42,6 +43,10 @@ import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; import * as ProjectService from "./ProjectService.ts"; +const isProjectionStoreThreadNotFoundError = Schema.is( + ProjectionStore.ProjectionStoreThreadNotFoundError, +); + const IMPORT_EVENT_PREFIX = "agent-session-import:v2"; const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -242,18 +247,21 @@ const make = Effect.gen(function* () { (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), ), ); - const completedSources = runtimeRows.flatMap((runtime) => { - const payload = AgentSessionScanner.decodeImportedTranscriptPayload(runtime.runtimePayload); - if ( - Option.isNone(payload) || - payload.value.cwd === undefined || - normalizeProjectPathForComparison(payload.value.cwd) !== - normalizeProjectPathForComparison(project.workspaceRoot) - ) { - return []; - } - return payload.value.importedTranscripts ?? []; - }); + // The thread's project owns its transcripts: a linked worktree import records the worktree as cwd. + const completedSources = (yield* Effect.forEach(runtimeRows, (runtime) => { + const imported = AgentSessionScanner.decodeImportedTranscriptPayload( + runtime.runtimePayload, + ).pipe(Option.flatMapNullishOr((payload) => payload.importedTranscripts)); + if (Option.isNone(imported) || imported.value.length === 0) return Effect.succeed([]); + return orchestrator.getThreadRecords(runtime.threadId, []).pipe( + Effect.map(({ thread }) => (thread.projectId === input.projectId ? imported.value : [])), + Effect.catch((cause) => + isProjectionStoreThreadNotFoundError(cause.cause) + ? Effect.succeed([]) + : Effect.fail(new AgentSessionScanError({ operation: "read-projects", cause })), + ), + ); + })).flat(); const outcomes = scanner.recentThreads(project.workspaceRoot, completedSources); const importedThreadIds = new Set(); let importedCount = 0; @@ -313,7 +321,9 @@ const make = Effect.gen(function* () { }, { onConflict: "ignore" }, ); - const existing = yield* Effect.option(orchestrator.getThreadRecords(threadId, [])); + const existing = yield* Effect.option( + orchestrator.getThreadRecords(threadId, ["messages"]), + ); if (Option.isSome(existing)) { if (existing.value.thread.projectId !== input.projectId) { return yield* new AgentSessionThreadProjectConflictError({ @@ -325,16 +335,15 @@ const make = Effect.gen(function* () { if (existing.value.thread.historyOrigin !== "v1_import") { return yield* new AgentSessionThreadModifiedError({ threadId }); } - // Other recorded transcripts mean this one changed after the import, and the - // thread does not hold its new messages, so it must stay unrecorded. - const recorded = Option.flatMap(yield* runtimes.getByThreadId({ threadId }), (row) => - AgentSessionScanner.decodeImportedTranscriptPayload(row.runtimePayload), - ).pipe(Option.flatMapNullishOr((payload) => payload.importedTranscripts)); + // A transcript can change after the import, so record it only when the + // thread already holds exactly its messages. + const stored = existing.value.messages; if ( - Option.isSome(recorded) && - recorded.value.length > 0 && - !recorded.value.some((imported) => - AgentSessionScanner.sameTranscriptIdentity(imported, source), + stored.length !== thread.messages.length || + stored.some( + (message, index) => + message.role !== thread.messages[index]?.role || + message.text !== thread.messages[index]?.text, ) ) { return false; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7a9d5d99b0ead..909a0b2cdb76d 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -815,10 +815,7 @@ type TranscriptIdentity = Pick< const fileIdKey = (inode: TranscriptIdentity["inode"]) => (inode === null ? null : String(inode)); -export function sameTranscriptIdentity( - left: TranscriptIdentity, - right: TranscriptIdentity, -): boolean { +function sameTranscriptIdentity(left: TranscriptIdentity, right: TranscriptIdentity): boolean { // Unsafe legacy numbers may already have lost file ID precision. if ( (typeof left.inode === "number" && !Number.isSafeInteger(left.inode)) || diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 011b54a6851b3..9a4a78888b72a 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -1,7 +1,13 @@ import { assert, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { CommandId, ProjectId, ThreadId, VcsProcessSpawnError } from "@t3tools/contracts"; +import { + CommandId, + ProjectId, + ThreadId, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -174,6 +180,44 @@ it.effect("offers a Scratch folder when Git is not installed", () => ), ); +it.effect("offers the Scratch folder once a Git check that timed out is asked again", () => + withScratch( + ({ baseDir }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const scratch = yield* ManagedProjectFolders.ManagedProjectFolders; + assert.isTrue(Option.isNone(yield* scratch.scratchRoot)); + assert.equal(yield* requireRoot, path.resolve(baseDir, "scratch")); + }), + { + vcsProcess: Layer.effect( + VcsProcess.VcsProcess, + Effect.map(VcsProcess.make, (real) => { + let timedOut = false; + return VcsProcess.VcsProcess.of({ + run: (input) => + Effect.suspend(() => { + if (timedOut) return real.run(input); + timedOut = true; + return Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + timeoutMs: 5_000, + }), + ); + }), + }); + }), + ).pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + }, + ), +); + it.effect("creates one Scratch project, even for concurrent first requests", () => withScratch(() => Effect.gen(function* () { diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 007f784374c15..143bbf4a5c10d 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -268,15 +268,18 @@ const make = Effect.gen(function* () { return Effect.logWarning(unavailable.message).pipe(Effect.as(Result.fail(unavailable))); }, ), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.interrupt - : Effect.succeed(Result.fail(new ScratchUnavailableError({ baseDir: config.baseDir }))), - ), ), - Duration.infinity, + // A failed git check (say, a timeout on a busy machine) is not an answer; ask again next time. + Exit.match({ onSuccess: () => Duration.infinity, onFailure: () => Duration.zero }), + ); + const scratchProbe = probe.pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.interrupt + : Effect.succeed(Result.fail(new ScratchUnavailableError({ baseDir: config.baseDir }))), + ), + Effect.onInterrupt(() => invalidate), ); - const scratchProbe = probe.pipe(Effect.onInterrupt(() => invalidate)); // A symlinked scratch folder lets the No project live at either the link or its target. const scratchProjectRoot = (root: string) => fileSystem.realPath(root).pipe( diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 28b1c40541ddf..25e5ee54a2737 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectSettingsOverrideSweep from "../provider/ProjectSettingsOverrideSweep.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; @@ -501,9 +502,13 @@ it.effect("deletes a project without force once its imported threads were delete }).pipe(Effect.provide(layerDatabase)), ); -it.effect( - "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive", - () => +it.effect.each([ + { policy: "no", worktreeCleanup: undefined }, + { policy: "its off", worktreeCleanup: { mode: "off" } as const }, + { policy: "its legacy", worktreeCleanup: null }, +])( + "clears a deleted project's settings overrides, keeping $policy worktree cleanup policy, so a machine-disabled instance cannot stay alive", + ({ worktreeCleanup }) => Effect.gen(function* () { const projectId = ProjectId.make("project:overrides-cleanup"); const instanceId = ProviderInstanceId.make("codex_work"); @@ -514,7 +519,11 @@ it.effect( const serverSettings = yield* ServerSettings.ServerSettingsService; yield* serverSettings.updateSettings({ projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + [projectId]: { + providerInstances: { [instanceId]: { enabled: true } }, + defaultAutoPull: true, + ...(worktreeCleanup === undefined ? {} : { worktreeCleanup }), + }, }, }); @@ -523,8 +532,35 @@ it.effect( projectId, }); - const settingsAfterDelete = yield* serverSettings.getSettings; - assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]); + const entry = (yield* serverSettings.getSettings).projectSettingsOverrides[projectId]; + if (worktreeCleanup === undefined) { + assert.isUndefined(entry); + } else { + assert.deepStrictEqual(entry, { worktreeCleanup, providerInstances: {} }); + } + + const liveProjectId = ProjectId.make("project:still-live"); + const missingProjectId = ProjectId.make("project:missing"); + yield* seedProject(liveProjectId); + // Older deletions or interrupted best-effort cleanup can leave the original overrides on restart. + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [instanceId]: { enabled: true } }, + defaultAutoPull: true, + ...(worktreeCleanup === undefined ? {} : { worktreeCleanup }), + }, + [liveProjectId]: { defaultAutoPull: true }, + [missingProjectId]: { worktreeCleanup: { mode: "off" } }, + }, + }); + yield* ProjectSettingsOverrideSweep.sweep; + assert.deepStrictEqual((yield* serverSettings.getSettings).projectSettingsOverrides, { + [liveProjectId]: { defaultAutoPull: true }, + ...(worktreeCleanup === undefined + ? {} + : { [projectId]: { worktreeCleanup, providerInstances: {} } }), + }); }).pipe(Effect.provide(layerServices)); }).pipe(Effect.provide(layerDatabase)), ); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index e381a9e0c56fe..2222572131091 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -33,6 +33,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { deletedProjectOverridePatch } from "../provider/ProjectSettingsOverrideSweep.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; @@ -556,7 +557,14 @@ export const make = Effect.gen(function* () { // Best-effort cleanup releases disabled instances kept alive by overrides; crashes have no replay to revisit deletion. const clearProjectSettingsOverrides = (projectId: ProjectId) => - serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( + serverSettings.getSettings.pipe( + Effect.flatMap(({ projectSettingsOverrides }) => + serverSettings.updateSettings({ + projectSettingsOverrides: { + [projectId]: deletedProjectOverridePatch(projectSettingsOverrides[projectId]), + }, + }), + ), Effect.asVoid, Effect.catchCause((cause) => Effect.logWarning( diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 7ce67ecd0b64e..d74628a13bb6a 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,16 +1,20 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; import { ProjectId, TerminalNotRunningError, type TerminalEvent, type TerminalSessionSnapshot, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { TestClock } from "effect/testing"; import * as ServerSettings from "../serverSettings.ts"; @@ -165,8 +169,11 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); - // A paste skips line editor widgets, such as zsh-autopair closing the `(` before the sentinel. - expect(yield* Queue.take(writes)).toMatch(/^\x1b\[200~ ?\( vp i\r\);[^]*\x1b\[201~\r$/); + // A paste skips line editor widgets, such as zsh-autopair pairing quotes and parentheses. + const written = yield* Queue.take(writes); + expect(written.startsWith("\x1b[200~ ")).toBe(true); + expect(written.endsWith("\x1b[201~\r")).toBe(true); + expect(written).toContain("vp i"); }), ); @@ -174,7 +181,7 @@ describe("ProjectSetupScriptRunner", () => { Effect.gen(function* () { const { writes } = yield* setup(); yield* TestClock.adjust("3 seconds"); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -196,7 +203,7 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); // The restart lands inside a quiet window, so the next full window decides. yield* TestClock.adjust("6 seconds"); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -229,3 +236,113 @@ describe("ProjectSetupScriptRunner", () => { }), ); }); + +const runObservedSetup = Effect.fnUntraced(function* (command: string) { + const { writes, outputLines, emit, completion } = yield* setup({ command }); + yield* emit({ ...event, type: "output", data: "\x1b[?2004h" }); + const pasted = yield* Queue.take(writes); + // Fork: the line editor receives a bracketed paste and drops its markers. + assert.isTrue(pasted.startsWith("\x1b[200~") && pasted.endsWith("\x1b[201~\r")); + const submitted = `${pasted.slice("\x1b[200~".length, -"\x1b[201~\r".length)}\r`; + const cwd = yield* HostProcess.WorkingDirectory; + const env = yield* HostProcess.Environment; + + // Enter becomes a newline at the terminal boundary; transport assertions below + // cover line-editor submissions and canonical TTY limits that bash -c bypasses. + const child = yield* Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + "/bin/bash", + ["--noprofile", "--norc", "-c", submitted.replaceAll("\r", "\n")], + { + cwd, + env: { PATH: env.PATH }, + }, + ), + ); + return yield* Effect.all( + { + stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: handle.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: handle.exitCode, + }, + { concurrency: "unbounded" }, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + yield* emit({ ...event, type: "output", data: submitted + "\n" }); + yield* emit({ ...event, type: "output", data: child.stdout + child.stderr }); + yield* emit({ ...event, type: "closed" }); + return { + submitted, + lines: yield* Queue.clear(outputLines), + exitCode: (yield* completion).exitCode, + }; +}); + +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup completion", () => { + it.live("submits one line while preserving multiline shell syntax and literal text", () => + Effect.gen(function* () { + const result = yield* runObservedSetup( + [ + "printf '%s\\n' \"it's ready\" 'literal \\n $(printf wrong) `printf wrong` café'", + "cat <<'END'", + "$literal \\path 雪", + "END", + "printf '%s\\n' done # trailing comment", + ].join("\r\n"), + ); + assert.equal(result.submitted.at(-1), "\r"); + assert.notMatch(result.submitted.slice(0, -1), /[\r\n]/); + assert.deepEqual(result.lines, [ + "it's ready", + "literal \\n $(printf wrong) `printf wrong` café", + "$literal \\path 雪", + "done", + ]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live("completes long scripts without exceeding canonical terminal line limits", () => + Effect.gen(function* () { + const expected = Array.from( + { length: 120 }, + (_, index) => `line-${index}: café 雪 'quoted' \\path`, + ); + const command = expected + .map((line) => `printf '%s\\n' "${line.replaceAll("\\", "\\\\")}"`) + .join("\n"); + const utf8 = new TextEncoder(); + assert.isAbove(utf8.encode(command).byteLength, 4_096); + + const result = yield* runObservedSetup(command); + assert.deepEqual(result.lines, expected); + assert.equal(result.exitCode, 0); + assert.equal(result.submitted.at(-1), "\r"); + assert.notInclude(result.submitted, "\n"); + // The runner can write before the shell switches the terminal out of canonical mode. + for (const line of result.submitted.slice(0, -1).split("\r")) { + assert.isBelow(utf8.encode(line).byteLength, 1_024); + } + }), + ); + + it.live("preserves a trailing backslash-newline even when losing it would still exit zero", () => + Effect.gen(function* () { + const result = yield* runObservedSetup("printf '%s\\n' direct\\\n"); + assert.deepEqual(result.lines, ["direct"]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live.each([ + { name: "an explicit exit", command: "exit 7", exitCode: 7 }, + { name: "a syntax error", command: "if then", exitCode: 2 }, + ])("reports a nonzero completion for $name", ({ command, exitCode }) => + Effect.gen(function* () { + const result = yield* runObservedSetup(command); + assert.equal(result.exitCode, exitCode); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 3d4fba79e1d42..a45c4a782ba26 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -180,7 +180,57 @@ function resolveCompletionShell(shell: string): CompletionShell | null { return "posix"; } -// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. +const utf8 = new TextEncoder(); +// Canonical TTY input (before a line editor starts reading) caps a line below 1 KiB on Linux. +const POSIX_MAX_LINE_BYTES = 1_023; +const POSIX_APPEND = '__t3_command="$__t3_command"'; + +function encodePosixScript(text: string): string { + let encoded = ""; + for (const character of text) { + const code = character.charCodeAt(0); + if (character === "'") { + encoded += "'\\''"; + } else if (character === "\\" || code <= 0x1f || code === 0x7f) { + encoded += `\\0${code.toString(8).padStart(3, "0")}`; + } else { + encoded += character; + } + } + return encoded; +} + +// Line editors run each submitted line on its own, so every line must be a complete command. +function wrapPosixCommand(command: string, sentinel: string): string { + const normalized = command.replace(/\r\n/g, "\n"); + // The `_` suffix keeps trailing newlines; the subshell contains exits and syntax errors. + const evaluate = (payload: string) => + `( __t3_command=$(printf '%b_' ${payload}); eval "\${__t3_command%_}" ); printf '\\n${sentinel}%s\\n' "$?"`; + const inline = evaluate(`'${encodePosixScript(normalized)}'`); + if (utf8.encode(` ${inline}\r`).byteLength <= POSIX_MAX_LINE_BYTES) return inline; + + const maxChunkBytes = POSIX_MAX_LINE_BYTES - utf8.encode(` ${POSIX_APPEND}''\r`).byteLength; + const lines: string[] = []; + let prefix = "__t3_command="; + let chunk = ""; + let chunkBytes = 0; + for (const character of normalized) { + const encoded = encodePosixScript(character); + const bytes = utf8.encode(encoded).byteLength; + if (chunkBytes + bytes > maxChunkBytes) { + lines.push(`${prefix}'${chunk}'`); + prefix = POSIX_APPEND; + chunk = ""; + chunkBytes = 0; + } + chunk += encoded; + chunkBytes += bytes; + } + lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`); + // The leading space gives every line the history suppression a short command gets. + return lines.join("\r "); +} + function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -207,7 +257,7 @@ function wrapCommandForCompletion( ].join("; "); } case "posix": - return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; + return wrapPosixCommand(command, sentinel); } } diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 4377caf55c524..50d749c3fb1b0 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -8,6 +8,7 @@ import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); @@ -71,6 +72,30 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); + it.effect("keeps a literal .git in an Azure DevOps repository name", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-azure-git-suffix-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, [ + "remote", + "add", + "origin", + "https://dev.azure.com/Acme/Platform/_git/Tool.git", + ]); + + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity?.displayName).toBe("Acme/Platform/_git/Tool.git"); + expect(identity?.name).toBe("Tool.git"); + expect(sourceControlDefinitions.repositorySelector(identity)).toBe("Tool.git"); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + ); + it.effect("resolves the fetch remote of a partial clone", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 51f63ecce7901..eed584c97b4f1 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -137,7 +137,12 @@ function pickPrimaryRemote( function repositoryPathOf(canonicalKey: string, remoteUrl: string): string { const canonicalPath = canonicalKey.split("/").slice(1).join("/"); const displayPath = repositoryPathFromRemoteUrl(remoteUrl); - return displayPath?.toLowerCase() === canonicalPath ? displayPath : canonicalPath; + if (displayPath === null) return canonicalPath; + // A host that names repositories literally keeps the `.git` the display path drops. + return ( + [displayPath, `${displayPath}.git`].find((path) => path.toLowerCase() === canonicalPath) ?? + canonicalPath + ); } function buildRepositoryOrigin( diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 2be0e69bf02b1..bbcc02f901329 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -137,6 +137,13 @@ describe("Claude model catalog", () => { formatClaudeVersionUpgradeMessage(catalog, "3.1.9"), "Claude Code v3.1.9 is too old for Claude Synthetic Next. Upgrade to v3.2.0 or newer to access it.", ); + // Claude Code before 2.1.321 does not know claude-fable-5-2 and runs it with a 200K window. + assert.notInclude( + resolveClaudeModelsForVersion(BUNDLED_CLAUDE_MODEL_CATALOG, "2.1.320").map( + (model) => model.slug, + ), + "claude-fable-5-2", + ); }); it("resolves aliases and declarative adapter mappings", () => { diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 1afb1e1cc6149..e3d4ea4397c0e 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -223,7 +223,7 @@ export function claudeReportedModels( return models; } -function resolveClaudeCatalogModel( +export function resolveClaudeCatalogModel( catalog: ClaudeModelCatalog, slugOrAlias: string | null | undefined, ): ClaudeCatalogModel | undefined { diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 0fb7ea8b2e1fd..0d669659a09ce 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -177,11 +177,16 @@ function formatClaudeSubscriptionAuthLabel(subscriptionType: string): string { return `Claude ${subscriptionLabel} Subscription`; } +function hasApiKey(apiKeySource: string | undefined): boolean { + return Boolean(apiKeySource) && apiKeySource !== "none"; +} + function claudeAuthMetadata(input: { readonly subscriptionType: string | undefined; readonly authMethod: string | undefined; + readonly apiKeySource: string | undefined; }): { readonly type: string; readonly label: string } | undefined { - if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") { + if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey" || hasApiKey(input.apiKeySource)) { return { type: "apiKey", label: "Claude API Key", @@ -216,8 +221,7 @@ function claudeAuthStatus( } if (capabilities.tokenSource !== "none") return "authenticated"; // API keys can authenticate with tokenSource: none; also check key and account fields. - const hasApiKey = Boolean(capabilities.apiKeySource) && capabilities.apiKeySource !== "none"; - return hasApiKey || capabilities.email || capabilities.subscriptionType + return hasApiKey(capabilities.apiKeySource) || capabilities.email || capabilities.subscriptionType ? "authenticated" : "unauthenticated"; } @@ -844,6 +848,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, authMethod: capabilities.tokenSource, + apiKeySource: capabilities.apiKeySource, }); const usageCheckedAt = capabilities.usageCheckedAt ?? checkedAt; const usageLimits = !capabilities.usage diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts index eccf421dd7c18..33f9f901c8731 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -24,6 +24,8 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Sink from "effect/Sink"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { makeClaudeMaintenanceResolution } from "./ClaudeMaintenance.ts"; @@ -77,6 +79,13 @@ const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( : platform === "darwin" ? "/Library/Application Support/ClaudeCode" : "/etc/claude-code"; + const npmPath = path.join(directory, "bin/npm"); + if (install === "npm") { + yield* fs.makeDirectory(path.dirname(npmPath), { recursive: true }); + yield* fs.writeFileString(npmPath, "#!/bin/sh\n"); + yield* fs.chmod(npmPath, 0o755); + } + const npmProbes: Array> = []; let installationReads = 0; const testFs = FileSystem.makeNoop({ ...fs, @@ -105,11 +114,37 @@ const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( managed, managedDirectory, settingsPath, + npmProbes, installationReads: () => installationReads, - makeResolution: makeClaudeMaintenanceResolution({ binaryPath, env: { PATH: "" } }).pipe( + makeResolution: makeClaudeMaintenanceResolution({ + binaryPath, + env: { PATH: install === "npm" ? path.dirname(npmPath) : "" }, + }).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("version checks must not run a subprocess")), + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== npmPath) { + return Effect.die("version checks may only ask npm"); + } + npmProbes.push(command.args.map(String)); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText( + Stream.make(JSON.stringify({ "@anthropic-ai/claude-code": { latest: "2.1.316" } })), + ), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), ), Effect.provideService(FileSystem.FileSystem, testFs), Effect.provideService(HostProcess.Platform, platform), @@ -233,12 +268,17 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { status: row.advisory, latestVersion: row.expected, }); - expect(requests).toHaveLength(1); - expect(requests[0]).toMatch( - row.npm ? /registry\.npmjs\.org/ : new RegExp(`claude-code-releases/${row.channel}$`), - ); + // npm installs ask `npm outdated`, which applies npm's release-age settings. + const lookups = row.npm ? f.npmProbes : requests; + expect(lookups).toHaveLength(1); + if (row.npm) { + expect(requests).toEqual([]); + expect(f.npmProbes[0]?.[0]).toBe("outdated"); + } else { + expect(requests[0]).toMatch(new RegExp(`claude-code-releases/${row.channel}$`)); + } yield* enrich; - expect(requests).toHaveLength(1); + expect(lookups).toHaveLength(1); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 8b9661a779ae1..349294baa8c88 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -3,10 +3,19 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills as discoverSkills } from "./ClaudeSkills.ts"; import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts"; +// Windows makes the managed directory fixture-controlled without reading host policy or skills. +const discoverClaudeSkills = ( + ...[config, cwd, environment = {}]: Parameters +) => + discoverSkills(config, cwd, environment).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + ); + const writeSkill = Effect.fn(function* ( skillsDir: string, directoryName: string, @@ -578,6 +587,96 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("discovers inline plugins from CLAUDE_CODE_PLUGIN_DIRS over installed namesakes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(workspace, "config"); + const named = path.join(workspace, "named"); + const unnamed = path.join(workspace, "unnamed"); + const inherited = path.join(workspace, "inherited"); + const muted = path.join(workspace, "muted"); + const installed = path.join(workspace, "installed-acme"); + yield* fs.makeDirectory(path.join(named, ".claude-plugin"), { recursive: true }); + yield* fs.writeFileString( + path.join(named, ".claude-plugin", "plugin.json"), + '{"name":"acme"}', + ); + yield* writeSkill(path.join(named, "skills"), "qa", "---\ndescription: QA a ticket\n---\n"); + yield* writeSkill(path.join(installed, "skills"), "old", "# Old"); + yield* writeSkill(path.join(unnamed, "skills"), "lint", "# Lint"); + yield* writeSkill(path.join(muted, "skills"), "hidden", "# Hidden"); + const quiet = path.join(workspace, "quiet"); + yield* fs.makeDirectory(path.join(quiet, ".claude-plugin"), { recursive: true }); + yield* fs.writeFileString( + path.join(quiet, ".claude-plugin", "plugin.json"), + '{"name":"quiet","defaultEnabled":false}', + ); + yield* writeSkill(path.join(quiet, "skills"), "hush", "# Hush"); + const bundle = path.join(workspace, "bundle"); + yield* fs.makeDirectory(path.join(bundle, "tools", ".claude-plugin"), { recursive: true }); + yield* fs.writeFileString( + path.join(bundle, "tools", ".claude-plugin", "plugin.json"), + '{"name":"tools"}', + ); + yield* writeSkill(path.join(bundle, "tools", "skills"), "fmt", "# Fmt"); + yield* writeSkill(path.join(bundle, "loose", "skills"), "ignored", "# Ignored"); + yield* writeSkill(path.join(inherited, "skills"), "stale", "# Stale"); + yield* writeSkill(path.join(workspace, "relative", "skills"), "skipped", "# Skipped"); + yield* fs.makeDirectory(path.join(configDir, "plugins"), { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "plugins", "installed_plugins.json"), + JSON.stringify({ plugins: { "acme@shop": [{ scope: "user", installPath: installed }] } }), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + JSON.stringify({ + enabledPlugins: { "acme@shop": true, "muted@inline": false }, + env: { + CLAUDE_CODE_PLUGIN_DIRS: [named, "relative", ` ${unnamed} `, muted, quiet, bundle].join( + ";", + ), + }, + }), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace, { + CLAUDE_CODE_PLUGIN_DIRS: inherited, + }); + assert.deepEqual( + skills.map(({ name, scope, description }) => ({ name, scope, description })), + [ + { name: "acme:qa", scope: "user", description: "QA a ticket" }, + { name: "tools:fmt", scope: "user", description: undefined }, + { name: "unnamed:lint", scope: "user", description: undefined }, + ], + ); + + // Managed enabledPlugins entries lock their plugin name against inline copies. + const programFiles = path.join(workspace, "program-files"); + yield* fs.makeDirectory(path.join(programFiles, "ClaudeCode"), { recursive: true }); + yield* fs.writeFileString( + path.join(programFiles, "ClaudeCode", "managed-settings.json"), + '{"enabledPlugins":{"acme@shop":true}}', + ); + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: path.join(workspace, "empty") }, workspace, { + CLAUDE_CODE_PLUGIN_DIRS: [named, unnamed].join(";"), + ProgramFiles: programFiles, + })).map((skill) => skill.name), + ["unnamed:lint"], + ); + + assert.deepEqual( + (yield* discoverClaudeSkills({ homePath: path.join(workspace, "empty") }, workspace, { + CLAUDE_CODE_PLUGIN_DIRS: inherited, + })).map((skill) => skill.name), + ["inherited:stale"], + ); + }), + ); + it.effect("discovers user and project skills with frontmatter metadata", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -688,7 +787,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("prefers user skills on name collisions even with a stray .agents copy", () => + it.effect("prefers managed then user skills on collisions, even with a stray .agents copy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -723,6 +822,62 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { description: "User deploy.", }, ]); + + const programFiles = path.join(tempDir, "Program Files"); + const programData = path.join(tempDir, "ProgramData"); + yield* writeSkill( + path.join(programData, "ClaudeCode", ".claude", "skills"), + "legacy-only", + "# Legacy skills must not be discovered", + ); + yield* fs.writeFileString( + path.join(programData, "ClaudeCode", "managed-settings.json"), + '{"skillOverrides":{"deploy":"off"}}', + ); + for (const ProgramFiles of [undefined, "", programFiles]) { + const legacySkills = yield* discoverClaudeSkills({ homePath: configDir }, workspace, { + PROGRAMDATA: programData, + ...(ProgramFiles !== undefined ? { ProgramFiles } : {}), + }); + assert.deepEqual( + legacySkills, + skills.map((skill) => ({ ...skill, enabled: false })), + ); + } + yield* writeSkill( + path.join(programFiles, "ClaudeCode", ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Managed deploy.", "---"].join("\n"), + ); + yield* fs.writeFileString( + path.join(configDir, "settings.json"), + '{"skillOverrides":{"deploy":"off"}}', + ); + yield* fs.writeFileString( + path.join(programFiles, "ClaudeCode", "managed-settings.json"), + '{"skillOverrides":{"deploy":"on"}}', + ); + const managedSkills = yield* discoverClaudeSkills({ homePath: configDir }, workspace, { + ProgramFiles: programFiles, + PROGRAMDATA: programData, + }); + + assert.deepEqual(managedSkills, [ + { + name: "deploy", + path: path.join(programFiles, "ClaudeCode", ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "user", + description: "Managed deploy.", + }, + ]); + assert.deepEqual( + yield* discoverClaudeSkills({ homePath: configDir }, workspace, { + PROGRAMFILES: ` ${programFiles} `, + PROGRAMDATA: programData, + }), + managedSkills, + ); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index c24c4afca9e7d..4fdd3565d11b6 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -106,19 +106,41 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { }; } -function claudeManagedSettingsPath( +// A caller-supplied environment is a plain object, without Windows' case-insensitive keys. +function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) { + const key = name.toLowerCase(); + return Object.entries(environment) + .find(([candidate]) => candidate.toLowerCase() === key)?.[1] + ?.trim(); +} + +function claudeManagedDirectory( path: Path.Path, platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, ): string | undefined { if (platform === "darwin") { - return "/Library/Application Support/ClaudeCode/managed-settings.json"; + return "/Library/Application Support/ClaudeCode"; } if (platform === "win32") { - const programData = environment.PROGRAMDATA?.trim(); - return programData ? path.join(programData, "ClaudeCode", "managed-settings.json") : undefined; + const programFiles = windowsEnvironmentValue(environment, "ProgramFiles"); + return programFiles ? path.join(programFiles, "ClaudeCode") : undefined; } - return "/etc/claude-code/managed-settings.json"; + return "/etc/claude-code"; +} + +function claudeManagedSettingsPaths( + path: Path.Path, + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): ReadonlyArray { + const managedDirectory = claudeManagedDirectory(path, platform, environment); + const programData = + platform === "win32" ? windowsEnvironmentValue(environment, "ProgramData") : undefined; + return [ + ...(programData ? [path.join(programData, "ClaudeCode", "managed-settings.json")] : []), + ...(managedDirectory ? [path.join(managedDirectory, "managed-settings.json")] : []), + ]; } /** Claude Code settings files, lowest precedence first. */ @@ -130,7 +152,6 @@ function claudeSettingsPaths( environment: NodeJS.ProcessEnv, repositoryRoot?: string, ): ReadonlyArray { - const managedPath = claudeManagedSettingsPath(path, platform, environment); const root = repositoryRoot !== undefined && repositoryRoot !== cwd ? repositoryRoot : undefined; return [ path.join(configDirPath, "settings.json"), @@ -141,7 +162,7 @@ function claudeSettingsPaths( ] : []), ...(root ? [path.join(root, ".claude", "settings.local.json")] : []), - ...(managedPath ? [managedPath] : []), + ...claudeManagedSettingsPaths(path, platform, environment), ]; } @@ -250,6 +271,8 @@ const readSkillSettings = Effect.fn("readSkillSettings")(function* ( const platform = yield* HostProcess.Platform; const overridesByName = new Map(); const enabledPlugins = new Map(); + const managedPluginNames = new Set(); + const managedPaths = new Set(claudeManagedSettingsPaths(path, platform, environment)); let syncedPluginsHidden = false; const repositoryRoot = cwd === undefined ? undefined : yield* findRepositoryRoot(cwd); const projectSettingsPath = @@ -285,6 +308,9 @@ const readSkillSettings = Effect.fn("readSkillSettings")(function* ( for (const [key, enabled] of Object.entries(parsed?.enabledPlugins ?? {})) { if (typeof enabled === "boolean") { enabledPlugins.set(key, enabled); + if (managedPaths.has(settingsPath) && key.includes("@")) { + managedPluginNames.add(pluginNameKey(key.slice(0, key.indexOf("@")))); + } } else { // Extended formats replace lower-precedence values without enabling the plugin here. enabledPlugins.delete(key); @@ -296,7 +322,12 @@ const readSkillSettings = Effect.fn("readSkillSettings")(function* ( } } - return { skillOverrides: overridesByName, enabledPlugins, syncedPluginsHidden }; + return { + skillOverrides: overridesByName, + enabledPlugins, + managedPluginNames, + syncedPluginsHidden, + }; }); const InstalledPlugins = Schema.fromJsonString( @@ -313,6 +344,7 @@ const PluginManifest = Schema.fromJsonString( Schema.Struct({ name: Schema.optional(Schema.NonEmptyString), skills: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + defaultEnabled: Schema.optional(Schema.Unknown), }), ); @@ -433,10 +465,66 @@ const resolveSyncedPluginBucket = Effect.fn("resolveSyncedPluginBucket")(functio return `${organizationId.toLowerCase()}_${accountId.toLowerCase()}`; }); +/** A plugin folder itself, or else the manifest-bearing plugins directly inside it. */ +const expandInlinePluginDirectory = Effect.fn("expandInlinePluginDirectory")(function* ( + directory: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = (candidate: string) => + fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + for (const component of [path.join(".claude-plugin", "plugin.json"), "skills", "SKILL.md"]) { + if (yield* exists(path.join(directory, component))) return [directory]; + } + const children = yield* fileSystem + .readDirectory(directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + return yield* Effect.filter( + [...children].sort().map((child) => path.join(directory, child)), + (child) => exists(path.join(child, ".claude-plugin", "plugin.json")), + ); +}); + +/** Plugin folders Claude Code loads inline from CLAUDE_CODE_PLUGIN_DIRS, and their plugin names. */ +const discoverInlinePluginSkillRoots = Effect.fn("discoverInlinePluginSkillRoots")(function* ( + pluginDirectories: string | undefined, + enabledPlugins: ReadonlyMap, + managedPluginNames: ReadonlySet, +) { + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const platform = yield* HostProcess.Platform; + const roots: Array = []; + const names = new Set(); + const seen = new Set(); + for (const entry of (pluginDirectories ?? "").split(platform === "win32" ? ";" : ":")) { + const directory = expandHomePath(entry.trim(), home); + // Claude skips relative entries rather than resolving them against its cwd. + if (!directory || !path.isAbsolute(directory)) continue; + for (const installPath of yield* expandInlinePluginDirectory(path.resolve(directory))) { + if (seen.has(installPath)) continue; + seen.add(installPath); + const manifest = yield* readPluginManifest(installPath); + const prefix = manifest?.name ?? path.basename(installPath); + // A `@inline` setting wins; otherwise the manifest's `defaultEnabled` decides. + const setting = enabledPlugins.get(`${prefix}@inline`); + if (setting === false || (setting === undefined && manifest?.defaultEnabled === false)) { + continue; + } + // Managed enabledPlugins entries, true or false, lock the name against inline copies. + if (managedPluginNames.has(pluginNameKey(prefix))) continue; + names.add(pluginNameKey(prefix)); + roots.push(...(yield* pluginSkillRoots(installPath, "user", prefix, manifest))); + } + } + return { roots, names }; +}); + const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* ( configDirPath: string, cwd: string | undefined, enabledPlugins: ReadonlyMap, + inlinePluginNames: ReadonlySet, syncedBucket: string | undefined, environment: NodeJS.ProcessEnv, ) { @@ -454,7 +542,8 @@ const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* Effect.orElseSucceed(() => undefined), ); const roots: Array = []; - const claimedPluginNames = new Set(); + // An enabled inline plugin replaces installed and synced plugins of the same name. + const claimedPluginNames = new Set(inlinePluginNames); // The CLI's process.cwd() resolves symlinks; registry projectPath values remain lexical. const workspace = cwd === undefined @@ -494,7 +583,7 @@ const discoverPluginSkillRoots = Effect.fn("discoverPluginSkillRoots")(function* const installPath = path.resolve(cwd ?? ".", install.installPath); const manifest = yield* readPluginManifest(installPath); const prefix = manifest?.name ?? key.split("@")[0]; - if (!prefix) continue; + if (!prefix || inlinePluginNames.has(pluginNameKey(prefix))) continue; // A registry record whose install is gone loads nothing and claims no namespace. if (Option.isSome(existing)) claimedPluginNames.add(pluginNameKey(prefix)); roots.push(...(yield* pluginSkillRoots(installPath, scope, prefix, manifest))); @@ -560,6 +649,7 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct const SettingsEnv = Schema.Struct({ ANTHROPIC_BASE_URL: Schema.optional(Schema.String), CLAUDE_CODE_ENTRYPOINT: Schema.optional(Schema.String), + CLAUDE_CODE_PLUGIN_DIRS: Schema.optional(Schema.String), // Unquoted JSON values must not fail the decode and drop ANTHROPIC_BASE_URL with them. CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: Schema.optional(Schema.Unknown), CLAUDE_CODE_GATEWAY_MODEL_DISCOVERY_TIMEOUT_MS: Schema.optional(Schema.Unknown), @@ -627,7 +717,7 @@ export function claudeLaunchArgDirectories(launchArgs?: string): ReadonlyArray & Partial>, cwd?: string, @@ -637,11 +727,9 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const path = yield* Path.Path; const env = environment ?? process.env; const configDirPath = yield* resolveClaudeConfigDirPath(config, env, cwd); - const { skillOverrides, enabledPlugins, syncedPluginsHidden } = yield* readSkillSettings( - configDirPath, - cwd, - env, - ); + const { skillOverrides, enabledPlugins, managedPluginNames, syncedPluginsHidden } = + yield* readSkillSettings(configDirPath, cwd, env); + const managedDirectory = claudeManagedDirectory(path, yield* HostProcess.Platform, env); // Most installs have no synced folder; skip reading `.claude.json` for them. const hasSyncedPlugins = !syncedPluginsHidden && @@ -651,8 +739,20 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const syncedBucket = hasSyncedPlugins ? yield* resolveSyncedPluginBucket(config, configDirPath, env) : undefined; + // Claude applies its settings `env` over the inherited environment. + const inlinePluginDirectories = + (yield* readClaudeSettingsEnv(config, cwd, env)).CLAUDE_CODE_PLUGIN_DIRS ?? + env.CLAUDE_CODE_PLUGIN_DIRS; + const inlinePlugins = yield* discoverInlinePluginSkillRoots( + inlinePluginDirectories, + enabledPlugins, + managedPluginNames, + ); const roots: ReadonlyArray = [ + ...(managedDirectory + ? [{ directory: path.join(managedDirectory, ".claude", "skills"), scope: "user" as const }] + : []), { directory: path.join(configDirPath, "skills"), scope: "user" }, ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), ...claudeLaunchArgDirectories(config.launchArgs) @@ -661,7 +761,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* directory: path.join(path.resolve(cwd ?? ".", directory), ".claude", "skills"), scope: "project" as const, })), - ...(yield* discoverPluginSkillRoots(configDirPath, cwd, enabledPlugins, syncedBucket, env)), + ...inlinePlugins.roots, + ...(yield* discoverPluginSkillRoots( + configDirPath, + cwd, + enabledPlugins, + inlinePlugins.names, + syncedBucket, + env, + )), ]; const skillsByName = new Map(); diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 01d44f3d3d1e8..1462d051346f1 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -729,6 +729,7 @@ it.layer(layerTest)("CodexDriver", (it) => { const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); const npmBin = NodePath.join(npmPrefix, "bin"); + const npmPath = NodePath.join(npmBin, "npm"); const npmEntry = NodePath.join( npmPrefix, "lib", @@ -738,7 +739,7 @@ it.layer(layerTest)("CodexDriver", (it) => { "bin", "codex.js", ); - for (const file of [brewPath, misePath, npmEntry]) { + for (const file of [brewPath, misePath, npmPath, npmEntry]) { yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); yield* fs.writeFileString(file, "#!/bin/sh\n"); yield* fs.chmod(file, 0o755); @@ -753,12 +754,20 @@ it.layer(layerTest)("CodexDriver", (it) => { ].join(NodePath.delimiter); const probes: Array> = []; const metadataSpawner = ChildProcessSpawner.make((command) => { - if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + const isNpmOutdated = + ChildProcess.isStandardCommand(command) && + command.command === npmPath && + command.args[0] === "outdated"; + if ( + !ChildProcess.isStandardCommand(command) || + (command.command !== brewPath && !isNpmOutdated) + ) { return Effect.die("Provider resolution must not execute a provider or updater"); } - probes.push(command.args.map(String)); - const stdout = - command.args[0] === "--prefix" + if (!isNpmOutdated) probes.push(command.args.map(String)); + const stdout = isNpmOutdated + ? JSON.stringify({ "@openai/codex": { latest: "0.153.4" } }) + : command.args[0] === "--prefix" ? brewPrefix : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); return Effect.succeed( diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts index eed20381b46cb..0bb55b9bcb39b 100644 --- a/apps/server/src/provider/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -255,6 +255,65 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); + it.effect("drops Cursor streaming deltas while keeping its lifecycle updates", () => + Effect.gen(function* () { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); + const basePath = NodePath.join(tempDir, "events.log"); + const threadId = ThreadId.make("cursor-deltas"); + const cursorUpdate = (update: object) => ({ + provider: "cursor", + kind: "protocol", + event: { + direction: "incoming", + stage: "decoded", + payload: { type: "interaction.update", runId: "run-1", update }, + }, + }); + const toolCall = { type: "shell", args: { command: "ls" } }; + const transient = [ + { type: "text-delta", text: "Hel" }, + { type: "thinking-delta", text: "Hmm" }, + { type: "token-delta", tokens: 3 }, + { type: "shell-output-delta", event: { stdout: "a.ts\n" } }, + { type: "partial-tool-call", callId: "call-1", modelCallId: "m-1", toolCall }, + { + type: "tool-call-delta", + callId: "call-1", + modelCallId: "m-1", + taskUpdate: { type: "text-delta", text: "Sub" }, + }, + ].map(cursorUpdate); + const retained = [ + { type: "tool-call-started", callId: "call-1", modelCallId: "m-1", toolCall }, + { + type: "tool-call-delta", + callId: "call-1", + modelCallId: "m-1", + taskUpdate: { type: "tool-call-completed", callId: "call-2", toolCall }, + }, + { type: "tool-call-completed", callId: "call-1", modelCallId: "m-1", toolCall }, + { type: "turn-ended" }, + ].map(cursorUpdate); + + try { + const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); + const native = store.logger("native"); + for (const event of [...transient, ...retained]) { + yield* native.write(event, threadId); + } + yield* store.close(); + + const records = NodeFS.readFileSync(ownedLogPath(basePath, "cursor-deltas"), "utf8") + .trim() + .split("\n") + .map((line) => decodeUnknownJson(parseLogLine(line).payload)); + assert.deepEqual(records, retained); + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } + }), + ); + it.effect("bounds oversized records while retaining failure details", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); diff --git a/apps/server/src/provider/EventNdjsonLogger.ts b/apps/server/src/provider/EventNdjsonLogger.ts index 6a1d3844b2373..5a7b3578578f1 100644 --- a/apps/server/src/provider/EventNdjsonLogger.ts +++ b/apps/server/src/provider/EventNdjsonLogger.ts @@ -63,6 +63,13 @@ const transientNativeMethods = new Set([ "turn/diff/updated", ]); const transientAcpUpdates = new Set(["agent_message_chunk", "agent_thought_chunk"]); +const transientCursorUpdates = new Set([ + "text-delta", + "thinking-delta", + "token-delta", + "shell-output-delta", + "partial-tool-call", +]); export type EventNdjsonStream = "native" | "canonical" | "orchestration"; @@ -219,6 +226,18 @@ export function shouldPersistProviderEvent(stream: EventNdjsonStream, event: unk } } + if (nativeType === "interaction.update") { + let update = Reflect.get(nativeEvent, "update"); + if (typeof update !== "object" || update === null) return true; + // Subagent updates arrive wrapped; keep the wrapper when it carries a lifecycle record. + if (Reflect.get(update, "type") === "tool-call-delta") { + update = Reflect.get(update, "taskUpdate"); + if (typeof update !== "object" || update === null) return true; + } + const updateType = Reflect.get(update, "type"); + return typeof updateType !== "string" || !transientCursorUpdates.has(updateType); + } + const payload = Reflect.get(nativeEvent, "payload"); if (typeof payload !== "object" || payload === null) return true; diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts index aa7d205271494..06f61f9ad8bfe 100644 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -1,32 +1,41 @@ -import { type ProjectId, type ServerSettings } from "@t3tools/contracts"; +import * as NodeUtil from "node:util"; + +import { type ProjectSettingsOverrides } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as Settings from "../serverSettings.ts"; -const findOrphanedProjectIds = ( - settings: Pick, - liveProjectIds: ReadonlySet, -): ReadonlyArray => - Object.keys(settings.projectSettingsOverrides) - .map((projectId) => projectId as ProjectId) - .filter((projectId) => !liveProjectIds.has(projectId)); +export function deletedProjectOverridePatch(entry: ProjectSettingsOverrides | undefined) { + // Keep cleanup policy for deleted threads; omitting providerInstances would preserve obsolete references in the merge. + return entry?.worktreeCleanup !== undefined + ? { worktreeCleanup: entry.worktreeCleanup, providerInstances: {} } + : null; +} export const sweep = Effect.gen(function* () { const serverSettings = yield* Settings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; const settings = yield* serverSettings.getSettings; - const liveShells = yield* projectStore.listShells(); - const liveProjectIds = new Set(liveShells.map((shell) => shell.id)); + const projects = yield* projectStore.list({ includeDeleted: true }); + const projectsById = new Map( + projects.map((project) => [project.projectId, project]), + ); - const orphanedProjectIds = findOrphanedProjectIds(settings, liveProjectIds); - if (orphanedProjectIds.length === 0) return; + const overrides = Object.entries(settings.projectSettingsOverrides).flatMap( + ([projectId, entry]) => { + const project = projectsById.get(projectId); + if (project?.deletedAt === null) return []; + const patch = project ? deletedProjectOverridePatch(entry) : null; + if (NodeUtil.isDeepStrictEqual(entry, patch)) return []; + return [[projectId, patch] as const]; + }, + ); + if (overrides.length === 0) return; yield* serverSettings.updateSettings({ - projectSettingsOverrides: Object.fromEntries( - orphanedProjectIds.map((projectId) => [projectId, null]), - ), + projectSettingsOverrides: Object.fromEntries(overrides), }); }).pipe( Effect.asVoid, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 564a90f40e6ce..be043bae0f847 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -4261,6 +4261,7 @@ it.layer( ); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.label, "Claude API Key"); }).pipe( Effect.provide( layerMockSpawner((args) => { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 0c6bddbbf8567..6b9afa7f30c50 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; import * as NodePath from "node:path"; import { describe, expect, it, vi } from "@effect/vitest"; @@ -813,6 +814,33 @@ describe("terminatePosixOwnedProcessTree", () => { }), ); + it.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "snapshots /proc when a process exits while its entry is being typed", + () => { + const fs = NodeModule.createRequire(import.meta.url)("node:fs") as typeof NodeFS; + const realReaddirSync = fs.readdirSync; + // Node lstat()s a dirent of unknown type; a process that exits meanwhile makes it throw. + fs.readdirSync = ((path: NodeFS.PathLike, options?: unknown) => { + if (path === "/proc" && typeof options === "object" && options !== null) { + throw Object.assign(new Error("ENOENT: no such file or directory, lstat '/proc/4563'"), { + code: "ENOENT", + syscall: "lstat", + path: "/proc/4563", + }); + } + return Reflect.apply(realReaddirSync, fs, [path, options]); + }) as typeof NodeFS.readdirSync; + NodeModule.syncBuiltinESMExports(); + try { + const snapshot = AcpSessionRuntime.makePosixProcessTreeController("linux").snapshot(); + expect(snapshot.some((entry) => entry.pid === process.pid)).toBe(true); + } finally { + fs.readdirSync = realReaddirSync; + NodeModule.syncBuiltinESMExports(); + } + }, + ); + it("fails closed where a stable POSIX identity provider is unavailable", () => { try { AcpSessionRuntime.makePosixProcessTreeController("darwin"); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 659a0ee968aee..a00f25f8ea1c5 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 6060, "codexAdditionalContextBytes": 7454, - "mcpToolListJsonBytes": 62292 + "mcpToolListJsonBytes": 62278 } diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 4906358bab70b..01727cc2680b0 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-12T18:25:36Z", + "updatedAt": "2026-11-14T15:58:47Z", "compatibility": [ { "driver": "codex", @@ -95,8 +95,8 @@ "t3CodeRange": ">=0.0.46", "recommendedRange": ">=1.0.0", "ranges": [ - { "range": ">=0.80.5", "status": "supported" }, - { "range": "<0.80.5", "status": "unsupported" } + { "range": ">=0.84.3", "status": "supported" }, + { "range": "<0.84.3", "status": "unsupported" } ] }, { @@ -771,7 +771,7 @@ "aliases": ["fable", "fable-5.2", "claude-fable-5.2"], "status": "current", "profile": "fable-5", - "adapter": { "claudeCode": { "minVersion": "2.1.290" } } + "adapter": { "claudeCode": { "minVersion": "2.1.321" } } }, { "slug": "claude-fable-5-1", diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 68e2289524c09..10388e99fe7c5 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -753,6 +753,37 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect( + "clears a completion that landed while snoozed, even when processed after the wake", + () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + const snooze = { + snoozedAt: DateTime.makeUnsafe(NOW), + snoozedUntil: DateTime.makeUnsafe("2026-09-04T13:00:00.000Z"), + }; + yield* Ref.set(currentShell, shell(snooze)); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("2 hours"); + yield* Ref.set( + currentShell, + shell({ + ...snooze, + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:30:00.000Z"), + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", null], + ); + }), + ); + it.effect("still publishes a completion when the thread settles during the hold", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 18c1dcf3f72ed..4e539fc904979 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -51,6 +51,7 @@ import { import { EnvironmentSigningKey } from "../cloud/environmentKeys.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { forkParked } from "../serverActivation.ts"; @@ -360,12 +361,27 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { state?.phase !== projectThreadAwarenessV2(awarenessInput)?.phase; return { projectId: input.thread.value.projectId, - state: sanitizeRelayAgentActivityState(state), + state: snoozedCompletion(input.thread.value, state) + ? null + : sanitizeRelayAgentActivityState(state), subagentWait: childDrivesWait ? input.pendingSubagentRequest : null, reason: "snapshot", }; } +// Checked at completion time, so a completion processed after the wake stays silent too. +function snoozedCompletion( + thread: AgentAwarenessThread, + state: { readonly phase: string } | null, +): boolean { + if (state?.phase !== "completed" || thread.latestRunCompletedAt == null) return false; + const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); + if (thread.snoozedAt != null && completedAt < DateTime.toEpochMillis(thread.snoozedAt)) { + return false; + } + return isSnoozed(thread, completedAt); +} + function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number): boolean { return ( thread.latestRunCompletedAt != null && diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index ac699b9c7d6cb..4bfe776a3aba8 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -29,6 +29,7 @@ import { readServiceState, registerServiceTask, serviceLauncherIsRunning, + serviceLauncherIsServing, serviceLauncherLockPath, stateDirOwned, writeServiceState, @@ -201,6 +202,212 @@ const fakeServiceUnit = ( }); it.layer(NodeServices.layer)("service state persistence", (it) => { + it.effect.each([ + "startup", + "log write fails", + "home probe error", + "database probe error", + "pending update", + "update handoff", + "trial exit", + "failed trial start", + "stop", + ] as const)("waits for disk recovery during %s without losing the update lifecycle", (scenario) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-disk-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "statev2.sqlite"); + let backupPath = path.join(root, "runtime", "db-backup", "disk-update"); + const pending = { + id: "disk-update", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + dbPath: databasePath, + status: "pending" as const, + }; + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, "before trial"); + const childSource = ` +const fs = require("node:fs"); +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + if (context.childVersion !== context.update.targetVersion) throw new Error("wrong trial version"); + if (fs.readFileSync(${JSON.stringify(path.dirname(backupPath))} + "/" + context.update.id + "/database", "utf8") !== "before trial") { + throw new Error("missing original backup"); + } + if (${JSON.stringify(scenario)} === "trial exit") { + fs.writeFileSync(context.update.dbPath, "migrated"); + process.on("message", () => process.exit(1)); + process.send("migrated"); + } else { + process.send({ type: "prepared", updateId: context.update.id }); + process.on("message", (message) => { + if (message.type === "committed") process.send("ready"); + }); + } +} else if (${JSON.stringify(scenario)} === "update handoff" && context.update === undefined) { + process.on("message", (message) => { + if (message.type === "update-accepted") process.send("exiting"); + if (message === "exit") process.exit(0); + }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${JSON.stringify(databasePath)} }); +} else { + process.send("ready"); +} +setInterval(() => {}, 1_000); +`; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + ...([ + "startup", + "log write fails", + "home probe error", + "database probe error", + "update handoff", + "stop", + ].includes(scenario) + ? {} + : { update: pending }), + }), + ); + let low = [ + "startup", + "log write fails", + "home probe error", + "database probe error", + "pending update", + "stop", + ].includes(scenario); + let recovered = false; + const blocked = Promise.withResolvers(); + const ready = Promise.withResolvers(); + const probing = Promise.withResolvers(); + const capacityResult = Promise.withResolvers(); + const actualStatfs = NodeFSP.statfs; + const stats = yield* Effect.promise(() => actualStatfs(root)); + yield* Effect.acquireRelease( + Effect.sync(() => { + vi.useFakeTimers({ + toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout"], + }); + const capacity = vi.spyOn(NodeFSP, "statfs").mockImplementation(async (directory) => { + if (scenario === "stop" && directory === root) { + probing.resolve(); + await capacityResult.promise; + } + if ( + (scenario === "home probe error" && directory === root) || + (scenario === "database probe error" && directory === path.dirname(databasePath)) + ) { + throw new Error("statfs unavailable"); + } + if (scenario === "failed trial start" && !recovered && NodeFS.existsSync(backupPath)) + low = true; + return { ...stats, bavail: low ? 0 : 1024 ** 4, bsize: 1 }; + }); + const log = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + if (String(chunk).includes("Waiting for free space")) { + blocked.resolve(); + if (scenario === "log write fails") { + throw Object.assign(new Error("no space left"), { code: "ENOSPC" }); + } + } + return true; + }); + spawnHook.current = (child) => + child.on("message", (message) => { + if (message === "ready") ready.resolve(); + if (message === "migrated" || message === "exiting") { + low = true; + child.send("exit"); + } + }); + return { capacity, log }; + }), + ({ capacity, log }) => + Effect.sync(() => { + capacity.mockRestore(); + log.mockRestore(); + spawnHook.current = undefined; + vi.useRealTimers(); + }), + ); + const beforeSpawns = spawnedChildren.length; + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + const running = launcher.run(); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await launcher.stop("SIGTERM"); + await running; + }), + ); + if (scenario === "stop") { + yield* Effect.promise(() => probing.promise); + const stopping = launcher.stop("SIGTERM"); + capacityResult.resolve(); + yield* Effect.promise(() => stopping); + yield* Effect.promise(() => running); + assert.equal(spawnedChildren.length, beforeSpawns); + assert.equal(vi.getTimerCount(), 0); + return; + } + yield* Effect.promise(() => Promise.race([blocked.promise, ready.promise, running])); + assert.equal( + spawnedChildren.length - beforeSpawns, + ["trial exit", "update handoff"].includes(scenario) ? 1 : 0, + ); + if (scenario === "update handoff") { + const state = yield* Effect.promise(() => readServiceState(statePath)); + backupPath = path.join(root, "runtime", "db-backup", state.update!.id); + } + assert.equal( + yield* fs.exists(backupPath), + scenario === "trial exit" || scenario === "failed trial start", + ); + if (scenario === "trial exit") + assert.equal(yield* fs.readFileString(databasePath), "migrated"); + if (scenario === "failed trial start") { + yield* fs.remove(path.join(root, "runtime", "versions", "1.1.0"), { recursive: true }); + } + low = false; + recovered = true; + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(30_000)); + yield* Effect.promise(() => Promise.race([ready.promise, running])); + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal( + state.activeVersion, + ["pending update", "update handoff"].includes(scenario) ? "1.1.0" : "1.0.0", + ); + assert.equal( + state.update?.status, + { + startup: undefined, + "log write fails": undefined, + "home probe error": undefined, + "database probe error": undefined, + "pending update": "committed", + "update handoff": "committed", + "trial exit": "rolled-back", + "failed trial start": "failed", + }[scenario], + ); + assert.equal(yield* fs.readFileString(databasePath), "before trial"); + assert.isFalse(yield* fs.exists(backupPath)); + }), + ); + it.effect( "loads optional service.env literally without changing inherited or managed variables", () => @@ -748,8 +955,11 @@ process.exit(0); }); yield* Effect.promise(() => refused.promise); assert.isFalse(completed); + // An idle launcher must not make an SSH launch wait for a server it is not starting. + assert.isFalse(yield* Effect.promise(() => serviceLauncherIsServing(root))); otherOwner.close(); yield* Effect.promise(() => owned.promise); + assert.isTrue(yield* Effect.promise(() => serviceLauncherIsServing(root))); yield* Effect.promise(() => launcher.stop("SIGTERM")); yield* Effect.promise(() => running); assert.strictEqual(spawnedChildren.length - spawnsBefore, 2); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index d57a3abaccc99..fad391896d307 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -30,6 +30,7 @@ import { serviceUnitLogPathOf, SERVICE_ENV_FILE, SERVICE_LAUNCHER_LOCK_FILE, + SERVICE_SERVING_LOCK_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, @@ -50,6 +51,9 @@ const HANDOFF_DELAY_MS = 2_000; */ const PREPARED_TIMEOUT_MS = 30 * 60_000; const TERMINATE_GRACE_MS = 5_000; +// Keep SQLite startup and journaling from turning a full filesystem into a restart loop. +const MIN_SERVICE_START_FREE_BYTES = 64 * 1024 ** 2; +const DISK_SPACE_RETRY_MS = 30_000; // A replacement started by a handoff waits up to two minutes for the stopping launcher. const LOCK_RETRY_MS = 500; const LOCK_ATTEMPTS = 240; @@ -755,11 +759,12 @@ export const serviceLauncherLockPath = (baseDir: string) => // An exclusive SQLite transaction, like the server's owner lock: the OS drops it when the holder dies. async function tryAcquireSqliteLock( lockPath: string, + busyTimeoutMs = 0, ): Promise<{ readonly close: () => void } | undefined> { const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(lockPath); try { - db.exec("PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;"); + db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}; BEGIN EXCLUSIVE;`); return db; } catch (cause) { db.close(); @@ -775,6 +780,13 @@ export async function serviceLauncherIsRunning(lockPath: string): Promise + NodePath.join(baseDir, "runtime", SERVICE_SERVING_LOCK_FILE); + +/** Whether a launcher has, or is about to start, a server for this home. An idle one does not. */ +export const serviceLauncherIsServing = (baseDir: string): Promise => + serviceLauncherIsRunning(servingLockPath(baseDir)).catch(() => false); + /** Resolves once no launcher holds the lock; the caller bounds the wait through `signal`. */ export async function waitForServiceLauncherExit( lockPath: string, @@ -854,6 +866,7 @@ export class Launcher { #stateDirFreeProbes = 0; readonly #output: number | undefined; readonly #lockPath: string | undefined; + #servingLock: { readonly close: () => void } | undefined; readonly #stopChildOverIpc: boolean; constructor( @@ -907,6 +920,7 @@ export class Launcher { } } } finally { + this.#releaseServing(); process.off("SIGTERM", onSigterm); process.off("SIGINT", onSigint); } @@ -924,13 +938,29 @@ export class Launcher { return undefined; } + // Advisory: without it an SSH launch may start its own server, as it does beside no service. + async #claimServing(): Promise { + // An SSH launch probes this lock for a moment; wait out that probe. + this.#servingLock ??= await tryAcquireSqliteLock(servingLockPath(this.#baseDir), 250).catch( + () => undefined, + ); + } + + #releaseServing(): void { + this.#servingLock?.close(); + this.#servingLock = undefined; + } + #terminate(child: NodeChildProcess.ChildProcess, signal: NodeJS.Signals = "SIGTERM") { return terminateChild(child, signal, this.#stopChildOverIpc); } + // Diagnostics are best-effort: a full log filesystem must not stop the launcher waiting for space. #log(line: string): void { - if (this.#output === undefined) process.stderr.write(line); - else NodeFS.writeSync(this.#output, line); + try { + if (this.#output === undefined) process.stderr.write(line); + else NodeFS.writeSync(this.#output, line); + } catch {} } #enqueue(transition: () => Promise): void { @@ -983,6 +1013,55 @@ export class Launcher { this.#timer = undefined; } + async #serviceStartDiskSpaceBlocker(): Promise { + const databasePath = + this.#state.update?.status === "pending" + ? this.#state.update.dbPath + : NodePath.join(this.#baseDir, "userdata", "statev2.sqlite"); + const resolvedDatabasePath = await restoreTarget(databasePath).catch(() => databasePath); + const freeBytes = async (directory: string): Promise => { + try { + return await filesystemBackupSpaceProbe.readFreeBytes(directory); + } catch (cause) { + const parent = NodePath.dirname(directory); + if ((cause as NodeJS.ErrnoException).code === "ENOENT" && parent !== directory) { + return freeBytes(parent); + } + return Number.POSITIVE_INFINITY; + } + }; + const available = await Promise.all([ + freeBytes(this.#baseDir), + freeBytes(NodePath.dirname(resolvedDatabasePath)), + ]); + const free = Math.min(...available); + return free < MIN_SERVICE_START_FREE_BYTES + ? `T3 Code needs at least ${MIN_SERVICE_START_FREE_BYTES} bytes free before starting the server; ${free} bytes available.` + : undefined; + } + + async #deferForDiskSpace(retry: () => Promise): Promise { + const reason = await this.#serviceStartDiskSpaceBlocker(); + if (reason === undefined) return false; + // A stop during the probe must not arm a retry that outlives it. + if (this.#stopRequested) return true; + this.#clearTimer(); + this.#releaseServing(); + this.#log(`[service-launcher] ${reason} Waiting for free space before retrying.\n`); + this.#timer = setInterval( + () => + this.#enqueue(async () => { + if (this.#child !== null || this.#stopping) return; + if ((await this.#serviceStartDiskSpaceBlocker()) !== undefined) return; + this.#clearTimer(); + await this.#claimServing(); + await retry(); + }), + DISK_SPACE_RETRY_MS, + ); + return true; + } + async #selectState(next: ServiceState): Promise { const release = await acquireServiceSelectionLock(this.#statePath); try { @@ -1004,6 +1083,7 @@ export class Launcher { } async #recover(): Promise { + await this.#claimServing(); // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); @@ -1057,6 +1137,7 @@ export class Launcher { } async #startTrial(pending: PendingServiceUpdate): Promise { + if (await this.#deferForDiskSpace(() => this.#startTrial(pending))) return; try { await backupDatabaseOnce(this.#baseDir, pending, this.#stopSignal.signal); } catch { @@ -1075,6 +1156,14 @@ export class Launcher { async #startChild(version: string, role: ChildRole, update?: ServiceUpdateRecord): Promise { if (this.#stopping) return; + if ( + await this.#deferForDiskSpace(() => + role === "trial" && update?.status === "pending" + ? this.#startTrial(update) + : this.#startChild(version, role, update), + ) + ) + return; if (!(await runtimeExists(this.#baseDir, version))) { throw new Error(`Selected t3@${version} runtime is missing or incomplete.`); } @@ -1333,6 +1422,12 @@ export class Launcher { await this.#startTrial(pending); return; } + if ( + await this.#deferForDiskSpace(() => + this.#startChild(child.version, "active", this.#state.update), + ) + ) + return; throw new Error(`Active child exited unexpectedly (${String(code ?? signal ?? "unknown")}).`); } @@ -1358,6 +1453,7 @@ export class Launcher { await this.#repointServiceUnit(next.activeVersion); } } + this.#releaseServing(); this.#log( "[service-launcher] Another T3 Code server owns this T3 home. This service starts again once it stops.\n", ); @@ -1382,6 +1478,7 @@ export class Launcher { this.#stateDirFreeProbes = owned ? 0 : this.#stateDirFreeProbes + 1; if (this.#stateDirFreeProbes < 2) return; this.#clearTimer(); + await this.#claimServing(); this.#log("[service-launcher] The T3 home is free again. Starting the server.\n"); const update = this.#state.update; if (update?.status === "pending") await this.#startTrial(update); @@ -1398,6 +1495,8 @@ export class Launcher { this.#child = null; await this.#terminate(child.process); } + if (await this.#deferForDiskSpace(() => this.#returnToPrevious(pending, status, reason))) + return; await restoreDatabaseBackup(this.#baseDir, pending); const outcome = terminalUpdate({ pending, status, reason }); const next: ServiceState = { @@ -1432,6 +1531,8 @@ export async function main(options: LauncherOptions = {}): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const windows = process.platform === "win32"; let logTimer: NodeJS.Timeout | undefined; + // A failed stderr write (ENOSPC on a redirected log) is emitted asynchronously; without a listener it is fatal. + process.stderr.on("error", () => undefined); try { // What the service manager started the launcher with. It locates the unit; service.env // changes only what the server gets. diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 65d7262aa06f9..d8f87dc8efe64 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1075,6 +1075,8 @@ describe("subagent worktree ownership", () => { it.effect.each([ "idle", "grandchild", + "branchless", + "branchless-running", "null-branch", "stale-branch", "isolated-owner", @@ -1106,7 +1108,7 @@ describe("subagent worktree ownership", () => { }, }); const root = shell({ - branch: "feature", + branch: ["branchless", "branchless-running"].includes(scenario) ? null : "feature", worktreePath: fixture.worktree, settledAt: scenario === "child-settled-only" ? null : at(-20 * DAY_MS), ...(scenario === "isolated-owner" || scenario === "fork-owner" @@ -1148,7 +1150,7 @@ describe("subagent worktree ownership", () => { : "subagent", ...(scenario === "side-chat" ? { sideChat: true } : {}), }, - status: scenario === "running" ? "running" : "completed", + status: ["running", "branchless-running"].includes(scenario) ? "running" : "completed", pendingBackgroundTasks: scenario === "pending" ? [{ taskId: "task", kind: "command" }] : [], latestRunCompletedAt: scenario === "recent-activity" || scenario === "recent-inactive" @@ -1197,7 +1199,7 @@ describe("subagent worktree ownership", () => { outcome: removed ? "removed" : "kept", reason: removed ? "Settled for 20 days" - : scenario === "running" || scenario === "pending" + : ["running", "pending", "branchless-running"].includes(scenario) ? "Thread is running or has pending work" : shared ? "Shared by 2 threads" @@ -1209,7 +1211,9 @@ describe("subagent worktree ownership", () => { ? "Inactive for 2 of 8 days" : ["recent-grace", "recent-settle", "child-settled-now"].includes(scenario) ? "Settled moments ago" - : "Not settled", + : scenario === "branchless" + ? "Thread has no branch" + : "Not settled", }); }).pipe(Effect.provide(cleanupTestLayer)), ); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index bd1cc619f9433..dde478f753cb6 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1215,11 +1215,9 @@ const make = Effect.gen(function* () { if (members.length > 1 && worktreeOwner(members) === undefined) return keep(`shared by ${members.length} threads`); if (project === undefined) return keep("project is unavailable"); - if ( - (thread.branch === null && !scratch) || - !group.every((member) => storageCleanupThreadIdle(member, now, serverSettings)) - ) + if (!group.every((member) => storageCleanupThreadIdle(member, now, serverSettings))) return keep(waitForStop("thread is running or has pending work")); + if (thread.branch === null && !scratch) return keep("thread has no branch"); if (group.some((member) => member.branch !== thread.branch)) return keep("a subagent thread's branch differs from this worktree's branch"); if (yield* hasTerminal(worktreePaths)) return keep(waitForStop("open terminal")); diff --git a/apps/server/src/telemetry/ProviderDimensions.test.ts b/apps/server/src/telemetry/ProviderDimensions.test.ts index d88010f600e35..00e59c11228cb 100644 --- a/apps/server/src/telemetry/ProviderDimensions.test.ts +++ b/apps/server/src/telemetry/ProviderDimensions.test.ts @@ -76,3 +76,16 @@ it("drops input strings outside the schema's literals, since rejected calls are handoffSettings("t3_thread_launch", { workspaceStrategy: { type: "/home/me/secret" } }, {}), ).toEqual({ targetChosen: false, workspace: "root" }); }); + +it("records the binding the saved scheduled task resolved to, and none for failures", () => { + expect(handoffSettings("schedule_task", {}, { boundThreadId: null })).toEqual({ + bindToCurrentThread: false, + }); + expect( + handoffSettings( + "schedule_task", + { bindToCurrentThread: true }, + { _tag: "OrchestratorMcpFailure", code: "invalid_request", message: "Different project." }, + ), + ).toEqual({}); +}); diff --git a/apps/server/src/telemetry/ProviderDimensions.ts b/apps/server/src/telemetry/ProviderDimensions.ts index f81a22e844aef..8352ed0d15cc0 100644 --- a/apps/server/src/telemetry/ProviderDimensions.ts +++ b/apps/server/src/telemetry/ProviderDimensions.ts @@ -128,9 +128,9 @@ function callerOrigin(input: { } /** - * Settings the agent chose for a handoff, read only from closed enums and - * booleans in the tool's arguments and result. Prompts, titles, and ids are - * never read. + * Settings for a handoff, recorded only as closed enums and booleans derived + * from the tool's arguments and result. Prompts, titles, and ids are never + * recorded. */ export function handoffSettings(tool: string, args: unknown, result: unknown): Fields { const input = asFields(args); @@ -166,8 +166,13 @@ export function handoffSettings(tool: string, args: unknown, result: unknown): F return defined({ delivery: stringField(output, "delivery") }); case "t3_thread_send_attachments": return { delivery: "auto" }; - case "schedule_task": - return { bindToCurrentThread: input.bindToCurrentThread !== false }; + case "schedule_task": { + // Omitting the input lets the server decide, so read the saved task. + const bound = output.boundThreadId; + return bound === null || typeof bound === "string" + ? { bindToCurrentThread: bound !== null } + : {}; + } default: return {}; } diff --git a/apps/server/src/usage/UsageLimitSources.test.ts b/apps/server/src/usage/UsageLimitSources.test.ts index d7e73a1da96c9..fc8a93f9e1bc6 100644 --- a/apps/server/src/usage/UsageLimitSources.test.ts +++ b/apps/server/src/usage/UsageLimitSources.test.ts @@ -44,6 +44,7 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => "https://hub.test:8317", undefined, ]); + expect(snapshots[3]?.endpoint).toBe("https://hub.test:8317/team-a"); expect(() => encodeConfigEvent({ version: 1, @@ -84,7 +85,7 @@ it.effect("keeps malformed source URLs encodable for config subscribers", () => }, [UsageLimitSourceId.make("valid-hub")]: { kind: "cliproxy", - url: "https://hub.test:8317", + url: "https://hub.test:8317/team-a/management.html", managementKey: "", enabled: true, }, diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index f28bbff0d367b..fe3f2b9d79d96 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -89,7 +89,12 @@ export const make = Effect.gen(function* () { id, kind: config.kind, label: sourceLabel(id, config), - ...(url && /^https?:$/.test(url.protocol) ? { origin: url.origin } : {}), + ...(url && /^https?:$/.test(url.protocol) + ? { + origin: url.origin, + endpoint: `${url.origin}${url.pathname.replace(/(\/management\.html)?\/*$/i, "")}`, + } + : {}), checkedAt, } as const; const managementKey = yield* readManagementKey(config); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9c616eb02ac1b..ed71046036c6d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -522,6 +522,73 @@ describe("Codex usage history", () => { }); describe("UsageService", () => { + it.effect("keeps pricing metadata paired with the rates used during a concurrent refresh", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const sessions = NodePath.join(home, "codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, "session.jsonl"), + [ + { type: "session_meta", payload: { id: "session", model_provider: "openai" } }, + { type: "turn_context", payload: { model: "gpt-6-astra", turn_id: "turn" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 0, output_tokens: 1 } }, + }, + }, + ] + .map((record) => encodeUnknownJsonString(record)) + .join("\n") + "\n", + ); + }); + let rate = 0; + yield* Effect.gen(function* () { + const resolving = yield* Deferred.make(); + const release = yield* Deferred.make(); + const service = yield* UsageService.make.pipe( + Effect.provideService(CodexUsageHistory.CodexUsageHistory, { + resolve: () => + Deferred.succeed(resolving, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(new Map()), + ), + }), + ); + const initialPricing = yield* service.refreshRates; + yield* TestClock.adjust(61_000); + const pending = yield* service.readSummary(WINDOW).pipe(Effect.forkChild); + yield* Deferred.await(resolving); + yield* service.refreshRates; + yield* Deferred.succeed(release, undefined); + const original = yield* Fiber.join(pending); + assert.strictEqual(original.buckets[0]?.costUsd, 1); + assert.strictEqual(original.pricing.fetchedAt, initialPricing.fetchedAt); + const updated = yield* service.readSummary(WINDOW); + assert.strictEqual(updated.buckets[0]?.costUsd, 2); + assert.notStrictEqual(updated.pricing.fetchedAt, initialPricing.fetchedAt); + }).pipe( + Effect.provide( + layerService({ + prefix: "concurrent-rates", + home, + settings, + ratesResponse: () => + Effect.succeed( + Response.json({ + "gpt-6-astra": { input_cost_per_token: 0, output_cost_per_token: ++rate }, + }), + ), + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.effect("counts compressed Codex rollouts across refresh, rewrites, and cache restarts", () => Effect.gen(function* () { const { home, settings } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index fbbc1205868ee..638d2fe2d2d97 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -881,6 +881,7 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); + const summaryPricing = pricing(); const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, @@ -1116,7 +1117,7 @@ export const make = Effect.gen(function* () { untilDay: input.untilDay, buckets: aggregated.buckets, sources, - pricing: pricing(), + pricing: summaryPricing, scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), } satisfies UsageSummary; diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index a8c0052452ed7..8912e9c235135 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,3 +1,5 @@ +import * as NodeNet from "node:net"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; @@ -173,6 +175,392 @@ const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( return { git, checkpointRef }; }); +it.effect.each(["interrupt", "timeout", "success"] as const)( + "checkpoint bulk staging cleans operation-owned packs after $0 and can retry", + (stop) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const liveProcess = yield* VcsProcess.VcsProcess; + const sandbox = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bulk-checkpoint-" }); + const cwd = path.join(sandbox, "repo"); + yield* fs.makeDirectory(cwd); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + const originalCheckpoint = (yield* git(["rev-parse", checkpointRef])).stdout; + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const originalHead = (yield* git(["rev-parse", "HEAD"])).stdout; + yield* git(["config", "core.bigFileThreshold", "1m"]); + const crypto = yield* SecureRandom.SecureRandom; + const payload = yield* crypto.randomBytes(2 * 1024 * 1024); + yield* fs.writeFile(path.join(cwd, "a-media.bin"), payload); + + // Git keeps bulk-checkin's pack open while the next path's clean filter runs. + // The socket is a barrier, not a race against the speed of compression. + const connected = Promise.withResolvers(); + const sockets = new Set(); + let released = false; + const server = yield* Effect.acquireRelease( + Effect.sync(() => + NodeNet.createServer((socket) => { + sockets.add(socket); + connected.resolve(socket); + // Git can run the filter again while refreshing the staged entry. + if (released) socket.end(); + }), + ), + (server) => + Effect.sync(() => { + for (const socket of sockets) socket.destroy(); + server.close(); + }), + ); + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }), + ); + const address = server.address(); + assert.isTrue(address !== null && typeof address !== "string"); + if (address === null || typeof address === "string") return; + const filter = path.join(sandbox, "filter.cjs"); + yield* fs.writeFileString( + filter, + `const s = require('node:net').connect(${address.port}, '127.0.0.1'); s.resume(); process.stdin.resume(); s.on('end', () => process.exit(0));`, + ); + yield* git(["config", "filter.barrier.clean", `"${process.execPath}" "${filter}"`]); + yield* git(["config", "filter.barrier.required", "true"]); + yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "z-gate filter=barrier\n"); + yield* fs.writeFileString(path.join(cwd, "z-gate"), "gate\n"); + const sharedPack = path.join(cwd, ".git/objects/pack/tmp_pack_other_operation"); + yield* fs.writeFileString(sharedPack, "owned by another Git operation"); + let objectDirectory = path.join(cwd, ".git/objects"); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + if (input.args.includes("add") && input.env?.GIT_OBJECT_DIRECTORY) + objectDirectory = input.env.GIT_OBJECT_DIRECTORY; + return liveProcess.run(input); + }, + }), + ); + const fiber = yield* captureDriver.checkpoints + .captureCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.exit, Effect.forkScoped); + const socket = yield* Effect.promise(() => connected.promise).pipe( + Effect.raceFirst( + Fiber.join(fiber).pipe( + Effect.flatMap((result) => + Exit.isFailure(result) + ? Effect.failCause(result.cause) + : Effect.die("capture ended before the filter connected"), + ), + ), + ), + ); + const packs = (yield* fs.readDirectory(path.join(objectDirectory, "pack"))).filter( + (name) => name.startsWith("tmp_pack_") && name !== "tmp_pack_other_operation", + ); + assert.strictEqual(packs.length, 1); + assert.isAbove( + Number((yield* fs.stat(path.join(objectDirectory, "pack", packs[0]!))).size), + 1024 * 1024, + ); + // An overlapping capture publishes the same large blob while the first + // still owns an unfinished pack. Its objects/ref must survive either exit. + const concurrentRef = CheckpointRef.make("refs/t3/checkpoints/concurrent"); + const concurrentDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + liveProcess.run({ ...input, args: ["-c", "filter.barrier.clean=cat", ...input.args] }), + }), + ); + yield* concurrentDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef: concurrentRef }); + const concurrentOid = (yield* git(["rev-parse", concurrentRef])).stdout; + const sharedPackNames = (yield* fs.readDirectory(path.join(cwd, ".git/objects/pack"))).sort(); + const packName = sharedPackNames.find((name) => name.endsWith(".pack")); + assert.isDefined(packName); + const sharedPackBytes = yield* fs.readFile(path.join(cwd, ".git/objects/pack", packName!)); + if (stop === "interrupt") yield* Fiber.interrupt(fiber); + else if (stop === "timeout") yield* TestClock.adjust("30 seconds"); + released = true; + socket.end(); + const result = yield* Fiber.await(fiber); + if (stop === "interrupt") + assert.isTrue(Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)); + else if (stop === "timeout") { + assert.isTrue(Exit.isSuccess(result)); + if (Exit.isSuccess(result)) { + assert.isTrue(Exit.isFailure(result.value)); + if (Exit.isFailure(result.value)) { + const error = Cause.findErrorOption(result.value.cause); + assert.isTrue(Option.isSome(error) && error.value._tag === "VcsProcessTimeoutError"); + } + } + } else assert.isTrue(Exit.isSuccess(result) && Exit.isSuccess(result.value)); + assert.deepEqual( + (yield* fs.readDirectory(path.join(cwd, ".git/objects/pack"))).sort(), + sharedPackNames, + ); + assert.deepEqual( + yield* fs.readFile(path.join(cwd, ".git/objects/pack", packName!)), + sharedPackBytes, + ); + assert.strictEqual((yield* git(["rev-parse", concurrentRef])).stdout, concurrentOid); + assert.isFalse(yield* fs.exists(path.join(objectDirectory, "pack", packs[0]!))); + assert.isFalse(yield* fs.exists(objectDirectory)); + assert.strictEqual(yield* fs.readFileString(sharedPack), "owned by another Git operation"); + if (stop !== "success") + assert.strictEqual((yield* git(["rev-parse", checkpointRef])).stdout, originalCheckpoint); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + assert.strictEqual((yield* git(["rev-parse", "HEAD"])).stdout, originalHead); + assert.deepEqual(yield* fs.readFile(path.join(cwd, "a-media.bin")), payload); + + yield* git(["config", "filter.barrier.clean", "cat"]); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.isFalse(yield* fs.exists(objectDirectory)); + const blob = (yield* git(["hash-object", "a-media.bin"])).stdout.trim(); + assert.strictEqual( + (yield* git(["rev-parse", `${checkpointRef}:a-media.bin`])).stdout.trim(), + blob, + ); + yield* fs.remove(sharedPack); + yield* git(["fsck", "--full"]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect.each(["failure", "interrupt"] as const)( + "checkpoint publication $0 preserves existing refs and permits retry", + (stop) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-publish-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + const previous = (yield* git(["show-ref"])).stdout; + yield* fs.writeFileString(path.join(cwd, "new.txt"), "new checkpoint content\n"); + const published = yield* Deferred.make(); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + link: (source, destination) => + fs.link(source, destination).pipe( + Effect.andThen(Deferred.succeed(published, undefined)), + Effect.andThen( + stop === "interrupt" + ? Effect.never + : Effect.fail( + PlatformError.systemError({ + _tag: "BadResource", + module: "FileSystem", + method: "link", + }), + ), + ), + ), + }), + ); + const fiber = yield* captureDriver.checkpoints + .captureCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(published); + if (stop === "interrupt") yield* Fiber.interrupt(fiber); + const result = yield* Fiber.await(fiber); + if (stop === "interrupt") + assert.isTrue(Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)); + else { + assert.isTrue(Exit.isSuccess(result) && Exit.isFailure(result.value)); + if (Exit.isSuccess(result) && Exit.isFailure(result.value)) { + const error = Cause.findErrorOption(result.value.cause); + assert.isTrue( + Option.isSome(error) && + error.value._tag === "VcsCheckpointStorageError" && + error.value.stage === "publish", + ); + } + } + assert.strictEqual((yield* git(["show-ref"])).stdout, previous); + assert.deepEqual( + (yield* fs.readDirectory(path.join(cwd, ".git/objects"))).filter((name) => + name.startsWith("tmp_objdir-t3-checkpoint-"), + ), + [], + ); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:new.txt`])).stdout, + "new checkpoint content\n", + ); + yield* git(["fsck", "--full"]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect.each([ + { stop: "before git restore spawns", gate: "restore", index: "staged", file: "later\n" }, + { stop: "after git restore ran", gate: "clean", index: "head", file: "unstaged\n" }, +] as const)( + "checkpoint restore interrupted $stop leaves the $index index", + ({ gate, index, file }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const liveProcess = yield* VcsProcess.VcsProcess; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-restore-interrupt-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* fs.writeFileString(path.join(cwd, "file.txt"), "later\n"); + yield* git(["add", "file.txt"]); + const stagedTree = (yield* git(["write-tree"])).stdout; + const entered = yield* Deferred.make(); + const gatedDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + input.operation === "GitVcsDriver.checkpoints.restoreCheckpoint" && + input.args.includes(gate) + ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)) + : liveProcess.run(input), + }), + ); + const fiber = yield* gatedDriver.checkpoints + .restoreCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.exit, Effect.forkScoped); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + const result = yield* Fiber.await(fiber); + assert.isTrue(Exit.isFailure(result) && Cause.hasInterruptsOnly(result.cause)); + + assert.strictEqual( + (yield* git(["write-tree"])).stdout, + index === "head" ? (yield* git(["rev-parse", "HEAD^{tree}"])).stdout : stagedTree, + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, "file.txt")), file); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("checkpoint publication falls back to rename where hardlinks are unavailable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* SecureRandom.SecureRandom; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-publish-no-links-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* git(["config", "core.bigFileThreshold", "1m"]); + yield* fs.writeFile(path.join(cwd, "media.bin"), yield* crypto.randomBytes(2 * 1024 * 1024)); + const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + link: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "link", + }), + ), + }), + ); + yield* captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + const files = yield* fs.readDirectory(path.join(cwd, ".git/objects/pack")); + assert.isTrue(files.some((name) => name.endsWith(".pack"))); + assert.isTrue(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef })); + assert.match((yield* git(["count-objects", "-v"])).stdout, /^garbage: 0$/m); + yield* git(["fsck", "--full"]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "checkpoint publication preserves Git's shared object directory permissions", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-publish-shared-" }); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "test.shared", cwd, args }); + yield* git(["init", "--shared=0660"]); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/shared"); + yield* fs.writeFileString(path.join(cwd, "shared.txt"), "new shared object\n"); + const blob = (yield* git(["hash-object", "-w", "shared.txt"])).stdout.trim(); + const directory = path.join(cwd, ".git/objects", blob.slice(0, 2)); + const expectedMode = (yield* fs.stat(directory)).mode & 0o7777; + yield* fs.remove(directory, { recursive: true }); + assert.isFalse(yield* fs.exists(directory)); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + assert.strictEqual((yield* fs.stat(directory)).mode & 0o7777, expectedMode); + yield* git(["fsck", "--full"]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect("checkpoint capture ignores an inherited object directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const sandbox = yield* fs.makeTempDirectoryScoped({ prefix: "t3-inherited-objects-" }); + const cwd = path.join(sandbox, "repo"); + yield* fs.makeDirectory(cwd); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const unrelated = path.join(sandbox, "unrelated"); + yield* git(["init", "--bare", unrelated]); + const unrelatedObjects = path.join(unrelated, "objects"); + // VcsProcess clears inherited selectors from git children; only the driver sees this one. + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }).pipe( + Effect.provideService(HostProcess.Environment, { + ...(yield* HostProcess.Environment), + GIT_OBJECT_DIRECTORY: unrelatedObjects, + }), + ); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + assert.deepEqual( + (yield* fs.readDirectory(unrelatedObjects)).filter( + (name) => name !== "info" && name !== "pack", + ), + [], + ); + yield* git(["fsck", "--full"]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + +it.effect.each(["sha1", "sha256"] as const)( + "checkpoint object isolation supports linked worktrees with $0 objects", + (format) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const sandbox = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-worktree-" }); + // A separator must remain part of the alternate's path, not split the list. + const platform = yield* HostProcess.Platform; + const cwd = path.join(sandbox, platform === "win32" ? "repo;space" : 'repo:space"\u0001'); + yield* fs.makeDirectory(cwd); + yield* driver.execute({ + operation: "test.init", + cwd, + args: ["init", `--object-format=${format}`], + }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const worktree = path.join(sandbox, "linked"); + yield* git(["worktree", "add", "-b", "linked", worktree]); + yield* fs.writeFileString(path.join(worktree, "untracked.txt"), "linked worktree\n"); + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + yield* driver.checkpoints.captureCheckpoint({ cwd: worktree, checkpointRef }); + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:untracked.txt`])).stdout, + "linked worktree\n", + ); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + yield* git(["fsck", "--full"]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +); + it.effect("checkpoint capture skips untracked nested repositories without a commit", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 4e6ae5f3ad6f4..603ec4ed824a3 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -4,6 +4,7 @@ import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -12,6 +13,7 @@ import { ChildProcessSpawner } from "effect/process"; import { GitCommandError, + VcsCheckpointStorageError, VcsProcessExitError, VcsUnsupportedOperationError, type VcsSwitchRefInput, @@ -37,6 +39,7 @@ import { } from "@t3tools/contracts"; import { isMissingExecutable } from "@t3tools/source-control-core/server/discovery"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, @@ -121,7 +124,10 @@ export interface ExecuteGitProgress { } export interface CreateWorktreeProgress { - /** Git refuses existing paths; claimed paths belong to this call and can be removed on cancel. */ + /** + * Runs uninterruptibly once the worktree exists; from then on the caller owns its cleanup. + * For new-branch calls on Git supporting add --reason, cancellation before the claim removes what it made. + */ readonly onWorktreeClaimed?: (path: string) => Effect.Effect; readonly onCheckoutProgress?: (input: { percent: number; @@ -183,12 +189,16 @@ export interface GitPushResult { status: "pushed" | "skipped_up_to_date"; branch: string; upstreamBranch?: string | undefined; + /** Set when `upstreamBranch` is on a configured push remote instead of the tracked upstream. */ + pushRemote?: string | undefined; setUpstream?: boolean | undefined; } export interface GitPushTarget { remoteName: string; branchName: string; + /** The tracked upstream is current, yet pushes go to this configured push remote. */ + viaPushRemote: boolean; } export interface GitRangeContext { @@ -569,6 +579,7 @@ const gitCommand = Effect.fn("GitVcsDriver.gitCommand")( args: ReadonlyArray, options?: { readonly stdin?: string; + readonly onSpawn?: () => void; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; @@ -584,6 +595,7 @@ const gitCommand = Effect.fn("GitVcsDriver.gitCommand")( cwd, spawnCwd: globalThis.process.cwd(), ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.onSpawn !== undefined ? { onSpawn: options.onSpawn } : {}), ...(options?.env !== undefined ? { env: options.env } : {}), ...(options?.allowNonZeroExit !== undefined ? { allowNonZeroExit: options.allowNonZeroExit } @@ -655,6 +667,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.onSpawn !== undefined ? { onSpawn: input.onSpawn } : {}), ...(input.env !== undefined ? { env: input.env } : {}), ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), @@ -1070,8 +1083,42 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const indexId = yield* random.uuidV4(); const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`); const hostEnv = yield* HostProcess.Environment; + // VcsProcess clears inherited object selectors, so every other command uses this store. + const sharedObjects = path.join(gitCommonDir, "objects"); + const quotedObjects = quoteGitPatchPath(sharedObjects); + // Isolate bulk-checkin packs so cleanup cannot remove another Git writer's objects. + const privateObjects = yield* Effect.acquireRelease( + fileSystem + .makeTempDirectory({ directory: sharedObjects, prefix: "tmp_objdir-t3-checkpoint-" }) + .pipe( + Effect.mapError( + (cause) => + new VcsCheckpointStorageError({ + operation, + cwd: input.cwd, + stage: "isolate", + cause, + }), + ), + ), + (directory) => + fileSystem.remove(directory, { recursive: true, force: true }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not remove private checkpoint objects", { + reason: cause.reason._tag, + }), + ), + ), + ); const commitEnv: NodeJS.ProcessEnv = { GIT_INDEX_FILE: tempIndexPath, + GIT_OBJECT_DIRECTORY: privateObjects, + // A filter must not publish refs that depend on our disposable store. + GIT_QUARANTINE_PATH: privateObjects, + // Git accepts C-quoted alternate paths, including path-list separators. + GIT_ALTERNATE_OBJECT_DIRECTORIES: quotedObjects.startsWith('"') + ? quotedObjects + : `"${quotedObjects}"`, GIT_AUTHOR_NAME: "T3 Code", GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", GIT_COMMITTER_NAME: "T3 Code", @@ -1375,6 +1422,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( GIT_INDEX_FILE: undefined, GIT_OBJECT_DIRECTORY: undefined, GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined, + GIT_QUARANTINE_PATH: undefined, }; const exclusions: Array = []; for (const entry of candidates) { @@ -1572,13 +1620,100 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } + // Git writers have exited; bound publication by fanout directory. + yield* fileSystem.readDirectory(privateObjects).pipe( + Effect.flatMap((directories) => + Effect.forEach( + directories.filter( + (directory) => directory === "pack" || /^[0-9a-f]{2}$/.test(directory), + ), + (directory) => + Effect.gen(function* () { + const source = path.join(privateObjects, directory); + const destination = path.join(sharedObjects, directory); + // Most fanouts already exist; avoid constructing an EEXIST error + // for each of them on every capture. mkdir still handles the race. + if (!(yield* fileSystem.exists(destination))) + yield* fileSystem.makeDirectory(destination).pipe( + Effect.andThen( + Effect.gen(function* () { + // Git has already applied core.sharedRepository to the source. + // chmod bypasses umask, but never changes a pre-existing bucket. + const { mode } = yield* fileSystem.stat(source); + yield* fileSystem.chmod(destination, mode & 0o7777); + }), + ), + Effect.catchIf( + (cause) => cause.reason._tag === "AlreadyExists", + () => Effect.void, + ), + Effect.uninterruptible, + ); + const names = yield* fileSystem.readDirectory(source); + // Per-object spans would swamp the capture span for large workspaces. + const linkObject = Effect.fnUntraced(function* (name: string) { + const from = path.join(source, name); + const to = path.join(destination, name); + yield* fileSystem.link(from, to).pipe( + Effect.catchIf( + (cause) => + cause.reason._tag === "PermissionDenied" || + cause.reason._tag === "Unknown", + () => + Effect.gen(function* () { + if (yield* fileSystem.exists(to)) return; + // Git also falls back to rename without hardlinks; identical object names are interchangeable. + yield* fileSystem.rename(from, to); + }), + ), + Effect.catchIf( + (cause) => cause.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); + }); + if (directory !== "pack") { + yield* Effect.forEach( + names.filter((name) => /^(?:[0-9a-f]{38}|[0-9a-f]{62})$/.test(name)), + linkObject, + { discard: true }, + ); + return; + } + const files = new Set(names); + for (const name of names) { + if (!/^pack-(?:[0-9a-f]{40}|[0-9a-f]{64})\.pack$/.test(name)) continue; + const base = name.slice(0, -5); + if (!files.has(`${base}.idx`)) continue; + // Link metadata before the pack so an I/O error cannot strand a + // large unindexed pack. Defer cancellation across this short group + // of links. Never roll back shared links: a concurrent writer may + // already depend on them. Ref publication still happens last. + yield* Effect.forEach( + [".keep", ".promisor", ".rev", ".idx", ".pack"] + .map((extension) => `${base}${extension}`) + .filter((file) => files.has(file)), + linkObject, + { discard: true }, + ).pipe(Effect.uninterruptible); + } + }), + { concurrency: 16, discard: true }, + ), + ), + Effect.mapError( + (cause) => + new VcsCheckpointStorageError({ operation, cwd: input.cwd, stage: "publish", cause }), + ), + ); + yield* execute({ operation, cwd: input.cwd, args: [...durableWrite, "update-ref", input.checkpointRef, commitOid], }); }).pipe(Effect.ensuring(cleanupTempIndex)); - }), + }, Effect.scoped), hasCheckpointRef: (input) => resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( @@ -1603,15 +1738,48 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( cwd: input.cwd, args: ["ls-files", "--cached", `--with-tree=${commitOid}`, "-z", "--", "."], }); - // An empty index and checkpoint have nothing for git restore's pathspec to match. - if (tracked.stdout.length > 0) { - const sparseConfig = yield* execute({ + const sparseConfig = + tracked.stdout.length > 0 + ? yield* execute({ + operation, + cwd: input.cwd, + args: ["config", "--bool", "core.sparseCheckout"], + allowNonZeroExit: true, + }) + : null; + const sparseCheckout = sparseConfig?.stdout.trim() === "true"; + // Restoring away the last tracked file can remove a nested workspace directory. + const recreateWorkspace = fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new VcsProcessExitError({ + operation, + command: "git restore", + cwd: input.cwd, + exitCode: 0, + detail: `Could not recreate the checkpoint workspace: ${cause.message}`, + }), + ), + ); + const resetIndex = Effect.gen(function* () { + // On Windows clean can remove './' and exit 0. + yield* recreateWorkspace; + yield* execute({ operation, cwd: input.cwd, - args: ["config", "--bool", "core.sparseCheckout"], - allowNonZeroExit: true, + args: [...longPaths, "reset", "--quiet", "--", "."], }); - const sparseCheckout = sparseConfig.stdout.trim() === "true"; + }); + let mutationStarted = false; + const onSpawn = () => { + mutationStarted = true; + }; + // Queued commands preserve user staging; spawned children stop before this scope resets it. + yield* Effect.addFinalizer((exit) => + mutationStarted && Exit.hasInterrupts(exit) ? resetIndex.pipe(Effect.ignore) : Effect.void, + ); + // An empty index and checkpoint have nothing for git restore's pathspec to match. + if (tracked.stdout.length > 0) { const restore = [...longPaths, "restore", "--source", commitOid, "--worktree", "--staged"]; // restore leaves skip-worktree paths alone. Outside a sparse checkout every such bit was // set by hand and capture recorded the file, so override them all. @@ -1619,6 +1787,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( operation, cwd: input.cwd, args: [...restore, ...(sparseCheckout ? [] : ["--ignore-skip-worktree-bits"]), "--", "."], + onSpawn, }); if (sparseCheckout) { // Sparse exclusions stay excluded; only skip-worktree paths the rules select are manual. @@ -1690,28 +1859,6 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( } } } - // Restoring away the last tracked file can remove a nested workspace directory. - const recreateWorkspace = fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new VcsProcessExitError({ - operation, - command: "git restore", - cwd: input.cwd, - exitCode: 0, - detail: `Could not recreate the checkpoint workspace: ${cause.message}`, - }), - ), - ); - const resetIndex = Effect.gen(function* () { - // On Windows clean can remove './' and exit 0. - yield* recreateWorkspace; - yield* execute({ - operation, - cwd: input.cwd, - args: [...longPaths, "reset", "--quiet", "--", "."], - }); - }); yield* recreateWorkspace; const clean = execute({ operation, @@ -1719,6 +1866,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [...longPaths, "clean", "-fd", "--", "."], env: { LC_ALL: "C" }, allowNonZeroExit: true, + onSpawn, }); let cleaned = yield* clean; // Removing a new .gitignore exposes the files it hid; clean again until nothing is left. @@ -1772,7 +1920,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* resetIndex; return true; - }), + }, Effect.scoped), diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index e16bd1f6716d3..eac688f599130 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -342,6 +344,11 @@ const git = ( return result.stdout.trim(); }); +const [, gitMajor, gitMinor] = + /(\d+)\.(\d+)/u.exec(NodeChildProcess.execFileSync("git", ["--version"], { encoding: "utf8" })) ?? + []; +const gitHasReftable = Number(gitMajor) > 2 || (Number(gitMajor) === 2 && Number(gitMinor) >= 45); + const gitWithStdin = ( cwd: string, args: ReadonlyArray, @@ -388,11 +395,11 @@ const initRepoWithCommit = ( return { initialBranch: head.trim().replace(/^ref: refs\/heads\//, "") }; }); -const commitInitialRepo = (cwd: string) => +const commitInitialRepo = (cwd: string, initArgs: ReadonlyArray = []) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; - yield* git(cwd, ["init"]); + yield* git(cwd, ["init", ...initArgs]); yield* fileSystem.writeFileString( pathService.join(cwd, ".git", "config"), "[user]\n\temail = test@test.com\n\tname = Test\n", @@ -550,7 +557,10 @@ it.effect.each([ assert.equal(created.failure.exitCode, hookExitCode); } const metadataQueries = spawnedArgs.filter((args) => args.includes("--git-common-dir")); - assert.deepStrictEqual(metadataQueries, [["rev-parse", "--git-common-dir"]]); + assert.deepStrictEqual( + metadataQueries, + Array.from({ length: 2 }, () => ["rev-parse", "--git-common-dir"]), + ); assert.isFalse(spawnedArgs.some((args) => args.includes("symbolic-ref"))); invalidationFailure = "none"; @@ -613,7 +623,7 @@ it.effect.each(["branch", "detached", "nested", "aliased-root", "aliased-nested" newRefName: "feature/cold", }); - assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 2); assert.isFalse(spawnedArgs.some((args) => args.includes("--show-toplevel"))); assert.isFalse(spawnedArgs.some((args) => args.includes("symbolic-ref"))); spawnedArgs.length = 0; @@ -675,7 +685,10 @@ it.effect.each(["bare", "unborn"] as const)( assert.equal(result.failure.operation, "GitVcsDriver.createWorktree.createBranch"); assert.equal(result.failure.detail, "git branch failed"); } - assert.equal(spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, 1); + assert.equal( + spawnedArgs.filter((args) => args.includes("--git-common-dir")).length, + state === "bare" ? 2 : 1, + ); assert.isFalse(spawnedArgs.some((args) => args[0] === "symbolic-ref")); spawnedArgs.length = 0; const refs = yield* driver.listRefs({ cwd }); @@ -722,24 +735,31 @@ it.effect("reads a branch whose name starts with a parenthesis as attached", () ).pipe(Effect.provide(layerTest)), ); -it.effect("follows a checkout made outside the driver after refs were cached", () => - Effect.scoped( - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["branch", "feature/outside"]); - const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.listRefs({ cwd }); +it.effect.each(["an existing", "a new"] as const)( + "follows a checkout of %s branch made outside the driver after refs were cached", + (branch) => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + if (branch === "an existing") yield* git(cwd, ["branch", "feature/outside"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.listRefs({ cwd }); - yield* git(cwd, ["switch", "feature/outside"]); + yield* git( + cwd, + branch === "an existing" + ? ["switch", "feature/outside"] + : ["switch", "-c", "feature/outside"], + ); - const refs = (yield* driver.listRefs({ cwd })).refs; - const previous = refs.find((ref) => ref.name === initialBranch); - assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current); - assert.isFalse(previous?.current); - assert.isNull(previous?.worktreePath ?? null); - }), - ).pipe(Effect.provide(layerTest)), + const refs = (yield* driver.listRefs({ cwd })).refs; + const previous = refs.find((ref) => ref.name === initialBranch); + assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current); + assert.isFalse(previous?.current); + assert.isNull(previous?.worktreePath ?? null); + }), + ).pipe(Effect.provide(layerTest)), ); it.effect.each(["on a branch", "detached", "unborn"] as const)( @@ -989,7 +1009,8 @@ it.effect("invalidates cached metadata after interrupted worktree creation", () !holdCheckout || !ChildProcess.isStandardCommand(command) || !command.args.includes("worktree") || - !command.args.includes("add") + // The unlock ends the checkout, so a cancel here keeps the new worktree. + !command.args.includes("unlock") ) return handle; holdCheckout = false; @@ -1548,6 +1569,53 @@ it.effect("reads worktrees from git that rejects worktree list -z", () => ), ); +it.effect("commits selected files with git that rejects --pathspec-from-file", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + // Git before 2.25 has no `add --pathspec-from-file` and exits with a usage error. + const oldGitSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) { + return Effect.die("expected a standard Git command"); + } + if (command.args.at(-1) === "version") { + return Effect.succeed(makeSuccessfulHandle("git version 2.24.4\n")); + } + if (command.args.some((arg) => String(arg).startsWith("--pathspec-from-file"))) { + return Effect.succeed(makeFailedHandle("error: unknown option `pathspec-from-file=-'")); + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, oldGitSpawner), + ); + yield* Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const selected = ["selected[1].txt", "-dash.txt", "with space.txt"]; + for (const file of selected) yield* writeTextFile(cwd, file, "selected\n"); + yield* writeTextFile(cwd, "left.txt", "left\n"); + + yield* driver.commit(cwd, "Selected", "", { stage: { filePaths: selected } }); + + assert.sameMembers( + (yield* git(cwd, ["show", "--pretty=", "--name-only", "-z", "HEAD"])) + .split("\0") + .filter(Boolean), + selected, + ); + assert.equal(yield* git(cwd, ["status", "--porcelain"]), "?? left.txt"); + }).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + }), + ).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + it.effect("does not report a separate git dir as the worktree of its branch", () => Effect.scoped( Effect.gen(function* () { @@ -1773,7 +1841,8 @@ it.effect("lets a worktree checkout that is still working run past five minutes" Effect.gen(function* () { if (!ChildProcess.isStandardCommand(command)) return yield* Effect.die("expected Git command"); - if (!command.args.includes("worktree")) return makeSuccessfulHandle(""); + if (!command.args.includes("worktree") || command.args.includes("-h")) + return makeSuccessfulHandle(""); yield* Deferred.succeed(checkoutStarted, undefined); return ChildProcessSpawner.makeHandle({ ...makeSuccessfulHandle(""), @@ -2223,6 +2292,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const spawner = ChildProcessSpawner.make((command) => { if ( ChildProcess.isStandardCommand(command) && + !command.args.includes("-h") && (command.args.includes("worktree") || command.args[0] === "branch" || command.args[0] === "checkout") @@ -3023,6 +3093,35 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps the review working beside a nested repository with no commits", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["init", "scaffold"]); + const committed = `${cwd}/committed`; + yield* (yield* FileSystem.FileSystem).makeDirectory(committed); + yield* initRepoWithCommit(committed); + yield* writeTextFile(cwd, "README.md", "# tracked change\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked contents\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd, ignoreWhitespace: false }); + const diff = preview.sources.find((source) => source.kind === "working-tree")?.diff ?? ""; + + assert.include(diff, "+# tracked change"); + assert.include(diff, "+untracked contents"); + assert.notInclude(diff, "scaffold"); + assert.include(diff, "diff --git a/committed b/committed"); + + const status = yield* driver.statusDetailsLocal(cwd, { includeBranchChanges: true }); + assert.deepStrictEqual(status.branchChanges, { + baseRef: null, + insertions: 3, + deletions: 1, + }); + }), + ); + it.effect("keeps untracked filenames with pathspec magic in the review", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -3615,6 +3714,77 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("loads working-tree contents as Git diffs them under the ident attribute", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const filePath = (yield* Path.Path).join(cwd, "README.md"); + const committed = "$Id$\nfirst\nsecond\n"; + yield* writeTextFile(cwd, ".gitattributes", "README.md ident\n"); + yield* writeTextFile(cwd, "README.md", committed); + yield* writeTextFile(cwd, "PLAIN.md", "plain\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add ident keyword"]); + // Checkout smudges the keyword to `$Id: $`. + yield* fileSystem.remove(filePath); + yield* git(cwd, ["checkout-index", "--force", "README.md"]); + const smudged = yield* fileSystem.readFileString(filePath); + assert.match(smudged, /^\$Id: [0-9a-f]{40} \$\n/); + yield* writeTextFile(cwd, "README.md", smudged.replace("first", "edited")); + yield* writeTextFile(cwd, "PLAIN.md", "$Id: literal $\n"); + const disk = yield* fileSystem.readFile(filePath); + + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + const plain = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { oldPath: "PLAIN.md", newPath: "PLAIN.md" }), + ); + + assert.deepStrictEqual(contents, { + oldContents: committed, + newContents: "$Id$\nedited\nsecond\n", + }); + assert.strictEqual(plain.newContents, "$Id: literal $\n"); + yield* writeTextFile(cwd, "README.md", "$Id: x $Id: y $\n"); + const sharedDollar = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + assert.strictEqual(sharedDollar.newContents, "$Id$Id: y $\n"); + yield* git(cwd, ["config", "filter.unrelated.clean", "touch filter-ran; cat"]); + yield* writeTextFile(cwd, ".gitattributes", "README.md ident text eol=lf\n"); + yield* writeTextFile( + cwd, + "README.md", + smudged.replace("first", "edited").replaceAll("\n", "\r\n"), + ); + const crlf = yield* driver.getReviewDiffFileContents(makeReviewDiffFileContentsInput(cwd)); + assert.strictEqual(crlf.newContents, "$Id$\r\nedited\r\nsecond\r\n"); + yield* fileSystem.writeFile(filePath, disk); + yield* writeTextFile(cwd, ".gitattributes", "README.md ident=set\n"); + const literal = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + assert.strictEqual(literal.newContents, smudged.replace("first", "edited")); + yield* writeTextFile(cwd, ".gitattributes", "README.md ident filter=probe\n"); + yield* git(cwd, ["config", "filter.probe.clean", "touch filter-ran; cat"]); + const filtered = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + assert.strictEqual(filtered.newContents, literal.newContents); + yield* writeTextFile(cwd, ".gitattributes", "README.md ident filter=unspecified\n"); + yield* git(cwd, ["config", "filter.unspecified.clean", "touch filter-ran; cat"]); + const reservedFilter = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd), + ); + assert.strictEqual(reservedFilter.newContents, literal.newContents); + assert.isFalse(yield* fileSystem.exists((yield* Path.Path).join(cwd, "filter-ran"))); + assert.deepStrictEqual(yield* fileSystem.readFile(filePath), disk); + }), + ); + it.effect("Changes combines commits, uncommitted edits, and untracked files", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -4562,7 +4732,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports remote status on unborn HEAD without failing", () => + it.effect("reports status on unborn HEAD without failing or dropping its upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; @@ -4576,6 +4746,17 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.equal(status.hasUpstream, false); assert.equal(status.aheadCount, 0); assert.equal(status.behindCount, 0); + + const seed = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(seed); + yield* git(remote, ["init", "--bare"]); + yield* git(seed, ["push", remote, `HEAD:refs/heads/${initialBranch}`]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["config", `branch.${initialBranch}.remote`, "origin"]); + yield* git(cwd, ["config", `branch.${initialBranch}.merge`, `refs/heads/${initialBranch}`]); + assert.equal((yield* driver.statusDetailsLocal(cwd)).hasUpstream, true); }), ); @@ -4895,25 +5076,21 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports staged file counts on unborn HEAD without failing", () => + it.effect("reports net file counts from the empty tree on unborn HEAD", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; yield* driver.initRepo({ cwd }); - yield* git(cwd, ["config", "user.email", "test@test.com"]); - yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(cwd, "initial.ts", "// first file\n"); yield* git(cwd, ["add", "initial.ts"]); + yield* writeTextFile(cwd, "initial.ts", "// edited after staging\n"); - const status = yield* driver.statusDetails(cwd); + const status = yield* driver.statusDetailsLocal(cwd); assert.equal(status.isRepo, true); - assert.equal(status.workingTree.files.length, 1); - const file = status.workingTree.files[0]; - if (file) { - assert.equal(file.path, "initial.ts"); - assert.equal(file.insertions, 1); - } + assert.deepStrictEqual(status.workingTree.files, [ + { path: "initial.ts", insertions: 1, deletions: 0 }, + ]); }), ); }); @@ -5321,6 +5498,172 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect.each(["current", "legacy"] as const)( + "preserves another creator's worktree on its new branch at the same path (%s)", + (version) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const path = NodePath.join(yield* makeTmpDir("git-worktrees-"), "contested"); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const reachedAdd = yield* Deferred.make(); + let paused = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if ( + ChildProcess.isStandardCommand(command) && + command.args.includes("worktree") && + command.args.includes("add") + ) { + if (command.args.includes("-h")) { + return version === "legacy" + ? makeFailedHandle("usage: git worktree add [--lock]") + : yield* delegate.spawn(command); + } + if (!paused) { + paused = true; + yield* Deferred.succeed(reachedAdd, undefined); + return yield* Effect.never; + } + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + const creator = yield* driver + .createWorktree({ + cwd, + path, + refName: initialBranch, + newRefName: "contested", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(reachedAdd); + yield* git(cwd, ["worktree", "add", "--", path, "contested"]); + yield* writeTextFile(path, "owned-by-B.txt", "keep this work\n"); + yield* Fiber.interrupt(creator); + assert.include(yield* git(cwd, ["worktree", "list", "--porcelain"]), path); + assert.equal(yield* git(path, ["branch", "--show-current"]), "contested"); + assert.equal( + yield* (yield* FileSystem.FileSystem).readFileString( + NodePath.join(path, "owned-by-B.txt"), + ), + "keep this work\n", + ); + const created = yield* driver.createWorktree({ + cwd, + path: `${path}-retry`, + refName: initialBranch, + newRefName: "retry", + }); + assert.equal(yield* git(created.worktree.path, ["branch", "--show-current"]), "retry"); + assert.notInclude(yield* git(cwd, ["worktree", "list", "--porcelain"]), "locked"); + }), + ); + + it.effect.each(["main", "linked"] as const)( + "removes its worktree and branch when cancelled before the claim (%s)", + (checkout) => + Effect.gen(function* () { + const repo = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(repo); + const cwd = + checkout === "main" + ? repo + : NodePath.join(yield* makeTmpDir("git-linked-"), "checkout"); + if (checkout === "linked") { + yield* git(repo, ["config", "worktree.useRelativePaths", "true"]); + yield* git(repo, ["worktree", "add", "--detach", cwd]); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "cancelled ü"); + const hookPath = path.join(repo, ".git/hooks/post-checkout"); + const connected = Promise.withResolvers(); + const sockets = new Set(); + const server = yield* Effect.acquireRelease( + Effect.sync(() => + NodeNet.createServer((socket) => { + sockets.add(socket); + connected.resolve(); + }), + ), + (server) => + Effect.sync(() => { + for (const socket of sockets) socket.destroy(); + server.close(); + }), + ); + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }), + ); + const address = server.address(); + assert.isTrue(address !== null && typeof address !== "string"); + if (address === null || typeof address === "string") return; + const gate = path.join(cwd, "hook-gate.cjs"); + yield* fs.writeFileString( + gate, + `const s = require('node:net').connect(${address.port}, '127.0.0.1'); s.on('connect', () => process.stderr.write('Updating files: 50% (1/1000)\\n')); s.resume();`, + ); + yield* writeTextFile( + repo, + ".git/hooks/post-checkout", + `#!/bin/sh\nexec "${process.execPath.replaceAll("\\", "/")}" "${gate.replaceAll("\\", "/")}"\n`, + ); + yield* fs.chmod(hookPath, 0o755); + const hookStarted = yield* Deferred.make(); + let claims = 0; + const input = { + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "t3/cancel", + }; + const worktrees = git(cwd, ["worktree", "list", "--porcelain"]); + + const cancelled = yield* driver + .createWorktree(input, { + progress: { + onCheckoutProgress: ({ total }) => + total === 1000 ? Deferred.succeed(hookStarted, undefined) : Effect.void, + onWorktreeClaimed: () => Effect.sync(() => void claims++), + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(hookStarted); + yield* Effect.promise(() => connected.promise); + assert.include(yield* worktrees, worktreePath); + yield* Fiber.interrupt(cancelled); + + assert.equal(claims, 0); + assert.notInclude(yield* worktrees, worktreePath); + assert.equal(yield* git(cwd, ["branch", "--list", "t3/cancel"]), ""); + assert.isFalse(yield* fs.exists(worktreePath)); + + // A retry gets the same path and branch, and a claimed worktree outlives a later cancel. + yield* fs.remove(hookPath); + const claimed = yield* Deferred.make(); + const retry = yield* driver + .createWorktree(input, { + progress: { onWorktreeClaimed: () => Deferred.succeed(claimed, undefined) }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(claimed); + yield* Fiber.interrupt(retry); + assert.include(yield* worktrees, worktreePath); + assert.notInclude(yield* worktrees, "locked"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "t3/cancel"); + }), + ); + it.effect("uses parallel checkout without skipping filters or hooks", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -5377,6 +5720,19 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { `${expected}\n`, ); } + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + '#!/bin/sh\ngit worktree unlock "$PWD" 2>/dev/null || :\ngit worktree lock --reason user-kept "$PWD"\n', + ); + const lockedPath = path.join(yield* makeTmpDir("git-worktrees-"), "hook-locked"); + yield* driver.createWorktree({ + cwd, + path: lockedPath, + refName: initialBranch, + newRefName: "feature/hook-locked", + }); + assert.include(yield* git(cwd, ["worktree", "list", "--porcelain"]), "locked user-kept"); }), ); @@ -6379,6 +6735,112 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + it.effect.each([ + { + configuration: "ordinary", + fetchRefspecs: ["+refs/heads/*:refs/remotes/origin/*"], + addsMapping: false, + }, + { + configuration: "restricted", + fetchRefspecs: [ + "+refs/heads/main:refs/remotes/origin/main", + "+refs/heads/team/*:refs/remotes/origin/team/*", + "+refs/heads/release/release-*:refs/remotes/origin/release/release-*", + ], + addsMapping: true, + }, + ])( + "sets a fetched branch's upstream with $configuration fetch configuration", + ({ fetchRefspecs, addsMapping }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["config", "--unset-all", "remote.origin.fetch"]); + for (const refspec of fetchRefspecs) { + yield* git(cwd, ["config", "--add", "remote.origin.fetch", refspec]); + } + const remoteBranch = "feature/example-change"; + const branch = "review/example-change"; + const mapping = `+refs/heads/${remoteBranch}*:refs/remotes/origin/${remoteBranch}*`; + yield* git(cwd, [ + "push", + remote, + "HEAD:refs/heads/main", + `HEAD:refs/heads/${remoteBranch}`, + ]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.fetchRemoteTrackingBranch({ cwd, remoteName: "origin", remoteBranch }); + yield* git(cwd, ["checkout", "--no-track", "-b", branch, `origin/${remoteBranch}`]); + + const input = { cwd, branch, remoteName: "origin", remoteBranch }; + yield* driver.setBranchUpstream(input); + yield* driver.setBranchUpstream(input); + + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`]), + `origin/${remoteBranch}`, + ); + assert.equal(yield* git(cwd, ["config", `branch.${branch}.remote`]), "origin"); + assert.equal( + yield* git(cwd, ["config", `branch.${branch}.merge`]), + `refs/heads/${remoteBranch}`, + ); + assert.deepEqual( + (yield* git(cwd, ["config", "--get-all", "remote.origin.fetch"])).split("\n"), + addsMapping ? [...fetchRefspecs, mapping] : fetchRefspecs, + ); + // A merged PR's head is deleted; later fetches of the remote must keep working. + yield* git(remote, ["--git-dir", remote, "branch", "-D", remoteBranch]); + yield* git(cwd, ["fetch", "origin"]); + }), + ); + + it.effect.each(["ordinary", "restricted"] as const)( + "retries upstream configuration with a config lock and %s fetch mappings", + (configuration) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["remote", "add", "origin", "https://example.invalid/repo.git"]); + if (configuration === "restricted") { + yield* git(cwd, [ + "config", + "remote.origin.fetch", + `+refs/heads/${initialBranch}:refs/remotes/origin/${initialBranch}`, + ]); + } + yield* git(cwd, ["branch", "review/pr-head"]); + yield* git(cwd, ["update-ref", "refs/remotes/origin/pr-head", "HEAD"]); + const lockPath = (yield* Path.Path).join(cwd, ".git", "config.lock"); + yield* (yield* FileSystem.FileSystem).writeFileString(lockPath, ""); + const { driver, failures } = yield* makeDriverReleasingLock( + configuration === "restricted" + ? (args) => args[0] === "config" && args[1] === "--add" + : (args) => args.includes("--set-upstream-to"), + lockPath, + ); + + yield* TestClock.withLive( + driver.setBranchUpstream({ + cwd, + branch: "review/pr-head", + remoteName: "origin", + remoteBranch: "pr-head", + }), + ); + + assert.equal(yield* Ref.get(failures), 1); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "review/pr-head@{upstream}"]), + "origin/pr-head", + ); + }), + ); + it.effect("ensureRemote reuses a partial clone remote and avoids its name", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -7066,41 +7528,108 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); it.effect.each([ - { operation: "cherry-pick", head: "CHERRY_PICK_HEAD" }, - { operation: "revert", head: "REVERT_HEAD" }, - ])("rejects selected-file commits during a $operation", ({ operation, head }) => + { operation: "cherry-pick", head: "CHERRY_PICK_HEAD", refs: "files" }, + { operation: "revert", head: "REVERT_HEAD", refs: "files" }, + // Reftable keeps CHERRY_PICK_HEAD and REVERT_HEAD as refs with no file under .git. + { operation: "cherry-pick", head: "CHERRY_PICK_HEAD", refs: "reftable" }, + { operation: "revert", head: "REVERT_HEAD", refs: "reftable" }, + ])( + "rejects selected-file commits during a $operation ($refs)", + ({ operation, head, refs }, ctx) => + Effect.gen(function* () { + if (refs === "reftable" && !gitHasReftable) + return ctx.skip("Needs Git 2.45 or later for reftable."); + const cwd = yield* makeTmpDir(); + if (refs === "reftable") yield* commitInitialRepo(cwd, ["--ref-format=reftable"]); + else yield* initRepoWithCommit(cwd); + // Reftable leaves .git/HEAD as a stub, so ask Git for the branch. + const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "other"]); + yield* writeTextFile(cwd, "README.md", "other version\n"); + yield* writeTextFile(cwd, "picked.txt", "picked\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Other"]); + if (operation === "cherry-pick") yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "README.md", "main version\n"); + yield* git(cwd, ["commit", "-am", "Main"]); + const originalHead = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["branch", head]); + const result = yield* driver.execute({ + operation: `test.${operation}`, + cwd, + args: [operation, operation === "cherry-pick" ? "other" : "HEAD~1"], + allowNonZeroExit: true, + }); + assert.equal(result.exitCode, 1); + yield* writeTextFile(cwd, "README.md", "resolved version\n"); + yield* git(cwd, ["add", "README.md"]); + const selected = operation === "cherry-pick" ? "picked.txt" : "README.md"; + + const commit = yield* driver + .commit(cwd, "Partial", "", { stage: { filePaths: [selected] } }) + .pipe(Effect.result); + + assert.isTrue(Result.isFailure(commit), `Selected-file ${operation} commit succeeded`); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), originalHead); + assert.equal( + yield* git(cwd, [ + "-c", + "core.warnAmbiguousRefs=false", + "rev-parse", + "--verify", + "--symbolic-full-name", + head, + ]), + head, + ); + }), + ); + + it.effect("rejects selected-file commits during a multi-commit no-commit cherry-pick", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; - yield* git(cwd, ["checkout", "-b", "other"]); - yield* writeTextFile(cwd, "README.md", "other version\n"); - yield* writeTextFile(cwd, "picked.txt", "picked\n"); + const path = yield* Path.Path; + yield* writeTextFile(cwd, "a.txt", "base a\n"); + yield* writeTextFile(cwd, "b.txt", "base b\n"); yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "Other"]); - if (operation === "cherry-pick") yield* git(cwd, ["checkout", initialBranch]); - yield* writeTextFile(cwd, "README.md", "main version\n"); + yield* git(cwd, ["commit", "-m", "Base"]); + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "a.txt", "feature a\n"); + yield* writeTextFile(cwd, "b.txt", "feature b\n"); + yield* git(cwd, ["commit", "-am", "Feature one"]); + yield* writeTextFile(cwd, "later.txt", "later\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "Feature two"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "a.txt", "main a\n"); + yield* writeTextFile(cwd, "b.txt", "main b\n"); yield* git(cwd, ["commit", "-am", "Main"]); - const originalHead = yield* git(cwd, ["rev-parse", "HEAD"]); - const result = yield* driver.execute({ - operation: `test.${operation}`, + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const started = yield* driver.execute({ + operation: "test.sequencer", cwd, - args: [operation, operation === "cherry-pick" ? "other" : "HEAD~1"], + args: ["cherry-pick", "--no-commit", "feature~1", "feature"], allowNonZeroExit: true, }); - assert.equal(result.exitCode, 1); - yield* writeTextFile(cwd, "README.md", "resolved version\n"); - yield* git(cwd, ["add", "README.md"]); + assert.equal(started.exitCode, 1); + yield* writeTextFile(cwd, "a.txt", "resolved a\n"); const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); - const selected = operation === "cherry-pick" ? "picked.txt" : "README.md"; - - const commit = yield* driver - .commit(cwd, "Partial", "", { stage: { filePaths: [selected] } }) - .pipe(Effect.result); + assert.isFalse(NodeFS.existsSync(path.join(gitDir, "CHERRY_PICK_HEAD"))); + const names = ["index", "sequencer/todo", "sequencer/head"]; + const before = names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))); - assert.isTrue(Result.isFailure(commit), `Selected-file ${operation} commit succeeded`); - assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), originalHead); - assert.isTrue(NodeFS.existsSync(`${gitDir}/${head}`)); + const error = yield* driver + .commit(cwd, "Partial", "", { stage: { filePaths: ["a.txt"] } }) + .pipe(Effect.flip); + assert.include(error.detail, "Cannot commit selected files during a cherry-pick or revert"); + assert.deepEqual( + names.map((name) => NodeFS.readFileSync(path.join(gitDir, name))), + before, + ); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), head); }), ); @@ -7590,6 +8119,92 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("republishes a branch whose upstream was deleted", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ + cwd, + refName: "feature/push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "feature/push", + }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + + yield* git(cwd, ["push", "origin", "--delete", "feature/push"]); + yield* writeTextFile(cwd, "followup.txt", "followup\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add followup", ""); + const republished = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(republished, { status: "pushed", branch: "feature/push" }); + assert.equal( + yield* git(remote, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + + const fork = yield* makeTmpDir("git-fork-"); + yield* git(fork, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "fork", fork]); + yield* git(cwd, ["remote", "add", "feature", fork]); + yield* git(cwd, ["push", "-u", "fork", "feature/push"]); + yield* git(cwd, ["push", "fork", "--delete", "feature/push"]); + yield* writeTextFile(cwd, "fork.txt", "fork\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add fork change", ""); + assert.deepInclude( + yield* (yield* GitVcsDriver.GitVcsDriver).resolvePushTarget(cwd, { + branch: "feature/push", + hasUpstream: false, + }), + { remoteName: "fork", branchName: "feature/push" }, + ); + const forkRepublished = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch( + cwd, + null, + ); + assert.deepInclude(forkRepublished, { upstreamBranch: "fork/feature/push" }); + assert.equal( + yield* git(fork, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + + yield* git(cwd, ["config", "branch.feature/push.remote", "."]); + yield* git(cwd, ["config", "branch.feature/push.merge", "refs/heads/push"]); + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add local change", ""); + const localUpstreamGone = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch( + cwd, + null, + ); + assert.deepInclude(localUpstreamGone, { upstreamBranch: "origin/feature/push" }); + + yield* git(cwd, ["push", "fork", "HEAD:refs/heads/release"]); + yield* git(cwd, ["branch", "-u", "fork/release"]); + yield* git(cwd, ["push", "fork", "--delete", "release"]); + yield* writeTextFile(cwd, "release.txt", "release\n"); + yield* git(cwd, ["add", "-A"]); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add release change", ""); + yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.equal( + yield* (yield* GitVcsDriver.GitVcsDriver).readConfigValue( + cwd, + "branch.feature/push.t3-merge-base", + ), + "release", + ); + }), + ); + it.effect("rebases a pushed branch's pulls when branch.autoSetupRebase asks for it", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -7693,6 +8308,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(originRemote, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), publishedHead, ); + + const pushOnlyRemote = yield* makeTmpDir("git-push-only-remote-"); + yield* git(pushOnlyRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "set-url", "--push", "origin", pushOnlyRemote]); + const pushedToPushUrl = yield* driver.pushCurrentBranch(cwd, null); + assert.deepInclude(pushedToPushUrl, { status: "pushed", branch }); + assert.equal( + yield* git(pushOnlyRemote, ["--git-dir=.", "rev-parse", "refs/heads/feature/push"]), + head, + ); }), ); @@ -7736,14 +8361,15 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { ); it.effect.each([ - { baseBranch: "dev", local: false }, - { baseBranch: "dev", local: true }, - { baseBranch: "feature/base", local: true }, - { baseBranch: "upstream", local: true }, - { baseBranch: "upstream/feature/x", local: true }, + { baseBranch: "dev", local: false, branch: "feature/x", deleted: false }, + { baseBranch: "dev", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "feature/base", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "upstream", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "upstream/feature/x", local: true, branch: "feature/x", deleted: false }, + { baseBranch: "dev", local: false, branch: "origin/dev", deleted: true }, ])( - "publishes a branch tracking $baseBranch (local=$local) under its own name, not the base", - ({ baseBranch, local }) => + "publishes $branch tracking $baseBranch (local=$local, deleted=$deleted) without updating the base", + ({ baseBranch, local, branch, deleted }) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const remote = yield* makeTmpDir("git-remote-"); @@ -7768,51 +8394,68 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { "checkout", "--track", "-b", - "feature/x", + branch, local ? `refs/heads/${baseBranch}` : `refs/remotes/upstream/${baseBranch}`, ]); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/x.remote"), + yield* driver.readConfigValue(cwd, `branch.${branch}.remote`), local ? "." : "upstream", ); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* git(cwd, ["add", "-A"]); yield* driver.commit(cwd, "Add feature", ""); + if (deleted) { + yield* git(cwd, ["push", "upstream", "--delete", baseBranch]); + } + const publishBranch = branch === "origin/dev" ? "dev" : branch; + assert.deepInclude( + yield* driver.resolvePushTarget(cwd, { branch, hasUpstream: !deleted }), + { remoteName: "origin", branchName: publishBranch }, + ); const pushed = yield* driver.pushCurrentBranch(cwd, null); assert.deepInclude(pushed, { status: "pushed", - branch: "feature/x", - upstreamBranch: "origin/feature/x", + branch, + upstreamBranch: `origin/${publishBranch}`, setUpstream: true, }); assert.equal( - yield* git(remote, ["--git-dir=.", "log", "-1", "--pretty=%s", "feature/x"]), + yield* git(remote, ["--git-dir=.", "log", "-1", "--pretty=%s", publishBranch]), "Add feature", ); + if (publishBranch !== baseBranch) { + assert.equal( + yield* git(remote, ["--git-dir=.", "rev-parse", `refs/heads/${baseBranch}`]), + devSha, + ); + } assert.equal( - yield* git(remote, ["--git-dir=.", "rev-parse", `refs/heads/${baseBranch}`]), - devSha, - ); - assert.equal( - yield* git(upstream, ["--git-dir=.", "rev-parse", `refs/heads/${baseBranch}`]), - upstreamDevSha, + yield* git(upstream, [ + "--git-dir=.", + "for-each-ref", + "--format=%(objectname)", + `refs/heads/${baseBranch}`, + ]), + deleted ? "" : upstreamDevSha, ); assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), - "origin/feature/x", + yield* git(cwd, ["rev-parse", "--symbolic-full-name", "@{upstream}"]), + `refs/remotes/origin/${publishBranch}`, ); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base"), + yield* driver.readConfigValue(cwd, `branch.${branch}.t3-merge-base`), baseBranch, ); assert.equal( - yield* driver.readConfigValue(cwd, "branch.feature/x.t3-merge-base-ref"), + yield* driver.readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`), local ? `refs/heads/${baseBranch}` : `refs/remotes/upstream/${baseBranch}`, ); assert.equal(yield* git(cwd, ["rev-parse", `refs/heads/${baseBranch}`]), upstreamDevSha); + if (deleted) return; + const preview = yield* driver.getReviewDiffPreview({ cwd }); const changes = preview.sources.find((source) => source.kind === "branch-range")!; assert.deepStrictEqual(changes.files, [ @@ -7961,6 +8604,32 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(remote, ["--git-dir=.", "log", "-1", "--pretty=%s", "effect-atom"]), "Add alias update", ); + + yield* git(cwd, ["push", "my-org/upstream", "--delete", "effect-atom"]); + assert.deepInclude( + yield* driver.resolvePushTarget(cwd, { + branch: "upstream/effect-atom", + hasUpstream: false, + }), + { remoteName: "my-org/upstream", branchName: "effect-atom" }, + ); + assert.deepInclude(yield* driver.pushCurrentBranch(cwd, null), { + upstreamBranch: "my-org/upstream/effect-atom", + setUpstream: true, + }); + assert.equal( + yield* git(remote, ["--git-dir=.", "rev-parse", "refs/heads/effect-atom"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + assert.equal( + yield* git(remote, [ + "--git-dir=.", + "for-each-ref", + "--format=%(refname)", + "refs/heads/upstream/", + ]), + "", + ); }), ); @@ -7999,6 +8668,196 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.notEqual(originMain.exitCode, 0); }), ); + + it.effect.each(["branch.topic.pushRemote", "remote.pushDefault"] as const)( + "pushes a branch tracking its upstream head to the %s remote", + (configKey) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["checkout", "-b", "topic"]); + yield* git(cwd, ["push", "upstream", "topic"]); + yield* git(cwd, ["push", "fork", "topic"]); + yield* git(cwd, ["branch", "--set-upstream-to=upstream/topic"]); + yield* git(cwd, ["config", configKey, "fork"]); + const upstreamTopicSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* writeTextFile(cwd, "fork.txt", "fork\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add fork update", ""); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "topic", + upstreamBranch: "fork/topic", + pushRemote: "fork", + setUpstream: false, + }); + assert.equal( + yield* git(forkRemote, ["--git-dir=.", "log", "-1", "--pretty=%s", "topic"]), + "Add fork update", + ); + assert.equal( + yield* git(upstreamRemote, ["--git-dir=.", "rev-parse", "topic"]), + upstreamTopicSha, + ); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "upstream/topic", + ); + }), + ); + + it.effect.each(["deleted", "rewound"] as const)( + "pushes an up-to-date tracked branch to the push remote when its branch there is %s", + (forkState) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["checkout", "-b", "topic"]); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add upstream update", ""); + yield* git(cwd, ["push", "-u", "upstream", "topic"]); + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + // Another checkout changes the fork after this one last fetched it, + // leaving a local fork/topic that still contains HEAD. + yield* git(cwd, ["push", "fork", "topic"]); + yield* git( + forkRemote, + forkState === "deleted" + ? ["--git-dir=.", "update-ref", "-d", "refs/heads/topic"] + : ["--git-dir=.", "update-ref", "refs/heads/topic", "topic~1"], + ); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "pushed", + upstreamBranch: "fork/topic", + pushRemote: "fork", + }); + assert.equal( + yield* git(forkRemote, ["--git-dir=.", "log", "-1", "--pretty=%s", "topic"]), + "Add upstream update", + ); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "upstream/topic", + ); + }), + ); + + it.effect.each([ + { pushUrls: [], status: "skipped_up_to_date" }, + { pushUrls: ["mirror"], status: "pushed" }, + { pushUrls: ["fork", "mirror"], status: "pushed" }, + ] as const)( + "skips an up-to-date tracked branch only when every push URL has it (pushurl $pushUrls)", + ({ pushUrls, status }) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const remotes = { + fork: yield* makeTmpDir("git-fork-remote-"), + mirror: yield* makeTmpDir("git-mirror-remote-"), + }; + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(remotes.fork, ["init", "--bare"]); + yield* git(remotes.mirror, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", remotes.fork]); + yield* git(cwd, ["checkout", "-b", "topic"]); + yield* git(cwd, ["push", "-u", "upstream", "topic"]); + yield* git(cwd, ["push", "fork", "topic"]); + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + for (const name of pushUrls) { + yield* git(cwd, ["remote", "set-url", "--add", "--push", "fork", remotes[name]]); + } + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { status, upstreamBranch: "fork/topic", pushRemote: "fork" }); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + for (const name of pushUrls) { + assert.equal( + yield* git(remotes[name], ["--git-dir=.", "rev-parse", "refs/heads/topic"]), + head, + ); + } + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "upstream/topic", + ); + }), + ); + + it.effect("pushes an up-to-date branch whose tracked remote has a separate push URL", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fetchRemote = yield* makeTmpDir("git-fetch-remote-"); + const pushRemote = yield* makeTmpDir("git-push-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(fetchRemote, ["init", "--bare"]); + yield* git(pushRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", fetchRemote]); + yield* git(cwd, ["checkout", "-b", "topic"]); + yield* git(cwd, ["push", "-u", "origin", "topic"]); + yield* git(cwd, ["remote", "set-url", "--push", "origin", pushRemote]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { status: "pushed", upstreamBranch: "origin/topic" }); + assert.equal( + yield* git(pushRemote, ["--git-dir=.", "rev-parse", "refs/heads/topic"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + }), + ); + + it.effect("skips an up-to-date branch whose differently named upstream is its base", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const upstreamRemote = yield* makeTmpDir("git-upstream-remote-"); + const forkRemote = yield* makeTmpDir("git-fork-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["push", "upstream", "HEAD:refs/heads/release"]); + yield* git(cwd, ["fetch", "upstream"]); + yield* git(cwd, ["checkout", "-b", "topic", "--track", "upstream/release"]); + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null); + + assert.deepInclude(pushed, { + status: "skipped_up_to_date", + upstreamBranch: "upstream/release", + }); + assert.equal(yield* git(forkRemote, ["--git-dir=.", "branch", "--list", "topic"]), ""); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fab7037f250a6..485092506488f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,7 +1,9 @@ import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -70,6 +72,10 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` * CLI's key, which branches cut before then still carry instead. */ const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; +// Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. +const isAliasOfUpstreamHead = (branch: string, upstreamBranch: string, upstreamRef: string) => + branch === upstreamBranch || + (branch.endsWith(`/${upstreamBranch}`) && upstreamRef.endsWith(`/${branch}`)); const gitProcesses = Semaphore.makeUnsafe(8); const backgroundGitProcesses = Semaphore.makeUnsafe(2); // The fetch before `git worktree add` downloads everything the remote gained since @@ -94,6 +100,8 @@ const REVIEW_UNTRACKED_MAX_BYTES = 256 * 1024 * 1024; const UNTRACKED_TOTALS_RETRY_MIN_MS = 60_000; const UNTRACKED_TOTALS_RETRY_MAX_MS = 30 * 60_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +// Git's ident clean step (convert.c ident_to_git): `$Id:` up to the next `$` on the same line. +const GIT_IDENT_KEYWORD = /\$Id:[^$\n]*\$/g; const REVIEW_BRANCH_COMMIT_LIMIT = 100; const REVIEW_BRANCH_SUBJECT_LIMIT = 300; const REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES = 128 * 1024; @@ -197,6 +205,7 @@ interface GitRepositoryPaths { } interface GitRefsSnapshot { + readonly defaultBranch: string | null; readonly localBranches: ReadonlyArray; readonly remoteBranches: ReadonlyArray; readonly hasPrimaryRemote: boolean; @@ -336,6 +345,14 @@ function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { return parts.filter((value) => value.length > 0); } +// Parses "git version 2.24.3 (Apple Git-128)" or "git version 2.45.1.windows.1". +const gitSupportsPathspecFromFile = (versionOutput: string): boolean => { + const match = /^git version (\d+)\.(\d+)/.exec(versionOutput); + if (match === null) return false; + const major = Number(match[1]); + return major > 2 || (major === 2 && Number(match[2]) >= 25); +}; + export function splitNullSeparatedGitStdoutPaths( result: Pick, ): string[] { @@ -975,6 +992,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const { worktreesDir, worktreeCheckoutCacheDir } = yield* ServerConfig.ServerConfig; const isServerInternalPath = yield* makeIsServerInternalPath; const digest = yield* Digest.Digest; + const random = yield* SecureRandom.SecureRandom; const hostPlatform = yield* HostProcess.Platform; const serviceScope = yield* Effect.scope; const statusFetches = new Map>(); @@ -1822,7 +1840,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); - const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + const resolveConfiguredPushRemoteName = Effect.fn("resolveConfiguredPushRemoteName")(function* ( cwd: string, refName: string, ) { @@ -1842,13 +1860,65 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--get", "remote.pushDefault"], true, ).pipe(Effect.map((stdout) => stdout.trim())); - if (pushDefaultRemote.length > 0) { - return pushDefaultRemote; + return pushDefaultRemote.length > 0 ? pushDefaultRemote : null; + }); + + const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + cwd: string, + refName: string, + fallbackRemoteName: string | null = null, + ) { + const configuredRemote = yield* resolveConfiguredPushRemoteName(cwd, refName); + if (configuredRemote) { + return configuredRemote; + } + + if (fallbackRemoteName) { + return fallbackRemoteName; } return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); + const remoteRefContainsHead = (cwd: string, remoteRef: string) => + executeGit( + "GitVcsDriver.pushCurrentBranch.publishContainsHead", + cwd, + ["merge-base", "--is-ancestor", "HEAD", `refs/remotes/${remoteRef}`], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => result.exitCode === 0), + Effect.orElseSucceed(() => false), + ); + + // A fetch reads only the remote's first url, while a push writes every push URL (pushurl, else + // each url). A fetched ref is evidence of the push destination only when those are the same one. + const pushesOnlyToFetchUrl = Effect.fn("GitVcsDriver.pushesOnlyToFetchUrl")( + function* (cwd: string, remoteName: string) { + const [fetchUrl, pushUrls] = yield* Effect.all([ + runGitStdout("GitVcsDriver.pushesOnlyToFetchUrl.fetchUrl", cwd, [ + "remote", + "get-url", + "--", + remoteName, + ]), + runGitStdout("GitVcsDriver.pushesOnlyToFetchUrl.pushUrls", cwd, [ + "remote", + "get-url", + "--push", + "--all", + "--", + remoteName, + ]), + ]); + // Preserve whitespace in URLs and reject ambiguous multiline fetch URLs. + return ( + fetchUrl.endsWith("\n") && !fetchUrl.slice(0, -1).includes("\n") && pushUrls === fetchUrl + ); + }, + Effect.orElseSucceed(() => false), + ); + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( "ensureRemote", )(function* (input) { @@ -2231,41 +2301,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), }), ); + // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. + const readNumstat = (ref: string) => + executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetails.numstat", + cwd, + ["diff-index", "-M", "--numstat", "-z", ref, "--"], + { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); // diff-index does not read status output, so both spawn together; a non-repository discards it. const [statusResult, numstatResult, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ readStatus, - // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. - executeGitWithStableDiagnostics( - "GitVcsDriver.statusDetails.numstat", - cwd, - ["diff-index", "-M", "--numstat", "-z", "HEAD", "--"], - { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ).pipe( + readNumstat("HEAD").pipe( + Effect.filterOrElse( + (result) => result.exitCode === 0 || !isUnbornHeadStderr(result.stderr), + () => Effect.flatMap(readEmptyTreeHash(cwd), readNumstat), + ), Effect.flatMap((result) => { if (result.exitCode === 0) return Effect.succeed(parseReviewNumstat(result.stdout)); - if (isUnbornHeadStderr(result.stderr)) { - return Effect.map( - Effect.all([ - runGitStdoutWithOptions( - "GitVcsDriver.statusDetails.numstat.unborn", - cwd, - ["diff-files", "--numstat", "-z"], - { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ), - runGitStdoutWithOptions( - "GitVcsDriver.statusDetails.numstat.unborn.staged", - cwd, - ["diff", "--cached", "--numstat", "-z"], - { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, - ), - ]), - ([unstagedStdout, stagedStdout]) => [ - ...parseReviewNumstat(stagedStdout), - ...parseReviewNumstat(unstagedStdout), - ], - ); - } return Effect.fail( new GitCommandError({ ...gitCommandContext({ @@ -2322,6 +2376,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* let refName: string | null = null; let upstreamRef: string | null = null; + // Git omits branch.ab when the upstream ref is gone, e.g. deleted after its PR merged, + // and also for an unborn HEAD, which can still track a fetched upstream. + let upstreamRefExists = false; + let isUnbornHead = false; let aheadCount = 0; let behindCount = 0; let aheadOfDefaultCount = 0; @@ -2332,6 +2390,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusRecords = statusStdout.split("\0"); for (let index = 0; index < statusRecords.length; index++) { const line = statusRecords[index]!; + if (line === "# branch.oid (initial)") { + isUnbornHead = true; + continue; + } if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); refName = value === "(detached)" ? null : value; @@ -2342,11 +2404,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* upstreamRef = value.length > 0 ? value : null; continue; } - if (includeDivergence && line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; + if (line.startsWith("# branch.ab ")) { + upstreamRefExists = true; + if (includeDivergence) { + const parsed = parseBranchAb(line.slice("# branch.ab ".length).trim()); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + } continue; } if (line.length > 0 && !line.startsWith("#")) { @@ -2365,6 +2429,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } + if (!upstreamRefExists && !isUnbornHead) upstreamRef = null; + const isDefaultBranch = refName !== null && (refName === defaultBranch || @@ -2489,6 +2555,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* })), ); + // git add takes --pathspec-from-file from Git 2.25; older Git keeps the argument form. + let pathspecFromFileSupported: boolean | undefined; + const supportsPathspecFromFile = Effect.fn("GitVcsDriver.supportsPathspecFromFile")(function* ( + cwd: string, + ) { + if (pathspecFromFileSupported !== undefined) return pathspecFromFileSupported; + const version = yield* executeGit("GitVcsDriver.version", cwd, ["version"]).pipe(Effect.option); + if (Option.isNone(version)) return false; + pathspecFromFileSupported = gitSupportsPathspecFromFile(version.value.stdout); + return pathspecFromFileSupported; + }); + const stageCommitChanges = Effect.fnUntraced(function* ( cwd: string, filePaths?: readonly string[], @@ -2517,14 +2595,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { head: "MERGE_HEAD", operation: "merge" }, { head: "CHERRY_PICK_HEAD", operation: "cherry-pick" }, { head: "REVERT_HEAD", operation: "revert" }, + // A conflicted multi-commit `cherry-pick --no-commit` writes only the sequencer; reset + // would clear its unmerged entries, so `--continue` would walk past the conflict. + { head: "sequencer", operation: "cherry-pick or revert" }, ] as const; const headPaths = yield* runGitStdout("GitVcsDriver.commit.mergeHead", cwd, [ "rev-parse", ...inProgressHeads.flatMap(({ head }) => ["--git-path", head]), ]); const resolvedHeadPaths = headPaths.trim().split("\n"); - const inProgress = yield* Effect.findFirst(inProgressHeads, (_, index) => - fileSystem.exists(path.resolve(cwd, resolvedHeadPaths[index] ?? "")), + // Reftable keeps CHERRY_PICK_HEAD and REVERT_HEAD as refs with no file under .git. A + // same-named branch resolves to its full name; an ambiguity warning would blank stdout. + const operationRefs = new Set(["CHERRY_PICK_HEAD", "REVERT_HEAD"]); + const isOperationRef = (head: string) => + runGitStdout( + "GitVcsDriver.commit.operationRef", + cwd, + [ + "-c", + "core.warnAmbiguousRefs=false", + "rev-parse", + "--verify", + "--quiet", + "--symbolic-full-name", + head, + ], + true, + ).pipe(Effect.map((ref) => ref.trim() === head)); + const inProgress = yield* Effect.findFirst(inProgressHeads, ({ head }, index) => + fileSystem.exists(path.resolve(cwd, resolvedHeadPaths[index] ?? "")).pipe( + Effect.filterOrElse( + (exists) => exists || !operationRefs.has(head), + () => isOperationRef(head), + ), + ), ).pipe( Effect.mapError( (cause) => @@ -2549,14 +2653,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); } + // Status paths are repository-relative, while cwd can be a subdirectory. + const pathspecs = filePaths.map((file) => `:(top,literal)${file}`); + const fromStdin = yield* supportsPathspecFromFile(cwd); yield* runGit( "GitVcsDriver.commit.addSelected", cwd, - [...config, "add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], + fromStdin + ? [...config, "add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"] + : [...config, "add", "-A", "--", ...pathspecs], { ...(env ? { env } : {}), - // Status paths are repository-relative, while cwd can be a subdirectory. - stdin: filePaths.map((file) => `:(top,literal)${file}\0`).join(""), + ...(fromStdin ? { stdin: pathspecs.map((pathspec) => `${pathspec}\0`).join("") } : {}), }, ); } else { @@ -2846,10 +2954,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* hasUpstream: boolean, ) { if (!hasUpstream) { - const remoteName = yield* resolvePushRemoteName(cwd, branch); + // A deleted upstream keeps its config: republish to it when it was this branch's own, + // otherwise it was the base, so keep that before publishing. + let goneUpstream: { remoteName: string; branchName: string } | null = null; + let goneBaseUpstream: { remoteName: string; branchName: string; upstreamRef: string } | null = + null; + const mergeRef = yield* readConfigValue(cwd, `branch.${branch}.merge`); + const mergeBranch = mergeRef?.startsWith("refs/heads/") + ? mergeRef.slice("refs/heads/".length) + : null; + const configuredRemoteName = yield* readConfigValue(cwd, `branch.${branch}.remote`); + if (mergeBranch && configuredRemoteName && configuredRemoteName !== ".") { + if (isAliasOfUpstreamHead(branch, mergeBranch, `${configuredRemoteName}/${mergeBranch}`)) { + goneUpstream = { remoteName: configuredRemoteName, branchName: mergeBranch }; + } else { + goneBaseUpstream = { + remoteName: configuredRemoteName, + branchName: mergeBranch, + upstreamRef: `${configuredRemoteName}/${mergeBranch}`, + }; + } + } + const remoteName = yield* resolvePushRemoteName(cwd, branch, goneUpstream?.remoteName); if (!remoteName) return null; - const branchName = yield* resolvePublishBranchName(cwd, branch); - return { remoteName, branchName, publishes: true, upstream: null }; + // A gone local upstream still gets the full local name, as below. + const branchName = + goneUpstream?.branchName ?? + (configuredRemoteName === "." ? branch : yield* resolvePublishBranchName(cwd, branch)); + return { + remoteName, + branchName, + publishes: true, + upstream: goneBaseUpstream, + pushRemote: null, + }; } if ((yield* readConfigValue(cwd, `branch.${branch}.remote`)) === ".") { @@ -2867,21 +3005,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* branchName: branch, publishes: true, upstream: { remoteName: ".", branchName: localUpstream, upstreamRef: localUpstream }, + pushRemote: null, }; } const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); if (!upstream) return null; - // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. - const isAliasOfUpstreamHead = - branch === upstream.branchName || - (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)); - if (isAliasOfUpstreamHead) { + if (isAliasOfUpstreamHead(branch, upstream.branchName, upstream.upstreamRef)) { + // A fork workflow pushes to the configured push remote and keeps the upstream for pulls, as plain `git push` does. + const configuredPushRemote = yield* resolveConfiguredPushRemoteName(cwd, branch).pipe( + Effect.orElseSucceed(() => null), + ); + const pushRemote = + configuredPushRemote && configuredPushRemote !== upstream.remoteName + ? configuredPushRemote + : null; return { - remoteName: upstream.remoteName, + remoteName: pushRemote ?? upstream.remoteName, branchName: upstream.branchName, publishes: false, upstream, + pushRemote, }; } const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( @@ -2893,6 +3037,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* branchName, publishes: true, upstream, + pushRemote: null, }; }); @@ -2901,7 +3046,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* )(function* (cwd, current) { const destination = yield* resolvePushDestination(cwd, current.branch, current.hasUpstream); return destination - ? { remoteName: destination.remoteName, branchName: destination.branchName } + ? { + remoteName: destination.remoteName, + branchName: destination.branchName, + viaPushRemote: destination.pushRemote !== null, + } : null; }); @@ -2939,16 +3088,41 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta && details.hasUpstream) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } - const destination = yield* resolvePushDestination(cwd, branch, details.hasUpstream); - if (hasNoLocalDelta) { + if (hasNoLocalDelta && details.hasUpstream) { + if (!destination?.pushRemote) { + // The tracking ref reflects the fetch URL, so it says nothing about a separate push URL. + if (!destination || (yield* pushesOnlyToFetchUrl(cwd, destination.remoteName))) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + } else { + const { pushRemote, branchName } = destination; + const pushRemoteRef = `${pushRemote}/${branchName}`; + // The local ref can be stale when the branch was deleted or rewound elsewhere. + const pushRemoteHasHead = + (yield* pushesOnlyToFetchUrl(cwd, pushRemote)) && + (yield* fetchRemoteTrackingBranch({ + cwd, + remoteName: pushRemote, + remoteBranch: branchName, + }).pipe( + Effect.andThen(remoteRefContainsHead(cwd, pushRemoteRef)), + Effect.orElseSucceed(() => false), + )); + if (pushRemoteHasHead) { + return { + status: "skipped_up_to_date" as const, + branch, + upstreamBranch: pushRemoteRef, + pushRemote, + }; + } + } + } else if (hasNoLocalDelta) { const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( Effect.orElseSucceed(() => null), ); @@ -2961,21 +3135,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } // The configured diff base can contain HEAD while the publish branch is still behind. - const publishContainsHead = yield* executeGit( - "GitVcsDriver.pushCurrentBranch.publishContainsHead", - cwd, - [ - "merge-base", - "--is-ancestor", - "HEAD", - `refs/remotes/${destination.remoteName}/${destination.branchName}`, - ], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => result.exitCode === 0), - Effect.orElseSucceed(() => false), - ); - if (publishContainsHead) { + if ( + (yield* remoteRefContainsHead( + cwd, + `${destination.remoteName}/${destination.branchName}`, + )) && + (yield* pushesOnlyToFetchUrl(cwd, destination.remoteName)) + ) { return { status: "skipped_up_to_date" as const, branch, @@ -3044,7 +3210,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { status: "pushed" as const, branch, - upstreamBranch: destination.upstream.upstreamRef, + ...(destination.pushRemote + ? { + upstreamBranch: `${destination.remoteName}/${destination.branchName}`, + pushRemote: destination.pushRemote, + } + : { upstreamBranch: destination.upstream.upstreamRef }), setUpstream: false, }; } @@ -3283,7 +3454,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean)); // Staged deletions never enter the index, so their worktree copies must not spend the budget. - const candidates = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)); + // A nested repository (listed with a trailing slash) with no commits cannot be indexed: + // Git before 2.53 fails the add, and later versions accept it and then fail every diff. + const candidates = yield* Effect.filter( + untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath)), + (relativePath) => + relativePath.endsWith("/") + ? executeGit( + "GitVcsDriver.readUnifiedWorkingTreeReviewDiff.nestedRepositoryHead", + path.resolve(cwd, relativePath), + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode !== 1)) + : Effect.succeed(true), + { concurrency: 16 }, + ); const sizes = yield* Effect.forEach( candidates, (relativePath) => @@ -4036,7 +4221,43 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (bytes.includes(0)) { return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`); } - return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); + const contents = new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes); + if (!contents.includes("$Id:")) return contents; + const collapsed = contents.replace(GIT_IDENT_KEYWORD, () => "$Id$"); + if (collapsed === contents) return contents; + // check-attr reports both boolean ident and literal ident=set as "set". + const attribute = yield* runGitStdout( + "GitVcsDriver.getReviewDiffFileContents.workingTree.ident", + repositoryRoot, + ["check-attr", "-z", "ident", "filter", "--", input.newPath], + ); + const attributes = attribute.split("\0"); + if (attributes[2] !== "set" || attributes[5] !== "unspecified") return contents; + + // Literal filter=unspecified is indistinguishable from an absent filter in check-attr. + const filters = yield* executeGit( + "GitVcsDriver.getReviewDiffFileContents.workingTree.filters", + repositoryRoot, + ["config", "--name-only", "--get-regexp", "^filter\\.unspecified\\."], + { allowNonZeroExit: true }, + ); + if (filters.exitCode !== 1 || filters.stdout.length > 0) return contents; + // Probe a nonoverlapping keyword so ident conversion runs once on the actual file. + const [canonicalHash, collapsedHash] = yield* Effect.all([ + runGitStdoutWithOptions( + "GitVcsDriver.getReviewDiffFileContents.workingTree.canonicalHash", + repositoryRoot, + ["hash-object", "--stdin", `--path=${input.newPath}`], + { stdin: "$Id: probe $" }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.getReviewDiffFileContents.workingTree.collapsedHash", + repositoryRoot, + ["hash-object", "--stdin", `--path=${input.newPath}`], + { stdin: "$Id$" }, + ), + ]); + return canonicalHash === collapsedHash ? collapsed : contents; }); const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* ( @@ -4344,6 +4565,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : left.ref.name.localeCompare(right.ref.name); return { + defaultBranch, localBranches: localBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), remoteBranches: remoteBranches.toSorted(byRecencyThenName).map(({ ref }) => ref), hasPrimaryRemote: remoteNames.length > 0, @@ -4484,7 +4706,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { concurrency: 2 }, ); const { worktreeRoot } = repositoryPaths; - const localBranches = snapshot.localBranches.map((ref) => { + // The snapshot misses a branch created outside T3 Code until it refreshes. An unborn HEAD + // names a branch with no ref yet, so the ref must exist before it is listed. + const currentBranchUnlisted = + currentBranch !== null && !snapshot.localBranches.some((ref) => ref.name === currentBranch); + const unlistedCurrentBranch: ReadonlyArray = + currentBranchUnlisted && + (yield* branchExists(input.cwd, currentBranch).pipe(Effect.orElseSucceed(() => false))) + ? [ + { + name: currentBranch, + current: true, + isRemote: false, + isDefault: currentBranch === snapshot.defaultBranch, + worktreePath: worktreeRoot, + }, + ] + : []; + const listedBranches = snapshot.localBranches.map((ref) => { const current = ref.name === currentBranch; return { ...ref, @@ -4499,6 +4738,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* current, }; }); + const localBranches = [...unlistedCurrentBranch, ...listedBranches]; const combinedBranches = input.includeMatchingRemoteRefs ? [...localBranches, ...snapshot.remoteBranches] : dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]); @@ -4578,6 +4818,40 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* refresh; }); + // Git writes the lock reason during registration, before checkout or user hooks can run. + const releaseWorktreeRegistration = Effect.fn("releaseWorktreeRegistration")(function* ( + cwd: string, + reason: string, + remove: boolean, + ) { + const commonDir = yield* readGitCommonDir(cwd); + if (commonDir === null) return; + const registrations = path.join(commonDir, "worktrees"); + const entries = yield* fileSystem.readDirectory(registrations).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed([]), + ), + ); + for (const entry of entries) { + const adminDir = path.join(registrations, entry); + const locked = yield* fileSystem + .readFileString(path.join(adminDir, "locked")) + .pipe(Effect.orElseSucceed(() => "")); + if (locked.replace(/\r?\n$/, "") !== reason) continue; + // gitdir can be relative to the registration with worktree.useRelativePaths enabled. + const gitdir = yield* fileSystem.readFileString(path.join(adminDir, "gitdir")); + const registered = path.dirname(path.resolve(adminDir, gitdir.replace(/\r?\n$/, ""))); + yield* runGit("GitVcsDriver.createWorktree.unlock", cwd, [ + "worktree", + "unlock", + "--", + registered, + ]); + if (remove) yield* removeWorktree({ cwd, path: registered, force: true }); + } + }); + const createWorktree = Effect.fn("createWorktree")(function* ( input: VcsCreateWorktreeInput, options: GitVcsDriver.CreateWorktreeOptions | undefined, @@ -4660,6 +4934,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* releaseTemplateName = placed.release; } } + // Older Git still creates worktrees normally, but cannot prove ownership for cancellation. + const addHelp = newRefName + ? yield* executeGit( + "GitVcsDriver.createWorktree.capabilities", + input.cwd, + ["worktree", "add", "-h"], + { allowNonZeroExit: true }, + ) + : null; + const lockReason = + addHelp && `${addHelp.stdout}\n${addHelp.stderr}`.includes("--reason") + ? `t3-create-${yield* random.uuidV4()}` + : undefined; const checkoutRef = newRefName ?? input.refName; const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; @@ -4693,6 +4980,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `checkout.workers=${checkoutWorkers}`, "worktree", "add", + ...(lockReason ? ["--lock", "--reason", lockReason] : []), "--", worktreePath, checkoutRef, @@ -4720,34 +5008,71 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : {}), }, ); - const checkout = ( - fastCheckout - ? checkoutCache - .create({ cwd: input.cwd, worktreePath, refName: checkoutRef, checkoutWorkers }) - .pipe(Effect.flatMap((cloned) => (cloned ? Effect.void : stockCheckout))) - : stockCheckout - ).pipe( - Effect.onError(() => - newRefName - ? deleteLocalBranch({ cwd: input.cwd, refName: newRefName, force: true }).pipe( + const checkout = fastCheckout + ? checkoutCache + .create({ + cwd: input.cwd, + worktreePath, + refName: checkoutRef, + checkoutWorkers, + lockReason, + }) + .pipe(Effect.flatMap((cloned) => (cloned ? Effect.void : stockCheckout))) + : stockCheckout; + // Set inside the claim's uninterruptible region: a pending cancel still surfaces as it ends. + let claimed = false; + const onWorktreeClaimed = progress?.onWorktreeClaimed; + const checkoutAndClaim = checkout.pipe( + Effect.andThen( + fastCheckout ? checkoutCache.schedule(worktreePath, cacheGeneration) : Effect.void, + ), + Effect.andThen( + onWorktreeClaimed + ? Effect.uninterruptible( + onWorktreeClaimed(worktreePath).pipe( + Effect.andThen( + Effect.sync(() => { + claimed = true; + }), + ), + ), + ) + : Effect.void, + ), + Effect.onError((cause) => + newRefName && !claimed + ? // A failed hook keeps its worktree, as Git does; only a cancel abandons it. + (Cause.hasInterrupts(cause) + ? lockReason + ? releaseWorktreeRegistration(input.cwd, lockReason, true) + : Effect.void + : Effect.void + ).pipe( + Effect.andThen( + deleteLocalBranch({ cwd: input.cwd, refName: newRefName, force: true }), + ), Effect.catch((cause) => - Effect.logWarning("could not remove the branch of a failed worktree", { cause }), + Effect.logWarning("could not clean up a failed worktree or its branch", { cause }), + ), + ) + : Effect.void, + ), + Effect.onExit(() => + lockReason + ? releaseWorktreeRegistration(input.cwd, lockReason, false).pipe( + Effect.catch((cause) => + Effect.logWarning("could not unlock a created worktree", { cause }), ), ) : Effect.void, ), ); // Branch cleanup must not delete the branch before `worktree add` checks it out. - yield* withRepositoryLease(leaseKey, "shared", Effect.andThen(createBranch, checkout)).pipe( - Effect.ensuring(releaseTemplateName), - ); - if (fastCheckout) { - yield* checkoutCache.schedule(worktreePath, cacheGeneration); - } - - if (progress?.onWorktreeClaimed) { - yield* progress.onWorktreeClaimed(worktreePath); - } + yield* withRepositoryLease( + leaseKey, + "shared", + Effect.andThen(createBranch, checkoutAndClaim), + ).pipe(Effect.ensuring(releaseTemplateName)); if (template !== "") yield* markT3CreatedWorktree(fileSystem, path, input.cwd, worktreePath); // worktree add leaves submodules empty, potentially omitting project skills, tools or source. @@ -5181,19 +5506,51 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); }); - const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => - executeGitWithStableDiagnostics( - "GitVcsDriver.setBranchUpstream", - input.cwd, - [ + const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = Effect.fn( + "setBranchUpstream", + )( + function* (input) { + const args = [ "branch", "--set-upstream-to", - `${input.remoteName}/${input.remoteBranch}`, + `refs/remotes/${input.remoteName}/${input.remoteBranch}`, "--", input.branch, - ], - { writesSharedConfig: true }, - ).pipe(retryWhileConfigLocked, Effect.asVoid); + ]; + const result = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.setBranchUpstream", + input.cwd, + args, + { allowNonZeroExit: true }, + ); + if (result.exitCode === 0) return; + // Git requires a configured fetch mapping even when the tracking ref already exists. The + // mapping is a glob because an exact source fails every later fetch once the branch is deleted. + if ( + /^fatal: cannot set up tracking information; starting point '.+' is not a branch$/m.test( + result.stderr, + ) + ) { + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.setBranchUpstream.addFetchMapping", + input.cwd, + [ + "config", + "--add", + `remote.${input.remoteName}.fetch`, + `+refs/heads/${input.remoteBranch}*:refs/remotes/${input.remoteName}/${input.remoteBranch}*`, + ], + ).pipe(retryWhileConfigLocked); + } + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.setBranchUpstream", + input.cwd, + args, + ).pipe(retryWhileConfigLocked); + }, + // Keep mapping detection and both writes under the same lock across sibling worktrees. + (effect, input) => withSharedConfigLock(input.cwd, effect), + ); /** * Unlinks links under `target` without following them and removes the diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 4bfdcc90037ec..da2f988ce1ed8 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -33,6 +33,7 @@ export interface VcsProcessInput { readonly spawnCwd?: string; readonly stdin?: string; readonly onStdoutChunk?: (chunk: Uint8Array) => void; + readonly onSpawn?: () => void; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; @@ -232,6 +233,7 @@ export const make = Effect.gen(function* () { ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}), ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.onStdoutChunk !== undefined ? { onStdoutChunk: input.onStdoutChunk } : {}), + ...(input.onSpawn !== undefined ? { onSpawn: input.onSpawn } : {}), env: { ...(input.command === "git" ? unsetInheritedRepositoryEnv(process.env, hostPlatform) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 96e29039abbd2..0a336a66ca2ff 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1068,6 +1068,49 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect("publishes a checkout's branch before returning, ahead of the remote read", () => { + const state: Parameters[0] = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + const subscribed = yield* Deferred.make(); + const localPublished = yield* Deferred.make(); + const remotePublished = yield* Deferred.make(); + yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.runForEach((event) => { + if (event._tag === "snapshot") return Deferred.succeed(subscribed, undefined); + if (event._tag === "localUpdated") return Deferred.succeed(localPublished, event.local); + return event.remote === null + ? Effect.void + : Deferred.succeed(remotePublished, event.remote); + }), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + + const releaseRemote = yield* Deferred.make(); + state.beforeRemoteStatus = Deferred.await(releaseRemote); + state.currentLocalStatus = { ...baseLocalStatus, refName: "main" }; + state.currentRemoteStatus = remoteStatusWithPr; + yield* broadcaster.refreshAfterCheckout("/repo"); + + assert.equal((yield* Deferred.await(localPublished)).refName, "main"); + assert.isFalse(yield* Deferred.isDone(remotePublished)); + + yield* Deferred.succeed(releaseRemote, undefined); + assert.deepStrictEqual(yield* Deferred.await(remotePublished), remoteStatusWithPr); + assert.equal((yield* broadcaster.getStatus({ cwd: "/repo" })).pr?.number, 2978); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("a remote poll refreshes Changes totals when divergence counts stay the same", () => { const state: Parameters[0] = { currentLocalStatus: { diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 06cc4aff7dfa2..00edfc3634a67 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -236,6 +236,11 @@ export class VcsStatusBroadcaster extends Context.Service< readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; + /** + * For a checkout someone is waiting on. Returns once the new branch is published, so the + * caller's reply does not outrun it, and finishes the slower remote read in the background. + */ + readonly refreshAfterCheckout: (cwd: string) => Effect.Effect; readonly refreshStatus: ( cwd: string, options?: { readonly localOnly?: boolean }, @@ -603,6 +608,30 @@ export const make = Effect.gen(function* () { ); }); + const refreshRemoteAndLocalTotals = Effect.fn("VcsStatusBroadcaster.refreshRemoteAndLocalTotals")( + function* (cwd: string, policyCwd: string) { + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); + // Bump the PR lookup epoch after earlier remote writers have finished. + yield* workflow.invalidateStatus(cwd); + const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream }); + // Fetch can move the base used for local Changes totals. + yield* refreshLocalStatusCore(cwd); + // Without a fetch, the cached behind count cannot justify an automatic pull. + const pulled = refreshUpstream + ? yield* maybeAutoPull(cwd, remoteRead, [policyCwd]) + : null; + if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); + yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); + const parts = yield* getStatusParts(cwd); + return mergeGitStatusParts(parts.local, parts.remote); + }), + ); + }, + ); + const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd, options) { @@ -613,23 +642,22 @@ export const make = Effect.gen(function* () { const parts = yield* getStatusParts(cwd); return mergeGitStatusParts(parts.local, parts.remote); } - return yield* withRemoteWriteLock( - cwd, - Effect.gen(function* () { - const refreshUpstream = !Duration.isZero(yield* automaticFetchInterval); - // Bump the PR lookup epoch after earlier remote writers have finished. - yield* workflow.invalidateStatus(cwd); - const remoteRead = yield* readRemoteStatus(cwd, { refreshUpstream }); - // Fetch can move the base used for local Changes totals. - yield* refreshLocalStatusCore(cwd); - // Without a fetch, the cached behind count cannot justify an automatic pull. - const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remoteRead, [rawCwd]) : null; - if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); - yield* updateCachedRemoteStatus(cwd, remoteRead, { publish: true }); - const parts = yield* getStatusParts(cwd); - return mergeGitStatusParts(parts.local, parts.remote); - }), + return yield* refreshRemoteAndLocalTotals(cwd, rawCwd); + }); + + const refreshAfterCheckout: VcsStatusBroadcaster["Service"]["refreshAfterCheckout"] = Effect.fn( + "VcsStatusBroadcaster.refreshAfterCheckout", + )(function* (rawCwd) { + const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const localDone = yield* Deferred.make(); + // Owned by the broadcaster so a client disconnecting mid-switch still leaves status correct. + yield* refreshLocalStatusCore(cwd).pipe( + Effect.ensuring(Deferred.succeed(localDone, undefined)), + Effect.andThen(refreshRemoteAndLocalTotals(cwd, rawCwd)), + Effect.ignoreCause({ log: true }), + Effect.forkIn(broadcasterScope), ); + yield* Deferred.await(localDone); }); const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = @@ -1122,6 +1150,7 @@ export const make = Effect.gen(function* () { ), getStatus, refreshLocalStatus, + refreshAfterCheckout, refreshStatus: shareQueuedRefreshStatus, refreshPullRequestStatus, streamStatus, diff --git a/apps/server/src/vcs/worktreeCheckoutCache.ts b/apps/server/src/vcs/worktreeCheckoutCache.ts index a72d6d859fc54..8a51189fe6d69 100644 --- a/apps/server/src/vcs/worktreeCheckoutCache.ts +++ b/apps/server/src/vcs/worktreeCheckoutCache.ts @@ -511,6 +511,7 @@ export const makeWorktreeCheckoutCache = Effect.fn("WorktreeCheckoutCache.make") readonly worktreePath: string; readonly refName: string; readonly checkoutWorkers: string; + readonly lockReason?: string | undefined; }) { if (!(yield* gitSupported)) return false; const worktreePath = path.resolve(input.cwd, input.worktreePath); @@ -613,13 +614,14 @@ export const makeWorktreeCheckoutCache = Effect.fn("WorktreeCheckoutCache.make") if (moved === null || (yield* fileSystem.exists(target.indexHook))) return null; return target; }); - const rollback = git("rollback", input.cwd, [ - "worktree", - "remove", - "--force", - "--", - worktreePath, - ]).pipe( + const rollback = ( + input.lockReason + ? git("unlockRollback", input.cwd, ["worktree", "unlock", "--", worktreePath]) + : Effect.void + ).pipe( + Effect.andThen( + git("rollback", input.cwd, ["worktree", "remove", "--force", "--", worktreePath]), + ), Effect.flatMap((removed) => removed === null ? Effect.logWarning("could not remove a partially created worktree", { worktreePath }) @@ -633,6 +635,7 @@ export const makeWorktreeCheckoutCache = Effect.fn("WorktreeCheckoutCache.make") "worktree", "add", "--no-checkout", + ...(input.lockReason ? ["--lock", "--reason", input.lockReason] : []), "--", worktreePath, input.refName, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 521e127779984..1c50b94a7cbcc 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -688,6 +688,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { yield* git(cwd, ["commit", "-m", "init"], env); yield* git(cwd, ["worktree", "add", "-b", "x", ".worktrees/x"]); yield* writeTextFile(cwd, ".worktrees/x/src/foo.py", "print(1)"); + const expected = [".worktrees/x/src/foo.py"]; + // Windows file names cannot contain a newline. + if ((yield* HostProcess.Platform) !== "win32") { + yield* git(cwd, ["worktree", "add", "--detach", ".worktrees/line\nname"]); + yield* writeTextFile(cwd, ".worktrees/line\nname/src/foo.py", "print(2)"); + expected.unshift(".worktrees/line\nname/src/foo.py"); + } const exact = yield* searchWorkspaceEntries({ cwd, @@ -695,10 +702,14 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { limit: 25, kind: "file", }); - expect(exact.entries).toEqual([{ path: ".worktrees/x/src/foo.py", kind: "file" }]); + expect(exact.entries.toSorted((a, b) => (a.path < b.path ? -1 : 1))).toEqual( + expected.map((path) => ({ path, kind: "file" })), + ); const partial = yield* searchWorkspaceEntries({ cwd, query: "src/fo", limit: 25 }); - expect(partial.entries.map((entry) => entry.path)).not.toContain(".worktrees/x/src/foo.py"); + for (const path of expected) { + expect(partial.entries.map((entry) => entry.path)).not.toContain(path); + } }), ); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 43c8fae4dfcaf..51b1720126536 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -307,23 +307,30 @@ export const make = Effect.gen(function* () { ) { return []; } - const listed = yield* vcsProcess - .run({ - operation: "WorkspaceEntries.listWorktrees", - command: "git", - args: ["worktree", "list", "--porcelain"], - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }) - .pipe(Effect.orElseSucceed(() => undefined)); - if (!listed || listed.exitCode !== 0) return []; + const listWorktrees = (args: ReadonlyArray) => + vcsProcess + .run({ + operation: "WorkspaceEntries.listWorktrees", + command: "git", + args: ["worktree", "list", "--porcelain", ...args], + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : undefined)), + Effect.orElseSucceed(() => undefined), + ); + // Only NUL framing keeps a newline in a worktree path. Pre-2.36 Git rejects `-z`. + const listed = + (yield* listWorktrees(["-z"])) ?? (yield* listWorktrees([]))?.replaceAll("\n", "\0"); + if (listed === undefined) return []; const realCwd = yield* Effect.promise(() => NodeFSP.realpath(cwd).catch(() => cwd)); - const worktreeRoots = listed.stdout.split(/\n\s*\n/).flatMap((record) => { - const lines = record.split("\n"); - const worktree = lines.find((line) => line.startsWith("worktree ")); + const worktreeRoots = listed.split("\0\0").flatMap((record) => { + const fields = record.split("\0"); + const worktree = fields.find((field) => field.startsWith("worktree ")); // A `prunable` record's directory is gone; another checkout may now sit there. - if (worktree === undefined || lines.some((line) => line.startsWith("prunable"))) return []; + if (worktree === undefined || fields.some((field) => field.startsWith("prunable"))) return []; const root = worktree.slice("worktree ".length); const relativeRoot = path.relative(realCwd, root); return relativeRoot === "" || diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 34ddeaa872ad4..bc22117ba74ab 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -320,25 +320,27 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("reads ~/ paths from the server home outside the workspace", () => - Effect.gen(function* () { - const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; - const cwd = yield* makeTempDir; - const home = yield* makeTempDir; - yield* writeTextFile(home, ".claude/CLAUDE.md", "# Notes\n"); - yield* writeTextFile(cwd, "~/.claude/CLAUDE.md", "Workspace decoy\n"); + it.effect.each(["~/.claude/CLAUDE.md", "~\\.claude/CLAUDE.md"])( + "reads %s from the server home outside the workspace", + (relativePath) => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const home = yield* makeTempDir; + yield* writeTextFile(home, ".claude/CLAUDE.md", "# Notes\n"); + yield* writeTextFile(cwd, "~/.claude/CLAUDE.md", "Workspace decoy\n"); - const result = yield* workspaceFileSystem - .readFile({ cwd, relativePath: "~/.claude/CLAUDE.md" }) - .pipe(Effect.provideService(HostProcess.HomeDirectory, home)); + const result = yield* workspaceFileSystem + .readFile({ cwd, relativePath }) + .pipe(Effect.provideService(HostProcess.HomeDirectory, home)); - expect(result).toEqual({ - relativePath: "~/.claude/CLAUDE.md", - contents: "# Notes\n", - byteLength: 8, - truncated: false, - }); - }), + expect(result).toEqual({ + relativePath, + contents: "# Notes\n", + byteLength: 8, + truncated: false, + }); + }), ); it.effect("does not read server state files", () => diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 7f3fb67b15000..494472e4ce68d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -260,9 +260,10 @@ export const make = Effect.gen(function* () { input: ProjectReadFileInput, ) { const requestedPath = input.relativePath; - const hostPath = requestedPath.startsWith("~/") - ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) - : yield* toHostPath(requestedPath); + const hostPath = + requestedPath.startsWith("~/") || requestedPath.startsWith("~\\") + ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) + : yield* toHostPath(requestedPath); if ( isNetworkPath(hostPath) && !KnownWorkspaces.isWithinRoot(path, path.resolve(hostPath), path.resolve(input.cwd)) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7b185d0f3ec8d..736d0132f767b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3225,9 +3225,19 @@ export const layerWsRpc = ( ), ), [WS_METHODS.vcsCreateRef]: (input) => - gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow + .createRef(input) + .pipe( + Effect.tap(() => + input.switchRef + ? vcsStatusBroadcaster.refreshAfterCheckout(input.cwd) + : refreshGitStatus(input.cwd), + ), + ), [WS_METHODS.vcsSwitchRef]: (input) => - gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow + .switchRef(input) + .pipe(Effect.tap(() => vcsStatusBroadcaster.refreshAfterCheckout(input.cwd))), [WS_METHODS.vcsInit]: (input) => vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), diff --git a/apps/web/src/components/AppSidebarLayout.browser.test.tsx b/apps/web/src/components/AppSidebarLayout.browser.test.tsx new file mode 100644 index 0000000000000..c97ea5e6412aa --- /dev/null +++ b/apps/web/src/components/AppSidebarLayout.browser.test.tsx @@ -0,0 +1,42 @@ +import { page, userEvent } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; + +vi.mock(import("../state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig } = await import("@t3tools/shared/keybindings"); + const keybindings = compileResolvedKeybindingsConfig([ + { key: "mod+b", command: "sidebar.toggle", when: "!terminalFocus" }, + ]); + return { ...actual, primaryServerKeybindingsAtom: Atom.make(keybindings) }; +}); +// The thread list needs live environments; the shortcut lives in the shell around it. +vi.mock(import("./Sidebar"), () => ({ default: (() => null) as never })); + +import { AppSidebarLayout } from "./AppSidebarLayout"; + +describe("sidebar toggle shortcut", () => { + it("leaves the key to a focused terminal when its rule excludes terminalFocus", async () => { + await page.viewport(1280, 800); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + await using screen = await render( + +
+