authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 00:38:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 00:38:17-07:00
logeeaac49a9d20d94970b26fe7775fc8ddb4672f4f
treec36ecbafcf2f63527bcb37c0d96c1b9097de3299
parent3bbfdc7755db099081ad546ff9e8263fc07b82e9
signature Signed by PGP key B5690EEEBB952194

fix: update all no longer runs agents twice or loses background work (#19291)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

46 files changed, 2059 insertions(+), 366 deletions(-)

apps/desktop/src/backend/DesktopBackendManager.test.ts+1-1
......@@ -264,7 +264,7 @@ describe("DesktopBackendManager", () => {
264264 assert.equal(spawnedCommand.options.additionalFds?.fd5?.type, "output");
265265 assert.equal(
266266 Duration.toMillis(Duration.fromInputUnsafe(spawnedCommand.options.forceKillAfter)),
267 2_000,
267 5_000,
268268 );
269269
270270 assert.deepEqual(yield* decodeBootstrap(bootstrapJson), configWithObservability);
apps/desktop/src/backend/DesktopBackendManager.ts+3-1
......@@ -65,7 +65,9 @@ const MAX_PREFLIGHT_FAILURE_ATTEMPTS = 5;
6565const DEFAULT_BACKEND_READINESS_TIMEOUT = Duration.minutes(1);
6666const DEFAULT_BACKEND_READINESS_INTERVAL = Duration.millis(100);
6767const DEFAULT_BACKEND_READINESS_REQUEST_TIMEOUT = Duration.seconds(1);
68const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(2);
68// Same as the service launcher. A killed backend leaves its agents running,
69// and a busy one needs a few seconds to stop every provider session.
70const DEFAULT_BACKEND_TERMINATE_GRACE = Duration.seconds(5);
6971const DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT = Duration.seconds(5);
7072const BACKEND_READINESS_PATH = "/.well-known/t3/environment";
7173const { logWarning: logBackendProcessWarning } =
apps/desktop/src/updates/DesktopUpdates.test.ts+63
......@@ -652,6 +652,67 @@ describe("DesktopUpdates", () => {
652652 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
653653 });
654654
655 it.effect("records the version an install hands to the installer", () => {
656 let recordsAtStop: ReadonlyArray<string> = [];
657 const harness = makeHarness({
658 stopBackend: Effect.sync(() => {
659 recordsAtStop = [...harness.installRecords.values()];
660 }),
661 });
662
663 return Effect.scoped(
664 Effect.gen(function* () {
665 const updates = yield* DesktopUpdates.DesktopUpdates;
666 yield* updates.configure;
667 harness.emit("update-downloaded", { version: "1.2.4" });
668 yield* flushCallbacks;
669
670 assert.isTrue((yield* updates.install).accepted);
671 assert.deepEqual(
672 recordsAtStop.map((record) => JSON.parse(record)),
673 [{ fromVersion: "1.2.3", targetVersion: "1.2.4" }],
674 );
675 }),
676 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
677 });
678
679 it.effect("flags an install that did not apply and keeps the flag through checks", () => {
680 const harness = makeHarness({
681 installRecord: JSON.stringify({ fromVersion: "1.2.3", targetVersion: "1.2.4" }),
682 });
683
684 return Effect.scoped(
685 Effect.gen(function* () {
686 const updates = yield* DesktopUpdates.DesktopUpdates;
687 yield* updates.configure;
688 assert.equal((yield* updates.getState).notAppliedVersion, "1.2.4");
689 // Kept, so the next start on 1.2.3 still knows 1.2.4 did not apply.
690 assert.isDefined(harness.previousInstallRecord());
691
692 harness.emit("update-available", { version: "1.2.4" });
693 yield* flushCallbacks;
694 const state = yield* updates.getState;
695 assert.equal(state.status, "available");
696 assert.equal(state.notAppliedVersion, "1.2.4");
697 }),
698 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
699 });
700
701 it.effect("does not flag an install that applied", () => {
702 const harness = makeHarness({
703 installRecord: JSON.stringify({ fromVersion: "1.2.2", targetVersion: "1.2.3" }),
704 });
705
706 return Effect.scoped(
707 Effect.gen(function* () {
708 const updates = yield* DesktopUpdates.DesktopUpdates;
709 yield* updates.configure;
710 assert.isUndefined((yield* updates.getState).notAppliedVersion);
711 assert.isUndefined(harness.previousInstallRecord());
712 }),
713 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
714 });
715
655716 it.effect("drops the update restart marker when an install is interrupted", () =>
656717 Effect.gen(function* () {
657718 const stopping = yield* Deferred.make<void>();
......@@ -703,6 +764,8 @@ describe("DesktopUpdates", () => {
703764 assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]);
704765 // The restarted old backend must release its tunnel on a later quit.
705766 assert.equal(harness.updateRestartMarkers.size, 0);
767 // The app never quit, so the next start must not report an unapplied install.
768 assert.equal(harness.installRecords.size, 0);
706769 }),
707770 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
708771 });
apps/desktop/src/updates/DesktopUpdates.ts+70-2
......@@ -46,6 +46,7 @@ import {
4646 reduceDesktopUpdateStateOnInstallFailure,
4747 reduceDesktopUpdateStateOnNoUpdate,
4848 reduceDesktopUpdateStateOnUpdateAvailable,
49 resolveNotAppliedInstallVersion,
4950} from "./updateMachine.ts";
5051
5152const AUTO_UPDATE_STARTUP_DELAY = "15 seconds";
......@@ -72,6 +73,14 @@ const UpdateInfo = Schema.Struct({
7273const DownloadProgressInfo = Schema.Struct({
7374 percent: Schema.Number,
7475});
76/** Saved in the runtime folder before an install quits the app, read on the next start. */
77export const DESKTOP_UPDATE_INSTALL_RECORD_FILE = "desktop-update-install.json";
78const DesktopUpdateInstallRecord = Schema.fromJsonString(
79 Schema.Struct({ fromVersion: Schema.String, targetVersion: Schema.String }),
80);
81const decodeInstallRecord = Schema.decodeUnknownEffect(DesktopUpdateInstallRecord);
82const encodeInstallRecord = Schema.encodeEffect(DesktopUpdateInstallRecord);
83
7584const decodeAppUpdateYmlConfig = Schema.decodeUnknownEffect(AppUpdateYmlConfig);
7685const decodeUpdateInfo = Schema.decodeUnknownEffect(UpdateInfo);
7786const decodeDownloadProgressInfo = Schema.decodeUnknownEffect(DownloadProgressInfo);
......@@ -347,6 +356,7 @@ export const make = Effect.gen(function* () {
347356 const finishedUpdateActions = yield* PubSub.unbounded<UpdateAction>();
348357 const updaterConfiguredRef = yield* Ref.make(false);
349358 const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1);
359 const notAppliedVersionRef = yield* Ref.make(Option.none<string>());
350360 const updateStateRef = yield* Ref.make<DesktopUpdateState>(
351361 createInitialDesktopUpdateState(
352362 environment.appVersion,
......@@ -600,11 +610,57 @@ export const make = Effect.gen(function* () {
600610 .remove(updateRestartMarkerPath, { force: true })
601611 .pipe(Effect.ignore);
602612
613 // Records the version an install hands to the OS installer, so the next
614 // start can tell whether it applied. Best effort, like the restart marker.
615 const installRecordPath = environment.path.join(
616 updateRestartMarkerDir,
617 DESKTOP_UPDATE_INSTALL_RECORD_FILE,
618 );
619 const writeInstallRecord = (targetVersion: string) =>
620 encodeInstallRecord({ fromVersion: environment.appVersion, targetVersion }).pipe(
621 Effect.flatMap((text) => fileSystem.writeFileString(installRecordPath, text)),
622 Effect.catch((error) =>
623 logUpdaterWarning("Could not record the update install.", { errorTag: error._tag }),
624 ),
625 );
626 const removeInstallRecord = fileSystem
627 .remove(installRecordPath, { force: true })
628 .pipe(Effect.ignore);
629
630 // Reads the record of the last install. Returns the version it did not
631 // apply, when the app came back on the version it installed from. That
632 // record stays through later starts, until the version changes or a new
633 // install replaces it, so no restart forgets the failed version.
634 const readNotAppliedInstallVersion = fileSystem.readFileString(installRecordPath).pipe(
635 Effect.flatMap(decodeInstallRecord),
636 Effect.option,
637 Effect.map((record) =>
638 Option.flatMap(record, (value) =>
639 Option.fromNullishOr(resolveNotAppliedInstallVersion(value, environment.appVersion)),
640 ),
641 ),
642 Effect.tap((notAppliedVersion) =>
643 Option.isNone(notAppliedVersion) ? removeInstallRecord : Effect.void,
644 ),
645 );
646
647 const baseUpdateState = (channel: DesktopUpdateChannel, enabled: boolean) =>
648 Ref.get(notAppliedVersionRef).pipe(
649 Effect.map((notAppliedVersion) => ({
650 ...createBaseUpdateState(channel, enabled, environment),
651 ...Option.match(notAppliedVersion, {
652 onNone: () => ({}),
653 onSome: (version) => ({ notAppliedVersion: version }),
654 }),
655 })),
656 );
657
603658 const resetInstallAction = Effect.all(
604659 [
605660 finishUpdateAction("install"),
606661 Ref.set(desktopState.quitting, false),
607662 removeUpdateRestartMarker,
663 removeInstallRecord,
608664 ],
609665 { discard: true },
610666 );
......@@ -621,6 +677,7 @@ export const make = Effect.gen(function* () {
621677
622678 yield* Ref.set(desktopState.quitting, false);
623679 yield* removeUpdateRestartMarker;
680 yield* removeInstallRecord;
624681 yield* Effect.gen(function* () {
625682 const instances = yield* pool.list;
626683 const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
......@@ -691,6 +748,8 @@ export const make = Effect.gen(function* () {
691748
692749 return yield* Effect.gen(function* () {
693750 yield* writeUpdateRestartMarker;
751 const { downloadedVersion } = yield* Ref.get(updateStateRef);
752 if (downloadedVersion !== null) yield* writeInstallRecord(downloadedVersion);
694753 // Stop every backend in the pool, not just the primary. With
695754 // parallel WSL + Windows backends, leaving the WSL instance up
696755 // means quitAndInstall's app.quit() exits before the pool's
......@@ -985,6 +1044,15 @@ export const make = Effect.gen(function* () {
9851044 const appUpdateYmlConfig = yield* readAppUpdateYml;
9861045 yield* Ref.set(appUpdateYmlConfigRef, appUpdateYmlConfig);
9871046
1047 const notAppliedVersion = yield* readNotAppliedInstallVersion;
1048 yield* Ref.set(notAppliedVersionRef, notAppliedVersion);
1049 if (Option.isSome(notAppliedVersion)) {
1050 yield* logUpdaterWarning("the last update install did not apply", {
1051 version: environment.appVersion,
1052 targetVersion: notAppliedVersion.value,
1053 });
1054 }
1055
9881056 if (config.mockUpdates) {
9891057 yield* electronUpdater.setFeedURL({
9901058 provider: "generic",
......@@ -994,7 +1062,7 @@ export const make = Effect.gen(function* () {
9941062
9951063 const settings = yield* desktopSettings.get;
9961064 const enabled = yield* shouldEnableAutoUpdates;
997 yield* setState(createBaseUpdateState(settings.updateChannel, enabled, environment));
1065 yield* setState(yield* baseUpdateState(settings.updateChannel, enabled));
9981066 if (!enabled) {
9991067 return;
10001068 }
......@@ -1065,7 +1133,7 @@ export const make = Effect.gen(function* () {
10651133 );
10661134
10671135 const enabled = yield* shouldEnableAutoUpdates;
1068 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));
1136 yield* setState(yield* baseUpdateState(nextChannel, enabled));
10691137
10701138 if (!enabled || !(yield* Ref.get(updaterConfiguredRef))) {
10711139 return yield* Ref.get(updateStateRef);
apps/desktop/src/updates/remoteUpdateFlow.test.ts+18
......@@ -34,6 +34,24 @@ function makeState(overrides: Partial<DesktopUpdateState> = {}): DesktopUpdateSt
3434}
3535
3636describe("nextRemoteDesktopUpdateStep", () => {
37 it("does not install again a version that did not apply on this machine", () => {
38 const step = nextRemoteDesktopUpdateStep(
39 makeState({ status: "available", availableVersion: "1.2.4", notAppliedVersion: "1.2.4" }),
40 NO_ATTEMPTS,
41 null,
42 );
43 assert.equal(step.action, "done");
44 assert.include(step.action === "done" ? step.reason : "", "1.2.4 did not apply");
45 assert.deepEqual(
46 nextRemoteDesktopUpdateStep(
47 makeState({ status: "available", availableVersion: "1.2.5", notAppliedVersion: "1.2.4" }),
48 NO_ATTEMPTS,
49 null,
50 ),
51 { action: "download" },
52 );
53 });
54
3755 it("fails immediately when updates are disabled, preferring the known reason", () => {
3856 const state = makeState({ enabled: false, status: "disabled" });
3957 assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, "dev build"), {
apps/desktop/src/updates/remoteUpdateFlow.ts+13
......@@ -52,6 +52,19 @@ export function nextRemoteDesktopUpdateStep(
5252 reason: disabledReason ?? "Automatic updates are disabled on this machine.",
5353 };
5454 }
55 // An install of this version already failed to apply here. Installing it
56 // again from afar would stop the app's threads for nothing, so only the user
57 // on this machine retries it.
58 if (
59 state.notAppliedVersion !== undefined &&
60 (state.downloadedVersion ?? state.availableVersion) === state.notAppliedVersion
61 ) {
62 return {
63 action: "done",
64 outcome: "failed",
65 reason: `Update ${state.notAppliedVersion} did not apply on this machine. Retry it from the desktop app there.`,
66 };
67 }
5568 // Mirror installDownloadedUpdate's own admission rule exactly, so the run
5669 // never reports an install that the updater then refuses. A download
5770 // survives an unrelated background updater error (errorContext null) and
apps/desktop/src/updates/updateMachine.test.ts+14
......@@ -11,6 +11,7 @@ import {
1111 reduceDesktopUpdateStateOnInstallFailure,
1212 reduceDesktopUpdateStateOnNoUpdate,
1313 reduceDesktopUpdateStateOnUpdateAvailable,
14 resolveNotAppliedInstallVersion,
1415} from "./updateMachine.ts";
1516
1617const runtimeInfo = {
......@@ -255,3 +256,16 @@ describe("updateMachine", () => {
255256 expect(state.omittedReleaseCount).toBe(0);
256257 });
257258});
259
260describe("resolveNotAppliedInstallVersion", () => {
261 const record = { fromVersion: "1.2.3", targetVersion: "1.2.4" };
262
263 it("flags the target when the app came back on the version it installed from", () => {
264 expect(resolveNotAppliedInstallVersion(record, "1.2.3")).toBe("1.2.4");
265 });
266
267 it("stays quiet when the install applied or the app runs another version", () => {
268 expect(resolveNotAppliedInstallVersion(record, "1.2.4")).toBeNull();
269 expect(resolveNotAppliedInstallVersion(record, "1.2.5")).toBeNull();
270 });
271});
apps/desktop/src/updates/updateMachine.ts+20
......@@ -211,3 +211,23 @@ export function reduceDesktopUpdateStateOnInstallFailure(
211211 canRetry: true,
212212 };
213213}
214
215/** The install this app handed to the OS installer, saved right before it quits. */
216export interface DesktopUpdateInstallRecord {
217 readonly fromVersion: string;
218 readonly targetVersion: string;
219}
220
221/**
222 * Read on the next start: the version an install did not apply, because the
223 * app came back on the version it installed from. Null when the install
224 * applied, or when the app now runs some other version.
225 */
226export function resolveNotAppliedInstallVersion(
227 record: DesktopUpdateInstallRecord,
228 appVersion: string,
229): string | null {
230 return record.fromVersion === appVersion && record.targetVersion !== appVersion
231 ? record.targetVersion
232 : null;
233}
apps/desktop/src/updates/updatesTestHarness.ts+25-7
......@@ -37,6 +37,8 @@ export interface UpdatesHarnessOptions {
3737 readonly platform?: NodeJS.Platform;
3838 /** Contents of the resources/package-type marker a Linux package ships. */
3939 readonly packageType?: string | undefined;
40 /** Contents of the install record a previous run left behind. */
41 readonly installRecord?: string | undefined;
4042}
4143
4244export function makeHarness(options: UpdatesHarnessOptions = {}) {
......@@ -208,12 +210,21 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
208210 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
209211 : DesktopAppSettings.layer;
210212
211 // Tracks the restart markers installs leave, so installs stay free of real
212 // disk I/O that would outrun the tests' settle loops.
213 // Tracks the restart markers and install records installs leave, so
214 // installs stay free of real disk I/O that would outrun the tests' settle loops.
213215 const updateRestartMarkers = new Set<string>();
216 // Contents of the install record, by path once an install writes one.
217 const installRecords = new Map<string, string>();
218 let previousInstallRecord = options.installRecord;
219 const isInstallRecord = (path: string) =>
220 path.endsWith(DesktopUpdates.DESKTOP_UPDATE_INSTALL_RECORD_FILE);
214221 const layerFileSystem = FileSystem.layerNoop({
215 readFileString: (path) =>
216 path === "/missing/resources/package-type" && options.packageType !== undefined
222 readFileString: (path) => {
223 const installRecord = isInstallRecord(path)
224 ? (installRecords.get(path) ?? previousInstallRecord)
225 : undefined;
226 if (installRecord !== undefined) return Effect.succeed(installRecord);
227 return path === "/missing/resources/package-type" && options.packageType !== undefined
217228 ? Effect.succeed(options.packageType)
218229 : Effect.fail(
219230 PlatformError.systemError({
......@@ -222,15 +233,19 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
222233 _tag: "NotFound",
223234 pathOrDescriptor: path,
224235 }),
225 ),
236 );
237 },
226238 makeDirectory: () => Effect.void,
227 writeFileString: (path) =>
239 writeFileString: (path, data) =>
228240 Effect.sync(() => {
229 updateRestartMarkers.add(path);
241 if (isInstallRecord(path)) installRecords.set(path, data);
242 else updateRestartMarkers.add(path);
230243 }),
231244 remove: (path) =>
232245 Effect.sync(() => {
233246 updateRestartMarkers.delete(path);
247 installRecords.delete(path);
248 if (isInstallRecord(path)) previousInstallRecord = undefined;
234249 }),
235250 });
236251
......@@ -259,6 +274,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
259274 quitAndInstalls: () => quitAndInstallCount,
260275 installSteps,
261276 updateRestartMarkers,
277 installRecords,
278 /** The record the test started with, until the app removes it. */
279 previousInstallRecord: () => previousInstallRecord,
262280 downloadCount: () => downloadCount,
263281 feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls,
264282 fullChangelog: () => fullChangelog,
apps/server/src/mcp/OrchestratorMcpService.ts+4-3
......@@ -81,6 +81,7 @@ import {
8181 DispatchModeLimit,
8282 type DispatchModeRefusal,
8383} from "../orchestration-v2/DispatchModeLimit.ts";
84import { runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts";
8485import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
8586import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
8687import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
......@@ -330,9 +331,9 @@ function invalidOptionSelections(
330331}
331332
332333function taskStatusForRun(
333 run: Pick<OrchestrationV2Run, "status"> | undefined,
334 run: Pick<OrchestrationV2Run, "status" | "restartCancelledBackgroundWork"> | undefined,
334335): OrchestratorMcpDelegateTaskResult["status"] {
335 switch (run?.status) {
336 switch (run === undefined ? undefined : runOutcome(run)) {
336337 case "queued":
337338 return "queued";
338339 case "waiting":
......@@ -2642,7 +2643,7 @@ const make = Effect.gen(function* () {
26422643 return {
26432644 threadId: input.threadId,
26442645 runId: result.run?.id ?? null,
2645 status: result.run?.status ?? "idle",
2646 status: result.run === null ? "idle" : runOutcome(result.run),
26462647 timedOut: result.timedOut,
26472648 } satisfies OrchestratorMcpThreadWaitResult;
26482649 }),
apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts+65-7
......@@ -803,6 +803,8 @@ const seedRestartCancelledChild = (input: {
803803 readonly continuationPending: boolean;
804804 /** "completed" seeds a settled turn whose background work the restart cancelled. */
805805 readonly runStatus?: "cancelled" | "completed";
806 /** Background work the restart cancelled on the child's run. */
807 readonly lostWork?: OrchestrationV2Run["restartCancelledBackgroundWork"];
806808 readonly now: DateTime.Utc;
807809}) =>
808810 Effect.gen(function* () {
......@@ -880,16 +882,22 @@ const seedRestartCancelledChild = (input: {
880882 },
881883 ],
882884 });
885 const childRun = runEvent({
886 threadId: childThreadId,
887 runId: childRunId,
888 ordinal: 1,
889 status: input.runStatus ?? "cancelled",
890 now: input.now,
891 });
883892 yield* eventSink.writeWithEffects({
884893 commandId: reconcileCommandId(input.name),
885894 events: [
886 runEvent({
887 threadId: childThreadId,
888 runId: childRunId,
889 ordinal: 1,
890 status: input.runStatus ?? "cancelled",
891 now: input.now,
892 }),
895 input.lostWork === undefined
896 ? childRun
897 : {
898 ...childRun,
899 payload: { ...childRun.payload, restartCancelledBackgroundWork: input.lostWork },
900 },
893901 ],
894902 effects: input.continuationPending
895903 ? [
......@@ -1427,6 +1435,56 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => {
14271435 }),
14281436 );
14291437
1438 it.effect("reports a child whose monitor the restart cut as interrupted, not completed", () =>
1439 Effect.gen(function* () {
1440 const orchestrator = yield* Orchestrator.OrchestratorV2;
1441 const now = yield* DateTime.now;
1442 const threadId = ThreadId.make("thread:restart-monitor-parent");
1443 const projectId = ProjectId.make("project:restart-monitor-parent");
1444 const runId = RunId.make("run:restart-monitor-parent");
1445 const rootNodeId = NodeId.make("node:restart-monitor-parent-root");
1446 yield* seedParentWithTerminalTask({
1447 threadId,
1448 projectId,
1449 runId,
1450 rootNodeId,
1451 taskId: NodeId.make("node:restart-monitor-parent-settled"),
1452 deliveryState: "delivered",
1453 now,
1454 });
1455 // The child ended its turn to wait on a gate monitor; the restart cut it.
1456 const child = yield* seedRestartCancelledChild({
1457 parentThreadId: threadId,
1458 projectId,
1459 parentRunId: runId,
1460 rootNodeId,
1461 name: "restart-monitor-child",
1462 completionWake: "always",
1463 continuationPending: true,
1464 runStatus: "completed",
1465 lostWork: [{ kind: "monitor", label: "Gate attempt 9" }],
1466 now,
1467 });
1468 yield* orchestrator.recoverDelegatedTasks;
1469 const held = yield* orchestrator.getThreadProjection(threadId);
1470 assert.equal(held.subagents.find((row) => row.id === child.taskId)?.status, "running");
1471
1472 // Continuation is off, so the cut run is the outcome. It still goes to the parent.
1473 yield* continueRestartedRun({
1474 threadId: child.childThreadId,
1475 sourceRunId: child.childRunId,
1476 }).pipe(
1477 Effect.provide(ServerSettings.layerTest({ continueThreadsAfterServerUpdate: false })),
1478 );
1479
1480 const settled = yield* orchestrator.getThreadProjection(threadId);
1481 const task = settled.subagents.find((row) => row.id === child.taskId);
1482 assert.equal(task?.status, "interrupted");
1483 assert.equal(task?.result, "Child task ended with status interrupted.");
1484 assert.equal(task?.completionDelivery?.state, "claimed");
1485 }),
1486 );
1487
14301488 // A running provider turn does not prove the provider consumed the delivery
14311489 // (Claude marks the turn running before it reads the prompt), so a cut
14321490 // delivery is offered again even when a continuation resumes that turn.
apps/server/src/orchestration-v2/EffectOutbox.ts+29
......@@ -224,6 +224,8 @@ export interface EffectOutboxV2Shape {
224224 readonly signalCancellations: (effectIds: ReadonlyArray<string>) => Effect.Effect<void>;
225225 readonly awaitCancellation: (effectId: string) => Effect.Effect<void>;
226226 readonly clearCancellation: (effectId: string) => Effect.Effect<void>;
227 /** Resolves once the effect is neither pending nor running, or right away when it does not exist. */
228 readonly awaitSettled: (effectId: string) => Effect.Effect<void, EffectOutboxError>;
227229 readonly reconcileAfterProcessLoss: Effect.Effect<
228230 { readonly requeued: number; readonly cancelled: number },
229231 EffectOutboxError
......@@ -307,6 +309,18 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
307309 // for distinct threads without allowing notifications to grow unbounded.
308310 const available = yield* Queue.dropping<void>(64);
309311 const cancellationSignals = new Map<string, Deferred.Deferred<void>>();
312 const settledSignals = new Map<string, Deferred.Deferred<void>>();
313 const signalSettled = (effectIds: ReadonlyArray<string>) =>
314 Effect.forEach(
315 effectIds,
316 (effectId) => {
317 const signal = settledSignals.get(effectId);
318 if (signal === undefined) return Effect.void;
319 settledSignals.delete(effectId);
320 return Deferred.succeed(signal, undefined);
321 },
322 { discard: true },
323 );
310324 const notifyAvailable = (count = 1) =>
311325 Queue.offerAll(
312326 available,
......@@ -477,6 +491,7 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
477491 },
478492 { discard: true },
479493 );
494 yield* signalSettled(effectIds);
480495 // Cancellation can unblock other pending work on the same thread.
481496 // This method is deliberately post-commit, so it is also the safe
482497 // place to wake claimers after the durable status change.
......@@ -487,6 +502,18 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
487502 Effect.sync(() => {
488503 cancellationSignals.delete(effectId);
489504 }),
505 awaitSettled: (effectId) =>
506 Effect.gen(function* () {
507 // Registered before the read, so a settle right after it still resolves.
508 const signal = settledSignals.get(effectId) ?? Deferred.makeUnsafe<void>();
509 settledSignals.set(effectId, signal);
510 const effect = yield* service.get(effectId);
511 if (Option.exists(effect, ({ status }) => status === "pending" || status === "running")) {
512 return yield* Deferred.await(signal);
513 }
514 // Settled already: release every waiter that shares this signal.
515 yield* signalSettled([effectId]);
516 }),
490517 reconcileAfterProcessLoss: Effect.gen(function* () {
491518 const now = DateTime.formatIso(yield* DateTime.now);
492519 const cancelledRows = yield* sql<{ readonly effect_id: string }>`
......@@ -599,6 +626,7 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
599626 `;
600627 if (rows.length === 1) {
601628 cancellationSignals.delete(effectId);
629 yield* signalSettled([effectId]);
602630 yield* notifyAvailable();
603631 }
604632 return rows.length === 1;
......@@ -657,6 +685,7 @@ export const layer: Layer.Layer<EffectOutboxV2, never, SqlClient.SqlClient> = La
657685 `;
658686 if (rows.length === 1) {
659687 cancellationSignals.delete(effectId);
688 yield* signalSettled([effectId]);
660689 yield* notifyAvailable();
661690 }
662691 return rows.length === 1;
apps/server/src/orchestration-v2/FoundationPersistence.test.ts+31
......@@ -2084,6 +2084,37 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
20842084 }),
20852085 );
20862086
2087 it.effect("wakes a settle waiter when its effect finishes", () =>
2088 Effect.gen(function* () {
2089 const outbox = yield* EffectOutbox.EffectOutboxV2;
2090 const effectId = "effect:foundation-await-settled";
2091 yield* outbox.enqueue([
2092 {
2093 id: effectId,
2094 commandId: CommandId.make("command:foundation-await-settled"),
2095 threadId: ThreadId.make("thread:foundation-await-settled"),
2096 request: {
2097 type: "provider-runtime.continue",
2098 sourceRunId: RunId.make("run:foundation-await-settled"),
2099 },
2100 },
2101 ]);
2102 const waiter = yield* outbox.awaitSettled(effectId).pipe(Effect.forkChild);
2103 const claimed = yield* outbox.claimNext({
2104 workerId: "settle-worker",
2105 leaseDurationMs: 30_000,
2106 });
2107 assert.isTrue(Option.isSome(claimed));
2108 yield* Effect.yieldNow;
2109 assert.isUndefined(waiter.pollUnsafe());
2110
2111 yield* outbox.succeed({ effectId, workerId: "settle-worker" });
2112 yield* Fiber.join(waiter);
2113 // A settled effect does not wait.
2114 yield* outbox.awaitSettled(effectId);
2115 }),
2116 );
2117
20872118 it.effect("runs title generation beside critical work while serializing each lane", () =>
20882119 Effect.gen(function* () {
20892120 const outbox = yield* EffectOutbox.EffectOutboxV2;
apps/server/src/orchestration-v2/Orchestrator.ts+21-5
......@@ -88,7 +88,7 @@ import { CommandPolicyV2, resolveMessageDispatchIntent } from "./CommandPolicy.t
8888import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts";
8989import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts";
9090import { notificationTurnItem } from "@t3tools/provider-core/server/notification";
91import { isRestartNoteSource } from "./RestartBackgroundNote.ts";
91import { isRestartContinuationSource, runOutcome } from "./RestartBackgroundNote.ts";
9292import {
9393 isBufferedProviderNotification,
9494 isUndeliveredMailboxSteer,
......@@ -293,6 +293,16 @@ export interface OrchestratorV2Shape {
293293 childThreadId: ThreadId,
294294 taskId?: NodeId,
295295 ) => Effect.Effect<boolean, OrchestratorProjectionError>;
296 /**
297 * Whether a restart continuation of this cut run has not started yet, so the
298 * run's status is not its outcome. A failed read counts as not pending.
299 */
300 readonly restartContinuationPending: (run: OrchestrationV2Run) => Effect.Effect<boolean>;
301 /**
302 * Resolves once that continuation has run or was declined. A declined one
303 * records no thread event, so a waiter needs this to wake.
304 */
305 readonly awaitRestartContinuation: (run: OrchestrationV2Run) => Effect.Effect<void>;
296306 readonly dispatch: (
297307 command: OrchestrationV2ServerCommand,
298308 ) => Effect.Effect<OrchestratorV2DispatchResult, OrchestratorV2Error>;
......@@ -4624,8 +4634,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
46244634 const source = projection.runs.find((run) => run.id === command.restartContinuationOfRunId);
46254635 if (
46264636 !source ||
4627 source.status !== "cancelled" ||
4628 isRestartNoteSource(source, projection.providerTurns) ||
4637 !isRestartContinuationSource(source, projection.providerTurns) ||
46294638 projection.thread.archivedAt !== null ||
46304639 projection.thread.deletedAt !== null ||
46314640 projection.thread.providerInstanceId !== source.providerInstanceId ||
......@@ -10147,7 +10156,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1014710156 : undefined
1014810157 : taskRuns.toSorted((a, b) => b.ordinal - a.ordinal)[0];
1014910158 if (childRun === undefined) return;
10150 const terminalStatus = delegatedTaskTerminalStatus(childRun.status);
10159 const outcome = runOutcome(childRun);
10160 const terminalStatus = delegatedTaskTerminalStatus(outcome);
1015110161 if (terminalStatus === null) {
1015210162 return;
1015310163 }
......@@ -10213,7 +10223,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1021310223 }
1021410224
1021510225 const now = yield* DateTime.now;
10216 const result = subagentResultForRun(childProjection, childRun);
10226 const result = subagentResultForRun(childProjection, { ...childRun, status: outcome });
1021710227 const parentRun =
1021810228 task.runId === null
1021910229 ? undefined
......@@ -11459,6 +11469,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1145911469 recoverDelegatedTasks,
1146011470 recoverDelegatedTask,
1146111471 delegatedTaskResultPending,
11472 restartContinuationPending: (run) =>
11473 awaitsRestartContinuation(run).pipe(Effect.orElseSucceed(() => false)),
11474 awaitRestartContinuation: (run) =>
11475 effectOutbox.awaitSettled(`effect:restart-continuation:${run.id}`).pipe(Effect.ignore),
1146211476 dispatch: dispatchWithReceipt,
1146311477 searchThreadStream: (input) =>
1146411478 projectionStore
......@@ -11607,6 +11621,8 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
1160711621 recoverDelegatedTasks: Effect.void,
1160811622 recoverDelegatedTask: () => Effect.void,
1160911623 delegatedTaskResultPending: () => Effect.succeed(false),
11624 restartContinuationPending: () => Effect.succeed(false),
11625 awaitRestartContinuation: () => Effect.void,
1161011626 dispatch: (command) =>
1161111627 Effect.fail(
1161211628 new OrchestratorDispatchError({
apps/server/src/orchestration-v2/ProjectionRecovery.test.ts+52
......@@ -209,6 +209,58 @@ it.effect.each(["startup", "shutdown"] as const)(
209209 }).pipe(Effect.provide(layerRecoveryTest)),
210210);
211211
212it.effect("stops a session its thread detached, which kept its live status", () =>
213 Effect.gen(function* () {
214 const projections = yield* ProjectionStore.ProjectionStoreV2;
215 const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService;
216 const now = yield* DateTime.now;
217 const threadId = yield* createThread("detached-session");
218 const providerSessionId = ProviderSessionId.make("session:recovery:detached");
219 yield* projections.apply({
220 id: EventId.make("event:recovery:detached-session:attached"),
221 type: "provider-session.attached",
222 threadId,
223 driver,
224 providerInstanceId,
225 occurredAt: now,
226 payload: {
227 id: providerSessionId,
228 driver,
229 providerInstanceId,
230 status: "ready",
231 cwd: "/workspace",
232 model: modelSelection.model,
233 capabilities: CodexProviderCapabilitiesV2,
234 createdAt: now,
235 updatedAt: now,
236 lastError: null,
237 },
238 });
239 // The server died before the release that should follow the detach.
240 yield* projections.apply({
241 id: EventId.make("event:recovery:detached-session:detached"),
242 type: "provider-session.detached",
243 threadId,
244 driver,
245 providerInstanceId,
246 occurredAt: now,
247 payload: { providerSessionId, detachedAt: now, reason: "Thread settled." },
248 });
249 assert.deepEqual(yield* projections.getRecoveryThreadIds("runtime"), [threadId]);
250
251 const summary = yield* recovery.reconcile("startup");
252
253 assert.equal(summary.stoppedSessions, 1);
254 const sql = yield* SqlClient.SqlClient;
255 const rows = yield* sql<{ readonly status: string }>`
256 SELECT status FROM orchestration_v2_projection_provider_sessions
257 WHERE provider_session_id = ${providerSessionId}
258 `;
259 assert.deepEqual(rows, [{ status: "stopped" }]);
260 assert.deepEqual(yield* projections.getRecoveryThreadIds("runtime"), []);
261 }).pipe(Effect.provide(layerRecoveryTest)),
262);
263
212264it.effect("selects unfinished recovery work without reading settled thread histories", () =>
213265 Effect.gen(function* () {
214266 const projections = yield* ProjectionStore.ProjectionStoreV2;
apps/server/src/orchestration-v2/ProjectionStore.ts+38-14
......@@ -3666,6 +3666,16 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
36663666 ON bindings.provider_session_id = sessions.provider_session_id
36673667 WHERE sessions.status NOT IN ('stopped', 'error')
36683668 UNION
3669 -- A detach drops the binding but keeps the session's status.
3670 SELECT sessions.thread_id
3671 FROM orchestration_v2_projection_provider_sessions AS sessions
3672 WHERE sessions.status NOT IN ('stopped', 'error')
3673 AND sessions.thread_id IS NOT NULL
3674 AND NOT EXISTS (
3675 SELECT 1 FROM orchestration_v2_projection_provider_session_bindings AS bindings
3676 WHERE bindings.provider_session_id = sessions.provider_session_id
3677 )
3678 UNION
36693679 SELECT thread_id FROM pending_provider_threads
36703680 UNION
36713681 SELECT nodes.thread_id FROM pending_provider_threads
......@@ -4123,24 +4133,38 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
41234133 ORDER BY COALESCE(subagent.started_at, ''), subagent.subagent_id ASC
41244134 `,
41254135 sql<PayloadRow>`
4126 SELECT DISTINCT session.payload_json
4136 SELECT session.payload_json
41274137 FROM orchestration_v2_projection_provider_sessions AS session
4128 JOIN orchestration_v2_projection_provider_session_bindings AS binding
4129 ON binding.provider_session_id = session.provider_session_id
4130 WHERE binding.thread_id = ${threadId}
4131 AND (
4132 session.status NOT IN ('stopped', 'error')
4133 OR session.provider_session_id IN (
4134 SELECT provider_thread.provider_session_id
4135 FROM orchestration_v2_projection_provider_threads AS provider_thread
4136 WHERE provider_thread.provider_thread_id IN (
4137 SELECT run.provider_thread_id
4138 FROM orchestration_v2_projection_runs AS run
4139 WHERE run.thread_id = ${threadId}
4140 AND run.status IN ('queued', 'preparing', 'starting', 'running', 'waiting')
4138 WHERE (
4139 session.provider_session_id IN (
4140 SELECT binding.provider_session_id
4141 FROM orchestration_v2_projection_provider_session_bindings AS binding
4142 WHERE binding.thread_id = ${threadId}
4143 )
4144 AND (
4145 session.status NOT IN ('stopped', 'error')
4146 OR session.provider_session_id IN (
4147 SELECT provider_thread.provider_session_id
4148 FROM orchestration_v2_projection_provider_threads AS provider_thread
4149 WHERE provider_thread.provider_thread_id IN (
4150 SELECT run.provider_thread_id
4151 FROM orchestration_v2_projection_runs AS run
4152 WHERE run.thread_id = ${threadId}
4153 AND run.status IN ('queued', 'preparing', 'starting', 'running', 'waiting')
4154 )
41414155 )
41424156 )
41434157 )
4158 -- This thread detached it, which keeps its status. Its process
4159 -- did not outlive the server either, so recovery stops it.
4160 OR (
4161 session.thread_id = ${threadId}
4162 AND session.status NOT IN ('stopped', 'error')
4163 AND NOT EXISTS (
4164 SELECT 1 FROM orchestration_v2_projection_provider_session_bindings AS binding
4165 WHERE binding.provider_session_id = session.provider_session_id
4166 )
4167 )
41444168 ORDER BY session.updated_at ASC, session.provider_session_id ASC
41454169 `,
41464170 sql<PayloadRow>`
apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts+26
......@@ -7,6 +7,7 @@ import {
77 type OrchestrationV2RestartCancelledBackgroundWork,
88 type OrchestrationV2Subagent,
99 type OrchestrationV2ThreadProjection,
10 isOrchestrationV2WorkActive,
1011 ThreadId,
1112} from "@t3tools/contracts";
1213import * as Cause from "effect/Cause";
......@@ -445,6 +446,31 @@ export const make = Effect.gen(function* () {
445446 });
446447 }
447448 }
449 // A blocking delegate_task wait dies with the turn that made it, and its
450 // continuation never got the tool result. Let the child's result wake
451 // the parent like an async task, so it reaches the continued turn.
452 const cutRunIds = new Set(runs.map((run) => run.id));
453 for (const task of projection.subagents ?? []) {
454 if (
455 task.origin !== "app_owned" ||
456 task.runId === null ||
457 !cutRunIds.has(task.runId) ||
458 task.completionWake !== "settled_only" ||
459 !isOrchestrationV2WorkActive(task.status)
460 )
461 continue;
462 events.push({
463 id: yield* allocateEventId(),
464 type: "subagent.updated",
465 threadId: projection.thread.id,
466 runId: task.runId,
467 nodeId: task.id,
468 driver: task.driver,
469 providerInstanceId: task.providerInstanceId,
470 occurredAt: now,
471 payload: { ...task, completionWake: "always", updatedAt: now },
472 });
473 }
448474 // Process loss also orphans background-capable turn items on already-
449475 // settled runs (e.g. post-settle Waiting work). Skip items already
450476 // cancelled above for recovered nonterminal runs to avoid duplicate
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+36
......@@ -1681,6 +1681,42 @@ it.effect("ProviderSessionManagerV2 releases live sessions when its layer shuts
16811681 }),
16821682);
16831683
1684it.effect("ProviderSessionManagerV2 releases sessions in parallel on server shutdown", () =>
1685 Effect.gen(function* () {
1686 const state = yield* Ref.make(emptyState);
1687 yield* Effect.gen(function* () {
1688 const eventSink = yield* EventSink.EventSinkV2;
1689 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1690 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
1691 const now = yield* DateTime.now;
1692 for (const name of ["a", "b", "c"]) {
1693 const threadId = ThreadId.make(`thread-provider-session-manager-parallel-shutdown-${name}`);
1694 yield* eventSink.write({
1695 events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
1696 });
1697 yield* manager.open({
1698 threadId,
1699 providerSessionId: yield* idAllocator.allocate.providerSession({
1700 providerInstanceId: modelSelection.instanceId,
1701 threadId,
1702 }),
1703 modelSelection,
1704 runtimePolicy,
1705 });
1706 }
1707
1708 // Every close hangs until its time box ends. One time box must cover all
1709 // three sessions, or a busy server outlives the launcher's kill timer.
1710 const stopping = yield* manager.shutdown.pipe(Effect.forkChild);
1711 yield* TestClock.adjust("30 seconds");
1712 yield* Effect.yieldNow;
1713 assert.isDefined(stopping.pollUnsafe());
1714 }).pipe(
1715 Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, hangSessionScopeClose: true })),
1716 );
1717 }),
1718);
1719
16841720it.effect("ProviderSessionManagerV2 closes event subscriptions normally on server shutdown", () =>
16851721 Effect.gen(function* () {
16861722 const state = yield* Ref.make(emptyState);
apps/server/src/orchestration-v2/ProviderSessionManager.ts+3-3
......@@ -1999,8 +1999,8 @@ export const layerWithOptions = (
19991999 // Parent of every session scope. On layer close, shutdown releases the
20002000 // live sessions first, then closes any session whose open is still in
20012001 // flight, time-boxed so a stuck adapter cannot hold up server shutdown.
2002 // Parallel, so one session whose close hangs does not stop the rest from
2003 // closing within the time box.
2002 // Both steps run in parallel. The service launcher kills the server
2003 // 5 s after it asks it to stop, and a busy server can hold 15 sessions.
20042004 const sessionScopes = yield* Scope.make("parallel");
20052005 const shutdown = Effect.gen(function* () {
20062006 const activeSessions = [...(yield* Ref.get(sessions)).values()];
......@@ -2018,7 +2018,7 @@ export const layerWithOptions = (
20182018 }),
20192019 ),
20202020 ),
2021 { discard: true },
2021 { concurrency: "unbounded", discard: true },
20222022 );
20232023 });
20242024 yield* Effect.addFinalizer(() =>
apps/server/src/orchestration-v2/RestartBackgroundNote.ts+38
......@@ -123,6 +123,44 @@ export function isRestartNoteSource(
123123 );
124124}
125125
126/**
127 * Work an agent ends its turn to wait for: a monitor, a subagent, or a
128 * background task. A restart that cancels it wakes the settled turn, because
129 * nothing else will. A shell does not wake it: most are dev servers that
130 * nobody waits on, so their note waits for the next user message.
131 */
132export function isAwaitedWork(work: Pick<Work, "kind">): boolean {
133 return work.kind !== "shell";
134}
135
136/**
137 * A run's status as its waiter should read it: a delegating parent,
138 * `task_status`, or `t3_thread_wait`. A turn that ended to wait on work the
139 * restart then cancelled did not finish, so it is "interrupted", not
140 * "completed", and its last message is not a final result.
141 */
142export function runOutcome(
143 run: Pick<OrchestrationV2Run, "status" | "restartCancelledBackgroundWork">,
144): OrchestrationV2Run["status"] {
145 return (run.status === "completed" || run.status === "cancelled") &&
146 (run.restartCancelledBackgroundWork ?? []).some(isAwaitedWork)
147 ? "interrupted"
148 : run.status;
149}
150
151/**
152 * Whether a restart continuation may prompt from `source`: a turn the restart
153 * cut mid-way, or a settled turn that lost awaited work in the restart.
154 */
155export function isRestartContinuationSource(
156 source: Pick<OrchestrationV2Run, "status" | "activeAttemptId" | "restartCancelledBackgroundWork">,
157 providerTurns: ReadonlyArray<Pick<ProviderTurnState, "runAttemptId" | "status">>,
158): boolean {
159 return isRestartNoteSource(source, providerTurns)
160 ? (source.restartCancelledBackgroundWork ?? []).some(isAwaitedWork)
161 : source.status === "cancelled";
162}
163
126164/**
127165 * The note a restart continuation of `source` carries, and whether the turn it
128166 * continues had settled (the note is then the whole prompt). A continuation cut
apps/server/src/orchestration-v2/RestartContinuation.test.ts+191-40
......@@ -223,6 +223,48 @@ it.effect.each(["completed", "waiting", "cancelled"] as const)(
223223 }),
224224);
225225
226it.effect.each(["completed", "waiting", "cancelled"] as const)(
227 "wakes a %s run whose monitor the restart cancelled, with the note as its prompt",
228 (status) =>
229 Effect.gen(function* () {
230 const base = makeProjection();
231 const work = [
232 { kind: "monitor" as const, label: "Watch CI run 42" },
233 { kind: "shell" as const, label: "vp run dev" },
234 ];
235 const projection = {
236 ...base,
237 runs: [{ ...base.runs[0]!, status, restartCancelledBackgroundWork: work }],
238 providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }],
239 } as unknown as OrchestrationV2ThreadProjection;
240 const commands: Parameters<
241 ThreadManagementService.ThreadManagementService["Service"]["dispatch"]
242 >[0][] = [];
243 yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe(
244 Effect.provide(
245 Layer.merge(
246 Layer.mock(ThreadManagementService.ThreadManagementService)({
247 getThreadRecords: () => Effect.succeed(projection),
248 recoverDelegatedTask: () => Effect.void,
249 dispatch: (command) => {
250 commands.push(command);
251 return Effect.succeed({} as never);
252 },
253 }),
254 ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }),
255 ),
256 ),
257 );
258 assert.lengthOf(commands, 1);
259 const command = commands[0]!;
260 assert.equal(command.type, "message.dispatch");
261 if (command.type !== "message.dispatch") return;
262 assert.equal(command.restartContinuationOfRunId, runId);
263 assert.include(command.text, "- monitor: Watch CI run 42");
264 assert.include(command.text, "- shell: vp run dev");
265 }),
266);
267
226268it.effect("does not continue a failed run that lost background work", () =>
227269 Effect.gen(function* () {
228270 const base = makeProjection();
......@@ -319,6 +361,62 @@ it.effect.each([
319361 }),
320362);
321363
364it.effect("lets a cut blocking delegate_task wake its parent like an async task", () =>
365 Effect.gen(function* () {
366 const base = makeProjection();
367 const task = (id: string, completionWake: "settled_only" | "always", status: string) => ({
368 id: NodeId.make(id),
369 threadId,
370 runId,
371 origin: "app_owned",
372 childThreadId: ThreadId.make(`thread:${id}`),
373 driver,
374 providerInstanceId: instanceId,
375 completionWake,
376 status,
377 });
378 const projection = {
379 ...base,
380 subagents: [
381 task("task:waited", "settled_only", "running"),
382 task("task:async", "always", "running"),
383 task("task:finished", "settled_only", "completed"),
384 ],
385 } as unknown as OrchestrationV2ThreadProjection;
386 let committed: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0] | undefined;
387 const recovery = yield* ProviderRuntimeRecovery.make.pipe(
388 Effect.provide(
389 Layer.mergeAll(
390 ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }),
391 Layer.mock(ProjectionStore.ProjectionStoreV2)({
392 getRecoveryThreadIds: () => Effect.succeed([threadId]),
393 getRuntimeRecoveryProjection: () => Effect.succeed(projection),
394 }),
395 Layer.mock(EventSink.EventSinkV2)({
396 commitCommand: (input) => {
397 committed = input;
398 return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never);
399 },
400 }),
401 IdAllocator.layer,
402 Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({}),
403 Layer.mock(EffectOutbox.EffectOutboxV2)({
404 reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }),
405 }),
406 ),
407 ),
408 );
409 yield* recovery.reconcile("startup");
410 const upgraded = (committed?.events ?? []).flatMap((event) =>
411 event.type === "subagent.updated" ? [event.payload] : [],
412 );
413 assert.deepEqual(
414 upgraded.map((row) => ({ id: row.id, completionWake: row.completionWake })),
415 [{ id: NodeId.make("task:waited"), completionWake: "always" }],
416 );
417 }),
418);
419
322420it.effect("does not duplicate delivery and yields to newer user work or opt-out", () =>
323421 Effect.gen(function* () {
324422 let projection = makeProjection();
......@@ -424,7 +522,7 @@ it.effect("prepares no continuation for background work another provider thread
424522 );
425523 yield* recovery.prepareForShutdown;
426524 assert.lengthOf(writes, 0);
427 // Work on the settled run's own provider thread must not wake it either.
525 // The same work on the settled run's own provider thread does wake it.
428526 const ownWork = {
429527 ...projection,
430528 turnItems: [{ ...projection.turnItems[0]!, providerThreadId }],
......@@ -451,18 +549,54 @@ it.effect("prepares no continuation for background work another provider thread
451549 ),
452550 );
453551 yield* ownRecovery.prepareForShutdown;
454 assert.lengthOf(writes, 0);
552 assert.lengthOf(writes, 1);
553 assert.deepEqual(writes[0]!.effects[0]?.request, {
554 type: "provider-runtime.continue",
555 sourceRunId: runId,
556 });
455557 }),
456558);
457559
560const backgroundSubagent = {
561 turnItem: {
562 id: "turn-item:background-subagent",
563 runId,
564 nodeId: null,
565 providerThreadId,
566 providerInstanceId: instanceId,
567 type: "subagent",
568 subagentId: "subagent:background",
569 title: "Background reviewer",
570 status: "running",
571 },
572 work: {
573 kind: "subagent" as const,
574 label: "Background reviewer",
575 id: "turn-item:background-subagent",
576 },
577};
578const devServer = {
579 turnItem: {
580 id: "turn-item:dev-server",
581 runId,
582 nodeId: null,
583 providerThreadId,
584 providerInstanceId: instanceId,
585 type: "command_execution",
586 input: "vp run dev",
587 status: "running",
588 },
589 work: { kind: "shell" as const, label: "vp run dev", id: "turn-item:dev-server" },
590};
591
458592it.effect.each([
459 ["completed", scratchProjectId],
460 ["completed", ProjectId.make("restart-project")],
461 ["waiting", scratchProjectId],
462 ["waiting", ProjectId.make("restart-project")],
593 ["completed", scratchProjectId, backgroundSubagent, true],
594 ["waiting", ProjectId.make("restart-project"), backgroundSubagent, true],
595 ["completed", ProjectId.make("restart-project"), devServer, false],
596 ["waiting", scratchProjectId, devServer, false],
463597] as const)(
464 "cleans up background work without waking a %s run in project %s",
465 ([status, projectId]) =>
598 "cleans up background work of a %s run in project %s and wakes it only for awaited work",
599 ([status, projectId, background, wakes]) =>
466600 Effect.gen(function* () {
467601 const base = makeProjection();
468602 const projection = {
......@@ -470,29 +604,20 @@ it.effect.each([
470604 thread: { ...base.thread, projectId },
471605 runs: [{ ...base.runs[0]!, status }],
472606 providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }],
473 turnItems: [
474 {
475 id: "turn-item:background-subagent",
476 runId,
477 nodeId: null,
478 providerThreadId,
479 providerInstanceId: instanceId,
480 type: "subagent",
481 subagentId: "subagent:background",
482 title: "Background reviewer",
483 status: "running",
484 },
485 ],
486 subagents: [
487 {
488 id: "subagent:background",
489 runId,
490 origin: "provider_native",
491 driver,
492 providerInstanceId: instanceId,
493 status: "running",
494 },
495 ],
607 turnItems: [background.turnItem],
608 subagents:
609 background === backgroundSubagent
610 ? [
611 {
612 id: "subagent:background",
613 runId,
614 origin: "provider_native",
615 driver,
616 providerInstanceId: instanceId,
617 status: "running",
618 },
619 ]
620 : [],
496621 } as unknown as OrchestrationV2ThreadProjection;
497622 for (const trigger of ["startup", "shutdown"] as const) {
498623 const commits: Parameters<EventSink.EventSinkV2["Service"]["commitCommand"]>[0][] = [];
......@@ -530,25 +655,51 @@ it.effect.each([
530655 );
531656 if (trigger === "shutdown") yield* recovery.prepareForShutdown;
532657 yield* recovery.reconcile(trigger);
533 assert.lengthOf(writes, 0);
658 // Shutdown records the wake before it cancels the work; a startup
659 // after a kill records it with the cancellation.
660 const recorded = [...writes.flatMap((write) => write.effects), ...commits[0]!.effects];
661 assert.deepEqual(
662 recorded.map((effect) => effect.request),
663 wakes ? [{ type: "provider-runtime.continue", sourceRunId: runId }] : [],
664 );
534665 assert.lengthOf(commits, 1);
535 assert.lengthOf(commits[0]!.effects, 0);
536666 const events = commits[0]!.events;
537 for (const type of ["turn-item.updated", "subagent.updated"] as const)
538 assert.isTrue(
539 events.some((event) => event.type === type && event.payload.status === "cancelled"),
540 );
667 assert.isTrue(
668 events.some(
669 (event) => event.type === "turn-item.updated" && event.payload.status === "cancelled",
670 ),
671 );
541672 const note = events.find((event) => event.type === "run.background-work-cancelled");
542673 assert.equal(note?.runId, runId);
543 assert.deepEqual(note?.payload.restartCancelledBackgroundWork, [
544 { kind: "subagent", label: "Background reviewer", id: "turn-item:background-subagent" },
545 ]);
674 assert.deepEqual(note?.payload.restartCancelledBackgroundWork, [background.work]);
546675 if (status === "completed")
547676 assert.isFalse(events.some((event) => event.type === "run.updated"));
548677 }
549678 }),
550679);
551680
681it("does not wake a settled run for work left by a session that already stopped", () => {
682 const base = makeProjection();
683 const projection = {
684 ...base,
685 runs: [{ ...base.runs[0]!, status: "completed" }],
686 providerThreads: [{ ...base.providerThreads[0]!, status: "idle" }],
687 turnItems: [backgroundSubagent.turnItem],
688 } as unknown as OrchestrationV2ThreadProjection;
689 assert.equal(restartContinuationRun(projection, now)?.id, runId);
690 for (const status of ["stopped", "error"])
691 assert.isUndefined(
692 restartContinuationRun(
693 {
694 ...projection,
695 providerSessions: [{ ...projection.providerSessions[0]!, status }],
696 } as OrchestrationV2ThreadProjection,
697 now,
698 ),
699 status,
700 );
701});
702
552703it.effect("does not cancel or resume a run that completes while shutdown intent commits", () =>
553704 Effect.gen(function* () {
554705 let projection = makeProjection();
apps/server/src/orchestration-v2/RestartContinuation.ts+56-12
......@@ -16,6 +16,10 @@ import * as ServerSettings from "../serverSettings.ts";
1616import { isNativeMaintenanceCommand } from "./Orchestrator.ts";
1717import * as ThreadManagementService from "./ThreadManagementService.ts";
1818import {
19 cancelledRosterTaskWork,
20 cancelledTurnItemWork,
21 isAwaitedWork,
22 isRestartContinuationSource,
1923 isRestartNoteSource,
2024 restartCancelledBackgroundWorkNote,
2125 restartContinuationNote,
......@@ -29,7 +33,9 @@ const MAX_CONTINUATION_CHAIN = 3;
2933/**
3034 * Whether a restart must leave this run for the user: the thread is put away,
3135 * the provider turn already ended, or the user owes an answer. Checked when
32 * intent is recorded and again when the continuation is delivered.
36 * intent is recorded and again when the continuation is delivered. A
37 * `settled` run is woken for the awaited work it lost, so its ended provider
38 * turn does not block it.
3339 */
3440function restartContinuationBlocked(
3541 projection: Pick<
......@@ -38,6 +44,7 @@ function restartContinuationBlocked(
3844 >,
3945 source: OrchestrationV2Run,
4046 now: DateTime.Utc,
47 settled: boolean,
4148): boolean {
4249 const { thread } = projection;
4350 if (
......@@ -49,6 +56,7 @@ function restartContinuationBlocked(
4956 return true;
5057 const turn = latestProviderTurnForAttempt(projection.providerTurns, source.activeAttemptId);
5158 if (
59 !settled &&
5260 turn !== undefined &&
5361 (turn.status === "completed" || turn.status === "failed" || turn.status === "interrupted")
5462 )
......@@ -82,8 +90,38 @@ function restartContinuationBlocked(
8290}
8391
8492/**
85 * Resume only an unfinished root turn. Leftover background work is cleaned up
86 * separately and reported on the next user turn; it must not wake a settled run.
93 * Open awaited work (see `isAwaitedWork`) on a provider thread. App-owned
94 * delegations are not counted: their own threads continue and report back.
95 */
96function hasOpenAwaitedWork(
97 projection: Pick<ProjectionRuntimeRecoveryState, "runs" | "turnItems">,
98 providerThread: ProjectionRuntimeRecoveryState["providerThreads"][number],
99): boolean {
100 const openItem = projection.turnItems.some((item) => {
101 if (item.status !== "pending" && item.status !== "running" && item.status !== "waiting")
102 return false;
103 if (item.type === "subagent" && item.origin === "app_owned" && item.childThreadId !== null)
104 return false;
105 const work = cancelledTurnItemWork(item);
106 const providerThreadId =
107 item.providerThreadId ??
108 projection.runs.find((run) => run.id === item.runId)?.providerThreadId;
109 return work !== undefined && isAwaitedWork(work) && providerThreadId === providerThread.id;
110 });
111 return (
112 openItem ||
113 (providerThread.ownerNodeId === null &&
114 (providerThread.pendingBackgroundTasks ?? []).some((task) =>
115 isAwaitedWork(cancelledRosterTaskWork(task)),
116 ))
117 );
118}
119
120/**
121 * The run a restart continues: an unfinished root turn, or a settled one
122 * waiting on awaited work that the restart cancels. A settled run needs a
123 * live session, so stale records from an older crash never wake it. Other
124 * leftover work is cleaned up and reported on the next user turn.
87125 */
88126export function restartContinuationRun(
89127 projection: Pick<
......@@ -109,9 +147,10 @@ export function restartContinuationRun(
109147 if (!run) return;
110148 const preparedContinuation =
111149 run.status === "starting" && run.restartContinuationOfRunId !== undefined;
112 if (run.status !== "running" && !preparedContinuation) return;
113 if (restartContinuationBlocked(projection, run, now)) return;
114 const liveTurnRequired = !preparedContinuation;
150 const settled = run.status === "completed" || run.status === "waiting";
151 if (run.status !== "running" && !preparedContinuation && !settled) return;
152 if (restartContinuationBlocked(projection, run, now, settled)) return;
153 const liveTurnRequired = !preparedContinuation && !settled;
115154 if (projection.thread.providerInstanceId !== run.providerInstanceId) return;
116155 const providerThread = projection.providerThreads.find(
117156 (thread) => thread.id === run.providerThreadId,
......@@ -138,9 +177,10 @@ export function restartContinuationRun(
138177 session === undefined ||
139178 session.providerInstanceId !== run.providerInstanceId ||
140179 session.driver !== providerThread.driver ||
141 (liveTurnRequired && (session.status === "stopped" || session.status === "error"))
180 (!preparedContinuation && (session.status === "stopped" || session.status === "error"))
142181 )
143182 return;
183 if (settled) return hasOpenAwaitedWork(projection, providerThread) ? run : undefined;
144184 if (
145185 liveTurnRequired &&
146186 !projection.providerTurns.some(
......@@ -173,13 +213,17 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
173213 return;
174214 if (projection.messages.some((message) => message.id === messageId)) return;
175215 const source = projection.runs.find((run) => run.id === input.sourceRunId);
176 // Pending effects from older versions may target settled background work,
177 // including waiting runs that reconciliation subsequently cancelled.
216 // Reconciliation cancels a waiting run it cannot checkpoint, so a settled
217 // source can be cancelled too. It continues only for awaited work.
178218 if (
179219 !source ||
180 source.status !== "cancelled" ||
181 isRestartNoteSource(source, projection.providerTurns) ||
182 restartContinuationBlocked(projection, source, yield* DateTime.now)
220 !isRestartContinuationSource(source, projection.providerTurns) ||
221 restartContinuationBlocked(
222 projection,
223 source,
224 yield* DateTime.now,
225 isRestartNoteSource(source, projection.providerTurns),
226 )
183227 )
184228 return;
185229 // A user submission after reconciliation takes precedence over an automatic
apps/server/src/orchestration-v2/ThreadManagementService.test.ts+123
......@@ -597,6 +597,7 @@ it.effect.each([
597597 Layer.provide(
598598 Layer.mock(Orchestrator.OrchestratorV2)({
599599 getThreadEventSequence: () => Effect.succeed(0),
600 restartContinuationPending: () => Effect.succeed(false),
600601 // No run update arrives, so the timeout path runs while a final
601602 // projection read can still observe a terminal run.
602603 streamStoredEventsFrom: () =>
......@@ -666,6 +667,7 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
666667 Layer.provide(
667668 Layer.mock(Orchestrator.OrchestratorV2)({
668669 getThreadEventSequence: () => Effect.succeed(0),
670 restartContinuationPending: () => Effect.succeed(false),
669671 // Only the run.updated stream carries events in this test.
670672 streamStoredEventsFrom: (input) =>
671673 input?.eventType === "run.updated"
......@@ -705,6 +707,126 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
705707 }),
706708);
707709
710it.effect("waitForThread follows a restart continuation instead of the cut run", () =>
711 Effect.gen(function* () {
712 const projectId = ProjectId.make("project:thread-management:wait-restart");
713 const threadId = ThreadId.make("thread:thread-management:wait-restart");
714 const runId = RunId.make("run:thread-management:wait-restart");
715 const continuationId = RunId.make("run:thread-management:wait-restart:2");
716 const subscribed = yield* Deferred.make<void>();
717 const events = yield* Queue.unbounded<OrchestrationV2StoredEvent>();
718 // The restart cut the run; its continuation effect has not started yet.
719 let runs: Array<{
720 id: RunId;
721 status: OrchestrationV2Run["status"];
722 restartContinuationOfRunId?: RunId;
723 }> = [{ id: runId, status: "cancelled" }];
724 let pending = true;
725 const stored = (sequence: number, event: object) =>
726 ({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent;
727 const layerTest = ThreadManagementService.layer.pipe(
728 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
729 Layer.provide(
730 Layer.mock(Orchestrator.OrchestratorV2)({
731 getThreadEventSequence: () => Effect.succeed(0),
732 restartContinuationPending: (run) => Effect.succeed(pending && run.id === runId),
733 awaitRestartContinuation: () => Effect.never,
734 streamStoredEventsFrom: (input) =>
735 input?.eventType === "run.updated"
736 ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe(
737 Stream.drain,
738 Stream.concat(Stream.fromQueue(events)),
739 )
740 : Stream.never,
741 getThreadRecords: () =>
742 Effect.sync(
743 () =>
744 ({
745 thread: { id: threadId, projectId, deletedAt: null },
746 runs,
747 }) as unknown as OrchestrationV2ThreadProjection,
748 ),
749 }),
750 ),
751 Layer.provide(ServerSettings.layerTest()),
752 Layer.provide(layerReceiptsTest),
753 );
754 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
755 Effect.provide(layerTest),
756 );
757 const fiber = yield* service
758 .waitForThread({ projectId, threadId, runId, timeoutMs: 60 * 60 * 1_000 })
759 .pipe(Effect.forkChild);
760 yield* Deferred.await(subscribed);
761 const continuation = { id: continuationId, restartContinuationOfRunId: runId };
762 runs = [...runs, { ...continuation, status: "running" }];
763 pending = false;
764 yield* Queue.offer(
765 events,
766 stored(1, { type: "run.updated", payload: { ...continuation, status: "running" } }),
767 );
768 runs = [runs[0]!, { ...continuation, status: "completed" }];
769 yield* Queue.offer(
770 events,
771 stored(2, { type: "run.updated", payload: { ...continuation, status: "completed" } }),
772 );
773 const result = yield* Fiber.join(fiber);
774
775 expect(result).toMatchObject({
776 timedOut: false,
777 run: { id: continuationId, status: "completed" },
778 });
779 }),
780);
781
782it.effect("waitForThread returns the cut run once its continuation is declined", () =>
783 Effect.gen(function* () {
784 const projectId = ProjectId.make("project:thread-management:wait-declined");
785 const threadId = ThreadId.make("thread:thread-management:wait-declined");
786 const runId = RunId.make("run:thread-management:wait-declined");
787 const subscribed = yield* Deferred.make<void>();
788 const declined = yield* Deferred.make<void>();
789 let pending = true;
790 const layerTest = ThreadManagementService.layer.pipe(
791 Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})),
792 Layer.provide(
793 Layer.mock(Orchestrator.OrchestratorV2)({
794 getThreadEventSequence: () => Effect.succeed(0),
795 restartContinuationPending: () => Effect.succeed(pending),
796 awaitRestartContinuation: () => Deferred.await(declined),
797 // Declining records no thread event.
798 streamStoredEventsFrom: (input) =>
799 input?.eventType === "run.updated"
800 ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe(
801 Stream.drain,
802 Stream.concat(Stream.never),
803 )
804 : Stream.never,
805 getThreadRecords: () =>
806 Effect.succeed({
807 thread: { id: threadId, projectId, deletedAt: null },
808 runs: [{ id: runId, status: "cancelled" }],
809 } as unknown as OrchestrationV2ThreadProjection),
810 }),
811 ),
812 Layer.provide(ServerSettings.layerTest()),
813 Layer.provide(layerReceiptsTest),
814 );
815 const service = yield* ThreadManagementService.ThreadManagementService.pipe(
816 Effect.provide(layerTest),
817 );
818 const fiber = yield* service
819 .waitForThread({ projectId, threadId, runId, timeoutMs: 60 * 60 * 1_000 })
820 .pipe(Effect.forkChild);
821 yield* Deferred.await(subscribed);
822 pending = false;
823 yield* Deferred.succeed(declined, undefined);
824 const result = yield* Fiber.join(fiber);
825
826 expect(result).toMatchObject({ timedOut: false, run: { id: runId, status: "cancelled" } });
827 }),
828);
829
708830it.effect.each([
709831 "running",
710832 "missing",
......@@ -938,6 +1060,7 @@ it.effect.each([
9381060 Layer.provide(
9391061 Layer.mock(Orchestrator.OrchestratorV2)({
9401062 getThreadEventSequence: () => Effect.succeed(0),
1063 restartContinuationPending: () => Effect.succeed(false),
9411064 getThreadRecords: () =>
9421065 Effect.succeed({
9431066 thread: { id: threadId, projectId, deletedAt: null },
apps/server/src/orchestration-v2/ThreadManagementService.ts+68-43
......@@ -486,6 +486,20 @@ export function latestRun(
486486 return projection.runs.toSorted((left, right) => right.ordinal - left.ordinal)[0];
487487}
488488
489/** The newest run in `run`'s restart continuation chain, or `run` itself. */
490function latestRestartContinuation(
491 runs: ReadonlyArray<OrchestrationV2Run>,
492 run: OrchestrationV2Run,
493): OrchestrationV2Run {
494 let current = run;
495 for (let hops = 0; hops < runs.length; hops++) {
496 const next = runs.find((candidate) => candidate.restartContinuationOfRunId === current.id);
497 if (next === undefined) break;
498 current = next;
499 }
500 return current;
501}
502
489503export function latestActiveRun(
490504 projection: Pick<OrchestrationV2ThreadProjection, "runs">,
491505): OrchestrationV2Run | undefined {
......@@ -844,65 +858,76 @@ const make = Effect.gen(function* () {
844858 if (selectedRun === undefined) {
845859 return { threadId: input.threadId, run: null, timedOut: false };
846860 }
847 if (isTerminalRunStatus(selectedRun.status)) {
848 return { threadId: input.threadId, run: selectedRun, timedOut: false };
861 // A restart cuts a run and continues it as a new run. Follow that chain,
862 // so a cut run is not the answer while its continuation is pending.
863 const outcome = (runs: ReadonlyArray<OrchestrationV2Run>) =>
864 Effect.gen(function* () {
865 const current = runs.find((candidate) => candidate.id === selectedRun.id);
866 if (current === undefined) {
867 return yield* new ThreadManagementRunNotFoundError({
868 threadId: input.threadId,
869 runId: selectedRun.id,
870 });
871 }
872 const run = latestRestartContinuation(runs, current);
873 const pending =
874 isTerminalRunStatus(run.status) &&
875 (yield* orchestrator.restartContinuationPending(run));
876 return { run, pending, settled: isTerminalRunStatus(run.status) && !pending };
877 });
878 const initial = yield* outcome(target.runs);
879 if (initial.settled) {
880 return { threadId: input.threadId, run: initial.run, timedOut: false };
849881 }
850882
851 // Re-read the run only when the thread records a run update or its
852 // deletion, instead of polling the projection for up to an hour. Each
853 // stream keeps one event type, so transcript events never fill its buffer.
883 // Re-read the runs only when the thread records an update to the waited
884 // run or a continuation, or its deletion, instead of polling the
885 // projection for up to an hour. Each stream keeps one event type, so
886 // transcript events never fill its buffer. A declined continuation
887 // records no event, so its effect settling wakes the wait too.
854888 const wait = Stream.merge(
855 orchestrator.streamStoredEventsFrom({
856 threadId: input.threadId,
857 afterSequence,
858 eventType: "run.updated",
859 }),
860 orchestrator.streamStoredEventsFrom({
861 threadId: input.threadId,
862 afterSequence,
863 eventType: "thread.deleted",
864 }),
865 ).pipe(
866 Stream.mapError(loadError),
867 Stream.filter(
868 (stored) =>
869 stored.event.type !== "run.updated" || stored.event.payload.id === selectedRun.id,
889 Stream.merge(
890 orchestrator.streamStoredEventsFrom({
891 threadId: input.threadId,
892 afterSequence,
893 eventType: "run.updated",
894 }),
895 orchestrator.streamStoredEventsFrom({
896 threadId: input.threadId,
897 afterSequence,
898 eventType: "thread.deleted",
899 }),
900 ).pipe(
901 Stream.mapError(loadError),
902 Stream.filter(
903 (stored) =>
904 stored.event.type !== "run.updated" ||
905 stored.event.payload.id === selectedRun.id ||
906 stored.event.payload.restartContinuationOfRunId !== undefined,
907 ),
870908 ),
909 initial.pending
910 ? Stream.fromEffect(orchestrator.awaitRestartContinuation(initial.run))
911 : Stream.empty,
912 ).pipe(
871913 Stream.mapEffect(() =>
872 getProjectThreadRecords(input, ["runs"], { runIds: [selectedRun.id] }).pipe(
873 Effect.flatMap((current) => {
874 const run = current.runs.find((candidate) => candidate.id === selectedRun.id);
875 return run === undefined
876 ? Effect.fail(
877 new ThreadManagementRunNotFoundError({
878 threadId: input.threadId,
879 runId: selectedRun.id,
880 }),
881 )
882 : Effect.succeed(run);
883 }),
914 getProjectThreadRecords(input, ["runs"]).pipe(
915 Effect.flatMap((current) => outcome(current.runs)),
884916 ),
885917 ),
886 Stream.filter((run) => isTerminalRunStatus(run.status)),
918 Stream.filter((current) => current.settled),
887919 Stream.runHead,
888920 Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs))),
889921 );
890922 const waited = Option.flatten(yield* wait);
891923 if (Option.isSome(waited)) {
892 return { threadId: input.threadId, run: waited.value, timedOut: false };
893 }
894 const current = yield* getProjectThreadRecords(input, ["runs"], { runIds: [selectedRun.id] });
895 const run = current.runs.find((candidate) => candidate.id === selectedRun.id);
896 if (run === undefined) {
897 return yield* new ThreadManagementRunNotFoundError({
898 threadId: input.threadId,
899 runId: selectedRun.id,
900 });
924 return { threadId: input.threadId, run: waited.value.run, timedOut: false };
901925 }
902926 // The run may have reached a terminal status while the timeout was
903927 // winning the race; the final projection read decides what actually
904928 // happened, so only a still-active run counts as timed out.
905 return { threadId: input.threadId, run, timedOut: !isTerminalRunStatus(run.status) };
929 const final = yield* outcome((yield* getProjectThreadRecords(input, ["runs"])).runs);
930 return { threadId: input.threadId, run: final.run, timedOut: !final.settled };
906931 });
907932
908933 const settleAfterRun: ThreadManagementServiceShape["settleAfterRun"] = Effect.fn(
apps/server/src/orchestration-v2/runtimeLayer.test.ts+27-14
......@@ -3251,18 +3251,28 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
32513251 }),
32523252 );
32533253
3254 it.effect.each(["failed", "completed", "waiting", "cancelled"] as const)(
3255 "does not admit a restart continuation of a %s run with only lost background work",
3256 (status) =>
3254 it.effect.each([
3255 ["failed", "shell", false],
3256 ["completed", "shell", false],
3257 ["waiting", "shell", false],
3258 ["cancelled", "shell", false],
3259 // A settled run lost work it was waiting on, so nothing else wakes it.
3260 ["failed", "monitor", false],
3261 ["completed", "monitor", true],
3262 ["waiting", "monitor", true],
3263 ["cancelled", "monitor", true],
3264 ] as const)(
3265 "admits a restart continuation of a %s run that lost a %s: %s",
3266 ([status, kind, admitted]) =>
32573267 Effect.gen(function* () {
32583268 const orchestrator = yield* Orchestrator.OrchestratorV2;
32593269 const eventSink = yield* EventSink.EventSinkV2;
3260 const threadId = ThreadId.make(`runtime-layer-restart-${status}-source`);
3270 const threadId = ThreadId.make(`runtime-layer-restart-${status}-${kind}-source`);
32613271 yield* orchestrator.dispatch({
32623272 type: "thread.create",
32633273 createdBy: "user",
32643274 creationSource: "web",
3265 commandId: CommandId.make(`restart-${status}-create`),
3275 commandId: CommandId.make(`restart-${status}-${kind}-create`),
32663276 threadId,
32673277 projectId: ProjectId.make("restart-project"),
32683278 title: "Restart",
......@@ -3276,9 +3286,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
32763286 type: "message.dispatch",
32773287 createdBy: "user",
32783288 creationSource: "web",
3279 commandId: CommandId.make(`restart-${status}-user-message`),
3289 commandId: CommandId.make(`restart-${status}-${kind}-user-message`),
32803290 threadId,
3281 messageId: MessageId.make(`restart-${status}-user-message`),
3291 messageId: MessageId.make(`restart-${status}-${kind}-user-message`),
32823292 text: "Original work",
32833293 attachments: [],
32843294 modelSelection,
......@@ -3287,13 +3297,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
32873297 const original = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!;
32883298 const now = yield* DateTime.now;
32893299 yield* eventSink.commitCommand({
3290 commandId: CommandId.make(`restart-${status}-reconcile`),
3300 commandId: CommandId.make(`restart-${status}-${kind}-reconcile`),
32913301 threadId,
32923302 commandType: "provider-runtime.reconcile",
32933303 acceptedAt: now,
32943304 events: [
32953305 {
3296 id: EventId.make(`restart-${status}-run`),
3306 id: EventId.make(`restart-${status}-${kind}-run`),
32973307 type: "run.updated",
32983308 threadId,
32993309 runId: original.id,
......@@ -3301,14 +3311,14 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
33013311 payload: { ...original, status, completedAt: now },
33023312 },
33033313 {
3304 id: EventId.make(`restart-${status}-work`),
3314 id: EventId.make(`restart-${status}-${kind}-work`),
33053315 type: "run.background-work-cancelled",
33063316 threadId,
33073317 runId: original.id,
33083318 occurredAt: now,
33093319 payload: {
33103320 runId: original.id,
3311 restartCancelledBackgroundWork: [{ kind: "shell", label: "sleep 25" }],
3321 restartCancelledBackgroundWork: [{ kind, label: "sleep 25" }],
33123322 },
33133323 },
33143324 ],
......@@ -3318,9 +3328,9 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
33183328 type: "message.dispatch",
33193329 createdBy: "agent",
33203330 creationSource: "server",
3321 commandId: CommandId.make(`restart-${status}-continuation`),
3331 commandId: CommandId.make(`restart-${status}-${kind}-continuation`),
33223332 threadId,
3323 messageId: MessageId.make(`restart-${status}-continuation`),
3333 messageId: MessageId.make(`restart-${status}-${kind}-continuation`),
33243334 text: "Note: the T3 server restarted.",
33253335 attachments: [],
33263336 modelSelection,
......@@ -3328,8 +3338,11 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
33283338 restartContinuationOfRunId: original.id,
33293339 });
33303340 const projection = yield* orchestrator.getThreadProjection(threadId);
3331 assert.lengthOf(projection.runs, 1);
33323341 assert.equal(projection.runs[0]?.status, status);
3342 assert.deepEqual(
3343 projection.runs.slice(1).map((run) => run.restartContinuationOfRunId),
3344 admitted ? [original.id] : [],
3345 );
33333346 }),
33343347 );
33353348
apps/server/src/process/agentScope.test.ts+61-4
......@@ -7,10 +7,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
77import { describe, expect, it } from "@effect/vitest";
88import * as HostProcess from "@t3tools/shared/HostProcess";
99import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
1011import { ChildProcessSpawner } from "effect/process";
1112
13import * as ServerConfig from "../config.ts";
1214import * as ProcessRunner from "../processRunner.ts";
13import { agentSliceMemoryLimits, classifyScope, make, type ScopeState } from "./agentScope.ts";
15import {
16 agentSliceMemoryLimits,
17 classifyScope,
18 make,
19 ownedAgentSlice,
20 type ScopeState,
21} from "./agentScope.ts";
1422
1523const GiB = 1024 ** 3;
1624
......@@ -55,11 +63,14 @@ describe("agentSliceMemoryLimits", () => {
5563});
5664
5765// Runs the real service against a fake user manager. `units` holds the
58// ActiveState and Result that `systemctl show` reports for each scope.
66// ActiveState and Result that `systemctl show` reports for each scope or
67// slice, and `calls` records every command in order.
5968const withFakeSystemd = <A, E>(
6069 body: (input: {
6170 readonly scope: Effect.Success<typeof make>;
6271 readonly units: Map<string, { activeState: string; result: string }>;
72 readonly calls: ReadonlyArray<ReadonlyArray<string>>;
73 readonly stateDir: string;
6374 }) => Effect.Effect<A, E>,
6475) =>
6576 Effect.gen(function* () {
......@@ -68,6 +79,7 @@ const withFakeSystemd = <A, E>(
6879 NodeFS.writeFileSync(NodePath.join(bin, name), "", { mode: 0o755 });
6980 }
7081 const units = new Map<string, { activeState: string; result: string }>();
82 const calls: Array<ReadonlyArray<string>> = [];
7183 const output = (stdout: string): ProcessRunner.ProcessRunOutput => ({
7284 stdout,
7385 stderr: "",
......@@ -81,6 +93,7 @@ const withFakeSystemd = <A, E>(
8193 const runner = ProcessRunner.ProcessRunner.of({
8294 run: ({ args }) =>
8395 Effect.sync(() => {
96 calls.push(args);
8497 const [, verb, unit = ""] = args;
8598 if (verb === "reset-failed") units.delete(unit);
8699 if (verb !== "show") return output("");
......@@ -97,14 +110,25 @@ const withFakeSystemd = <A, E>(
97110 Effect.provideService(HostProcess.Platform, "linux"),
98111 Effect.provideService(HostProcess.Environment, { PATH: bin, XDG_RUNTIME_DIR: "/run/user/1" }),
99112 );
100 return yield* body({ scope, units }).pipe(
113 const { stateDir } = yield* ServerConfig.ServerConfig;
114 return yield* body({ scope, units, calls, stateDir }).pipe(
101115 Effect.ensuring(Effect.sync(() => NodeFS.rmSync(bin, { recursive: true, force: true }))),
102116 );
103 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
117 }).pipe(
118 Effect.scoped,
119 Effect.provide(
120 ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-scope-" }).pipe(
121 Layer.provideMerge(NodeServices.layer),
122 ),
123 ),
124 );
104125
105126const unitOf = (launch: { readonly args: ReadonlyArray<string> }) =>
106127 launch.args.find((arg) => arg.startsWith("--unit="))?.slice("--unit=".length) ?? "";
107128
129const sliceOf = (launch: { readonly args: ReadonlyArray<string> }) =>
130 launch.args.find((arg) => arg.startsWith("--slice="))?.slice("--slice=".length) ?? "";
131
108132describe("AgentScope service", () => {
109133 it.effect("labels a thread from its newest scope until its next session clears it", () =>
110134 withFakeSystemd(({ scope, units }) =>
......@@ -142,4 +166,37 @@ describe("AgentScope service", () => {
142166 }),
143167 ),
144168 );
169
170 it.effect("stops agents a killed server left before the first launch", () =>
171 withFakeSystemd(({ scope, units, calls, stateDir }) =>
172 Effect.gen(function* () {
173 units.set(ownedAgentSlice(stateDir), { activeState: "active", result: "success" });
174 const agent = yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" });
175 const stops = calls.filter(([, verb]) => verb === "stop");
176 expect(stops).toEqual([["--user", "stop", ownedAgentSlice(stateDir)]]);
177 // Agents run in this server's own slice; terminals stay outside it.
178 expect(sliceOf(agent)).toBe(ownedAgentSlice(stateDir));
179 // An agent that ignores SIGTERM is killed well inside the stop's wait.
180 expect(agent.args).toContain("--property=TimeoutStopSec=10s");
181 const terminal = yield* scope.wrap({
182 command: "agent",
183 args: [],
184 name: "terminal",
185 survivesRestart: true,
186 });
187 expect(sliceOf(terminal)).toBe("app-t3code-agents.slice");
188 // Only the first launch looks for leftovers.
189 expect(calls.filter(([, verb]) => verb === "stop")).toHaveLength(1);
190 }),
191 ),
192 );
193
194 it.effect("leaves a quiet slice alone", () =>
195 withFakeSystemd(({ scope, calls }) =>
196 Effect.gen(function* () {
197 yield* scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" });
198 expect(calls.filter(([, verb]) => verb === "stop")).toEqual([]);
199 }),
200 ),
201 );
145202});
apps/server/src/process/agentScope.ts+73-14
......@@ -15,6 +15,7 @@ import * as FileSystem from "effect/FileSystem";
1515import * as Layer from "effect/Layer";
1616import * as Path from "effect/Path";
1717
18import * as ServerConfig from "../config.ts";
1819import * as ProcessRunner from "../processRunner.ts";
1920
2021/**
......@@ -24,6 +25,18 @@ import * as ProcessRunner from "../processRunner.ts";
2425 */
2526const AGENT_SLICE = "app-t3code-agents.slice";
2627
28/**
29 * The child slice for one server's agents, named after its state directory.
30 * Only one server uses a state directory at a time, so an agent in this slice
31 * when the server starts was left by an earlier server on the same state,
32 * for example one the launcher killed during an update. Other servers on the
33 * machine, such as dev servers, have their own slice.
34 */
35export function ownedAgentSlice(stateDir: string): string {
36 const hash = NodeCrypto.createHash("sha256").update(stateDir).digest("hex").slice(0, 12);
37 return `app-t3code-agents-${hash}.slice`;
38}
39
2740const GiB = 1024 ** 3;
2841
2942/**
......@@ -84,9 +97,12 @@ function parseCgroupCounter(text: string, key: string): number | undefined {
8497 return match === null ? undefined : Number(match[1]);
8598}
8699
100// A stop sends SIGTERM, then SIGKILL after this long. systemd's 90 s default
101// would outlast the wait for a previous server's agents below.
102const STOP_TIMEOUT_OPTION = "--property=TimeoutStopSec=10s";
87103const STOPPING_POLL_INTERVAL = "100 millis";
88104const STOPPING_POLL_ATTEMPTS = 20;
89// A stopping scope can wait out TimeoutStopSec (90 s by default) for a stuck process.
105// A stopping scope can wait out its TimeoutStopSec for a stuck process.
90106const CLEAR_FAILED_INTERVAL = "1 second";
91107const CLEAR_FAILED_ATTEMPTS = 120;
92108const MAX_TRACKED_THREADS = 500;
......@@ -99,6 +115,8 @@ export const make = Effect.gen(function* () {
99115 const fileSystem = yield* FileSystem.FileSystem;
100116 const path = yield* Path.Path;
101117 const hostEnvironment = yield* HostProcess.Environment;
118 const { stateDir } = yield* ServerConfig.ServerConfig;
119 const ownedSlice = ownedAgentSlice(stateDir);
102120 const layerScope = yield* Effect.scope;
103121
104122 const resolve = (command: string, env: NodeJS.ProcessEnv) =>
......@@ -111,8 +129,45 @@ export const make = Effect.gen(function* () {
111129 const run = (command: string, args: ReadonlyArray<string>) =>
112130 runner.run({ command, args, timeout: "5 seconds" }).pipe(Effect.option);
113131
114 // Runs once, on the first agent launch. It proves this process can reach a
115 // systemd user manager, creates the agents slice, and sets its limits.
132 const readCgroupFile = (controlGroup: string, file: string) =>
133 fileSystem
134 .readFileString(path.join("/sys/fs/cgroup", controlGroup, file))
135 .pipe(Effect.orElseSucceed(() => ""));
136
137 // Old agents keep their scopes when the server is killed, and the restarted
138 // server resumes their sessions. Stop them first, so two copies of one
139 // session never work in the same worktree.
140 const stopLeftoverAgents = (systemctl: string) =>
141 Effect.gen(function* () {
142 const shown = yield* run(systemctl, [
143 "--user",
144 "show",
145 ownedSlice,
146 "--property=ActiveState,ControlGroup",
147 ]);
148 if (shown._tag === "None") return;
149 const values = parseKeyValues(shown.value.stdout);
150 if (values.get("ActiveState") !== "active") return;
151 // An active slice can be empty once its last agent exited.
152 const controlGroup = values.get("ControlGroup") ?? "";
153 const events = controlGroup ? yield* readCgroupFile(controlGroup, "cgroup.events") : "";
154 if (parseCgroupCounter(events, "populated") === 0) return;
155 yield* Effect.logWarning("Stopping agents left by a previous server", { slice: ownedSlice });
156 // Agents get SIGTERM, then SIGKILL after their stop timeout.
157 const stopped = yield* runner
158 .run({ command: systemctl, args: ["--user", "stop", ownedSlice], timeout: "30 seconds" })
159 .pipe(Effect.option);
160 if (stopped._tag === "None" || stopped.value.code !== 0) {
161 yield* Effect.logWarning("Could not stop agents left by a previous server", {
162 slice: ownedSlice,
163 stderr: stopped._tag === "Some" ? stopped.value.stderr.trim() : undefined,
164 });
165 }
166 });
167
168 // Runs once, on the first agent or terminal launch. It proves this process
169 // can reach a systemd user manager, stops agents a previous server left,
170 // creates the agents slice, and sets its limits.
116171 const systemd = yield* Effect.cached(
117172 Effect.gen(function* () {
118173 const runtimeDir = hostEnvironment.XDG_RUNTIME_DIR;
......@@ -120,6 +175,7 @@ export const make = Effect.gen(function* () {
120175 const systemdRun = yield* resolve("systemd-run", hostEnvironment);
121176 const systemctl = yield* resolve("systemctl", hostEnvironment);
122177 if (systemdRun === null || systemctl === null) return undefined;
178 yield* stopLeftoverAgents(systemctl);
123179 const probe = (options: ReadonlyArray<string>) =>
124180 run(systemdRun, [
125181 "--user",
......@@ -133,12 +189,13 @@ export const make = Effect.gen(function* () {
133189 ]).pipe(Effect.map((result) => result._tag === "Some" && result.value.code === 0));
134190 // Stop the whole scope when the kernel kills one of its processes, and
135191 // record the result as oom-kill. Scopes take OOMPolicy from systemd 253;
136 // older user managers still get plain scopes, without the OOM label.
137 const oomOptions = ["--property=OOMPolicy=stop"];
192 // older user managers still get scopes, without the OOM label.
193 const stopOptions = [STOP_TIMEOUT_OPTION];
194 const oomOptions = [...stopOptions, "--property=OOMPolicy=stop"];
138195 const scopeOptions = (yield* probe(oomOptions))
139196 ? oomOptions
140 : (yield* probe([]))
141 ? []
197 : (yield* probe(stopOptions))
198 ? stopOptions
142199 : undefined;
143200 if (scopeOptions === undefined) {
144201 yield* Effect.logInfo("Agent scopes are off: no systemd user manager");
......@@ -176,11 +233,6 @@ export const make = Effect.gen(function* () {
176233 // at a time, so only its latest scope can explain a failure.
177234 const scopeByThread = new Map<string, { unit: string; oomKilled: boolean }>();
178235
179 const readCgroupFile = (controlGroup: string, file: string) =>
180 fileSystem
181 .readFileString(path.join("/sys/fs/cgroup", controlGroup, file))
182 .pipe(Effect.orElseSucceed(() => ""));
183
184236 const readScope = (systemctl: string, unit: string) =>
185237 Effect.gen(function* () {
186238 const shown = yield* run(systemctl, [
......@@ -290,7 +342,14 @@ export const make = Effect.gen(function* () {
290342 });
291343
292344 return {
293 wrap: Effect.fn("AgentScope.wrap")(function* ({ command, args, name, threadId, env }) {
345 wrap: Effect.fn("AgentScope.wrap")(function* ({
346 command,
347 args,
348 name,
349 threadId,
350 env,
351 survivesRestart,
352 }) {
294353 const resolved = yield* resolve(command, env ?? hostEnvironment);
295354 // Leave unresolved commands alone so the spawn reports the missing binary.
296355 if (resolved === null) return { command, args };
......@@ -301,7 +360,7 @@ export const make = Effect.gen(function* () {
301360 const systemdScope: AgentSystemdScope = {
302361 systemdRun: scope.systemdRun,
303362 runtimeDir: scope.runtimeDir,
304 slice: AGENT_SLICE,
363 slice: survivesRestart === true ? AGENT_SLICE : ownedSlice,
305364 unit,
306365 // Without a thread nothing reads the result, so let systemd unload
307366 // the unit even when it fails.
apps/server/src/terminal/Manager.ts+1
......@@ -2196,6 +2196,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
21962196 args: candidate.args ?? [],
21972197 name: "terminal",
21982198 env: spawnEnv,
2199 survivesRestart: true,
21992200 });
22002201 const attempt = yield* Effect.result(
22012202 options.ptyAdapter.spawn({
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+2-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vite-plus/test";
22import {
3 type EnvironmentId,
3 EnvironmentId,
44 ProviderDriverKind,
55 ProviderInstanceId,
66 type ServerProvider,
......@@ -1076,6 +1076,7 @@ describe("getProviderUpdateRunToastView", () => {
10761076 providerDriver: string,
10771077 result: Parameters<typeof getProviderUpdateRunToastView>[0][number]["result"],
10781078 ) => ({
1079 environmentId: EnvironmentId.make(machineLabel),
10791080 machineLabel,
10801081 driver: driver(providerDriver),
10811082 instanceId: instanceId(providerDriver),
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+36-14
......@@ -333,6 +333,7 @@ export function getProviderUpdateProgressToastView(input: {
333333
334334/** One provider update sent by the cross-machine "Update all", with its result. */
335335export interface ProviderUpdateRun {
336 readonly environmentId: EnvironmentId;
336337 readonly machineLabel: string;
337338 readonly driver: ProviderDriverKind;
338339 readonly instanceId: ProviderInstanceId;
......@@ -342,6 +343,35 @@ export interface ProviderUpdateRun {
342343 >;
343344}
344345
346/**
347 * What one provider update in a cross-machine run did. A failure carries a
348 * line that names the machine and provider, such as "alvin · Codex: npm exited".
349 */
350export function getProviderUpdateRunOutcome(
351 run: ProviderUpdateRun,
352):
353 | { readonly status: "interrupted" | "updated" }
354 | { readonly status: "failed"; readonly line: string } {
355 if (isAtomCommandInterrupted(run.result)) return { status: "interrupted" };
356 const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`;
357 if (run.result._tag === "Failure") {
358 const error = squashAtomCommandFailure(run.result);
359 return {
360 status: "failed",
361 line: `${label}: ${error instanceof Error ? error.message : "Provider update failed."}`,
362 };
363 }
364 const updateState = run.result.value.providers.find(
365 (provider) => provider.instanceId === run.instanceId,
366 )?.updateState;
367 return updateState?.status === "succeeded"
368 ? { status: "updated" }
369 : {
370 status: "failed",
371 line: `${label}: ${updateState?.message ?? "Provider update did not finish."}`,
372 };
373}
374
345375/**
346376 * Summarize a cross-machine "Update all" as one toast, or null when every
347377 * request was interrupted. Each update that did not succeed gets its own line,
......@@ -350,23 +380,15 @@ export interface ProviderUpdateRun {
350380export function getProviderUpdateRunToastView(
351381 runs: ReadonlyArray<ProviderUpdateRun>,
352382): Pick<ProviderUpdateToastView, "type" | "title" | "description"> | null {
353 const settled = runs.filter((run) => !isAtomCommandInterrupted(run.result));
383 const settled = runs
384 .map(getProviderUpdateRunOutcome)
385 .filter((outcome) => outcome.status !== "interrupted");
354386 if (settled.length === 0) {
355387 return null;
356388 }
357 const failureLines = settled.flatMap((run) => {
358 const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`;
359 if (run.result._tag === "Failure") {
360 const error = squashAtomCommandFailure(run.result);
361 return [`${label}: ${error instanceof Error ? error.message : "Provider update failed."}`];
362 }
363 const updateState = run.result.value.providers.find(
364 (provider) => provider.instanceId === run.instanceId,
365 )?.updateState;
366 return updateState?.status === "succeeded"
367 ? []
368 : [`${label}: ${updateState?.message ?? "Provider update did not finish."}`];
369 });
389 const failureLines = settled.flatMap((outcome) =>
390 outcome.status === "failed" ? [outcome.line] : [],
391 );
370392 if (failureLines.length === 0) {
371393 return {
372394 type: "success",
apps/web/src/components/ProviderUpdatesAction.tsx+6-3
......@@ -20,7 +20,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
2020 * Each server queues updates that share an installer, so sending them all
2121 * together is safe. The server still checks permissions: a session that
2222 * cannot operate a machine gets a failure line for it. The returned promise
23 * settles when every update has finished.
23 * settles with every result when every update has finished. `quiet` skips the
24 * toast, for a caller that reports the results itself.
2425 */
2526export function useProviderUpdates() {
2627 const { environments } = useEnvironments();
......@@ -44,10 +45,11 @@ export function useProviderUpdates() {
4445 [environments],
4546 );
4647
47 const updateAll = async () => {
48 const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
4849 const runs = await Promise.all(
4950 machines.flatMap(({ environmentId, label, candidates }) =>
5051 candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
52 environmentId,
5153 machineLabel: label,
5254 driver: candidate.driver,
5355 instanceId: candidate.instanceId,
......@@ -58,7 +60,7 @@ export function useProviderUpdates() {
5860 })),
5961 ),
6062 );
61 const view = getProviderUpdateRunToastView(runs);
63 const view = quiet ? null : getProviderUpdateRunToastView(runs);
6264 if (view) {
6365 toastManager.add(
6466 stackedThreadToast({
......@@ -67,6 +69,7 @@ export function useProviderUpdates() {
6769 }),
6870 );
6971 }
72 return runs;
7073 };
7174
7275 return { machines, updateAll };
apps/web/src/components/ServerUpdateAction.test.tsx+2-3
......@@ -498,13 +498,12 @@ describe("ServerUpdateProgress", () => {
498498
499499 expect(markup).toContain("Restarting…");
500500 // The wait state is monochrome and calm: no versions, no step rail, no
501 // success/warning colors, one duty-cycled pulse on the dot.
501 // success/warning colors, and a static dot that never repaints.
502502 expect(markup).not.toContain("0.0.30");
503503 expect(markup).not.toContain("Resum");
504504 expect(markup).not.toContain("text-success");
505505 expect(markup).not.toContain("text-primary");
506 expect(markup).toContain("animate-status-pulse");
507 expect(markup).not.toContain("animate-spin");
506 expect(markup).not.toContain("animate-");
508507 });
509508
510509 it("folds the sub-second installing handoff into the download phase", () => {
apps/web/src/components/ServerUpdateAction.tsx+37-26
......@@ -65,17 +65,26 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"
6565 readonly appearance?: "button" | "icon";
6666};
6767
68/** What {@link useServerUpdate} did. "skipped" covers a missing permission, an
69 update already in flight, and an interrupted request. */
70export type ServerUpdateOutcome =
71 | { readonly status: "updated" | "skipped" }
72 | { readonly status: "failed"; readonly message: string };
73
6874/** Updates one server and waits until it is back on the target version.
69 Reports its own success or failure toast; never rejects. */
75 Toasts its success or failure unless `quiet`; never rejects. */
7076export function useServerUpdate() {
7177 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
72 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
78 return async (
79 target: ServerUpdateTarget,
80 { quiet = false }: { readonly quiet?: boolean } = {},
81 ): Promise<ServerUpdateOutcome> => {
7382 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
7483 if (
7584 !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) ||
7685 pendingUpdateEnvironmentIds.has(environmentId)
7786 )
78 return;
87 return { status: "skipped" };
7988 pendingUpdateEnvironmentIds.add(environmentId);
8089 try {
8190 const result = await updateServer({
......@@ -88,23 +97,30 @@ export function useServerUpdate() {
8897 },
8998 });
9099 if (result._tag === "Failure") {
91 if (isAtomCommandInterrupted(result)) return;
100 if (isAtomCommandInterrupted(result)) return { status: "skipped" };
92101 throw squashAtomCommandFailure(result);
93102 }
94 toastManager.add({
95 type: "success",
96 title: `${serverLabel} updated`,
97 description:
98 selfUpdate === "desktop-managed"
99 ? `Desktop app relaunched on ${result.value.targetVersion}.`
100 : `Reconnected on t3@${result.value.targetVersion}.`,
101 });
103 if (!quiet) {
104 toastManager.add({
105 type: "success",
106 title: `${serverLabel} updated`,
107 description:
108 selfUpdate === "desktop-managed"
109 ? `Desktop app relaunched on ${result.value.targetVersion}.`
110 : `Reconnected on t3@${result.value.targetVersion}.`,
111 });
112 }
113 return { status: "updated" };
102114 } catch (error) {
103 toastManager.add({
104 type: "error",
105 title: failureTitle,
106 description: updateFailureMessage(error),
107 });
115 const message = updateFailureMessage(error);
116 if (!quiet) {
117 toastManager.add({
118 type: "error",
119 title: `${serverLabel} update failed`,
120 description: message,
121 });
122 }
123 return { status: "failed", message };
108124 } finally {
109125 pendingUpdateEnvironmentIds.delete(environmentId);
110126 }
......@@ -145,9 +161,7 @@ export function ServerUpdatesAction({
145161 )) ?? true;
146162 if (!confirmed) return;
147163 }
148 await Promise.all(
149 available.map((target) => update(target, `${target.serverLabel} update failed`)),
150 );
164 await Promise.all(available.map((target) => update(target)));
151165 } finally {
152166 pending.current = false;
153167 setIsPending(false);
......@@ -175,7 +189,7 @@ export function canUpdateServer(
175189
176190/**
177191 * One-row status for an in-flight server update: "Downloading…" then
178 * "Restarting…". The update is a wait, not a warning: a single pulsing dot
192 * "Restarting…". The update is a wait, not a warning: a single static dot
179193 * and label, no step rail, no versions. Failure turns the row red with the
180194 * rollback reason.
181195 */
......@@ -197,10 +211,7 @@ export function ServerUpdateProgress({
197211 }
198212 return (
199213 <div className="mt-1 flex items-center gap-2 text-xs font-medium text-foreground">
200 <span
201 className="size-1.5 shrink-0 animate-status-pulse rounded-full bg-foreground"
202 aria-hidden="true"
203 />
214 <span className="size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden="true" />
204215 <span>{serverUpdateStageLabel(state.stage)}</span>
205216 </div>
206217 );
......@@ -381,7 +392,7 @@ export function OutdatedServerUpdateAction({
381392 } catch (error) {
382393 toastManager.add({
383394 type: "error",
384 title: "Server update failed",
395 title: `${serverLabel} update failed`,
385396 description: updateFailureMessage(error),
386397 });
387398 } finally {
apps/web/src/components/desktopUpdate.logic.test.ts+20
......@@ -57,6 +57,26 @@ describe("desktop update button state", () => {
5757 expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
5858 });
5959
60 it("says when the same version did not apply before", () => {
61 const state: DesktopUpdateState = {
62 ...baseState,
63 status: "downloaded",
64 availableVersion: "1.1.0",
65 downloadedVersion: "1.1.0",
66 notAppliedVersion: "1.1.0",
67 };
68 expect(getDesktopUpdateButtonTooltip(state)).toBe(
69 "Update 1.1.0 did not apply. Click to retry.",
70 );
71 expect(
72 getDesktopUpdateButtonTooltip({
73 ...state,
74 availableVersion: "1.2.0",
75 downloadedVersion: "1.2.0",
76 }),
77 ).toContain("1.2.0 downloaded");
78 });
79
6080 it("keeps install action available after an install error", () => {
6181 const state: DesktopUpdateState = {
6282 ...baseState,
apps/web/src/components/desktopUpdate.logic.ts+7
......@@ -71,6 +71,13 @@ export function getArm64IntelBuildWarningDescription(state: DesktopUpdateState):
7171}
7272
7373export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string {
74 if (
75 state.notAppliedVersion !== undefined &&
76 (state.status === "available" || state.status === "downloaded") &&
77 getDesktopUpdateDownloadedVersion(state) === state.notAppliedVersion
78 ) {
79 return `Update ${state.notAppliedVersion} did not apply. Click to retry.`;
80 }
7481 if (state.status === "available") {
7582 return `Update ${state.availableVersion ?? "available"} ready to download`;
7683 }
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+131-71
......@@ -1,12 +1,23 @@
1import { useAtomValue } from "@effect/atom-react";
12import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
24import { TriangleAlertIcon } from "lucide-react";
3import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react";
5import {
6 type ComponentProps,
7 useCallback,
8 useEffect,
9 useId,
10 useMemo,
11 useRef,
12 useState,
13} from "react";
414import { flushSync } from "react-dom";
515import { isElectron } from "../../env";
616import { useMediaQuery } from "../../hooks/useMediaQuery";
717import { cn } from "../../lib/utils";
818import { ensureLocalApi } from "../../localApi";
919import { useDesktopUpdateState } from "../../state/desktopUpdate";
20import { serverEnvironment } from "../../state/server";
1021import { stackedThreadToast, toastManager } from "../ui/toast";
1122import {
1223 canCheckForUpdate,
......@@ -33,7 +44,13 @@ import {
3344} from "./DesktopUpdateStatusIcon";
3445import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
3546import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
36import { useUpdateEverything } from "./useUpdateEverything";
47import {
48 describeUpdateRun,
49 resolveUpdateRunMachineStatus,
50 type UpdateEverythingRun,
51} from "./updateEverything.logic";
52import { useUpdateEverythingRun } from "./updateEverythingRun";
53import { type ReportLocalUpdateError, useUpdateEverything } from "./useUpdateEverything";
3754
3855type SidebarUpdatePopoverChangeDetails = Parameters<
3956 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
......@@ -68,27 +85,34 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
6885function resolveSidebarUpdatePresentation({
6986 action,
7087 showsEverything,
88 run,
7189 isDownloading,
7290 showCheckIcon,
7391}: {
7492 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
75 /** Remote work is waiting or running, and this app is not downloading. */
93 /** Remote work is waiting, and this app is not downloading. */
7694 readonly showsEverything: boolean;
95 /** The running "update everything" pass. Its state sets the icon. */
96 readonly run: UpdateEverythingRun | null;
7797 readonly isDownloading: boolean;
7898 readonly showCheckIcon: boolean;
7999}) {
80 const showUpdateDetails = action !== "none" || showsEverything || isDownloading;
100 const showUpdateDetails = action !== "none" || showsEverything || run !== null || isDownloading;
81101 const iconStatus: DesktopUpdateStatusIconState = showCheckIcon
82102 ? "checking"
83103 : isDownloading
84104 ? "downloading"
85 : showsEverything
86 ? "everything"
87 : action === "install"
105 : run !== null
106 ? run.localVersion !== null && action === "install"
88107 ? "downloaded"
89 : action === "download"
90 ? "available"
91 : "idle";
108 : "everything"
109 : showsEverything
110 ? "everything"
111 : action === "install"
112 ? "downloaded"
113 : action === "download"
114 ? "available"
115 : "idle";
92116
93117 return {
94118 iconStatus,
......@@ -117,11 +141,18 @@ function SidebarUpdateArchitectureWarningContent() {
117141 );
118142}
119143
144const toastUpdateError: ReportLocalUpdateError = (title, description) => {
145 toastManager.add(stackedThreadToast({ type: "error", title, description }));
146};
147
120148/** Downloads this app's update. Resolves true when it is ready to install;
121 failures toast and resolve false. */
149 failures go to `reportError` (a toast by default) and resolve false. */
122150async function downloadLocalUpdate(
123151 bridge: DesktopBridge,
124 { notifyDownloaded }: { readonly notifyDownloaded: boolean },
152 {
153 notifyDownloaded,
154 reportError = toastUpdateError,
155 }: { readonly notifyDownloaded: boolean; readonly reportError?: ReportLocalUpdateError },
125156): Promise<boolean> {
126157 try {
127158 const result = await bridge.downloadUpdate();
......@@ -131,60 +162,83 @@ async function downloadLocalUpdate(
131162 const actionError = shouldToastDesktopUpdateActionResult(result)
132163 ? getDesktopUpdateActionError(result)
133164 : null;
134 if (actionError) {
135 toastManager.add(
136 stackedThreadToast({
137 type: "error",
138 title: "Could not download update",
139 description: actionError,
140 }),
141 );
142 }
165 if (actionError) reportError("Could not download update", actionError);
143166 return result.completed;
144167 } catch (error) {
145 toastManager.add(
146 stackedThreadToast({
147 type: "error",
148 title: "Could not start update download",
149 description: error instanceof Error ? error.message : "An unexpected error occurred.",
150 }),
168 reportError(
169 "Could not start update download",
170 error instanceof Error ? error.message : "An unexpected error occurred.",
151171 );
152172 return false;
153173 }
154174}
155175
156/** Installs this app's downloaded update, which relaunches the app.
157 Failures toast. A refused install (the download is not marked ready yet)
176/** Installs this app's downloaded update, which relaunches the app. Resolves
177 true when the relaunch is under way. Failures go to `reportError` (a toast
178 by default). A refused install (the download is not marked ready yet)
158179 leaves the restart to the user. */
159async function installLocalUpdate(bridge: DesktopBridge): Promise<void> {
180async function installLocalUpdate(
181 bridge: DesktopBridge,
182 reportError: ReportLocalUpdateError = toastUpdateError,
183): Promise<boolean> {
160184 try {
161185 const result = await bridge.installUpdate();
162186 if (!result.accepted) {
163187 showDesktopUpdateDownloadedToast(bridge, result.state);
164 return;
188 return false;
165189 }
166190 const actionError = shouldToastDesktopUpdateActionResult(result)
167191 ? getDesktopUpdateActionError(result)
168192 : null;
169 if (!actionError) return;
170 toastManager.add(
171 stackedThreadToast({
172 type: "error",
173 title: "Could not install update",
174 description: actionError,
175 }),
176 );
193 if (!actionError) return true;
194 reportError("Could not install update", actionError);
195 return false;
177196 } catch (error) {
178 toastManager.add(
179 stackedThreadToast({
180 type: "error",
181 title: "Could not install update",
182 description: error instanceof Error ? error.message : "An unexpected error occurred.",
183 }),
197 reportError(
198 "Could not install update",
199 error instanceof Error ? error.message : "An unexpected error occurred.",
184200 );
201 return false;
185202 }
186203}
187204
205/** Tooltip lines for a running pass: this app first, then one per machine. */
206function useUpdateRunTooltip(
207 run: UpdateEverythingRun | null,
208 state: DesktopUpdateState | null,
209 localReady: boolean,
210): string | null {
211 const statusesAtom = useMemo(
212 () =>
213 Atom.make((get) =>
214 run === null
215 ? []
216 : run.machines.map((machine) => ({
217 label: machine.label,
218 status: resolveUpdateRunMachineStatus(
219 machine,
220 run.phase,
221 get(serverEnvironment.updateStateAtom(machine.environmentId)),
222 ),
223 })),
224 ),
225 [run],
226 );
227 const statuses = useAtomValue(statusesAtom);
228 if (run === null) return null;
229 const local =
230 run.localVersion === null
231 ? null
232 : state?.status === "downloading"
233 ? `downloading${state.downloadPercent === null ? "" : ` ${Math.floor(state.downloadPercent)}%`}`
234 : run.phase === "installing"
235 ? "restarting"
236 : localReady
237 ? "restarts last"
238 : "waiting";
239 return describeUpdateRun(statuses, local).join("\n");
240}
241
188242/**
189243 * The sidebar update button. With only a local app update it downloads, then
190244 * installs. When other machines' servers or any providers are behind, one
......@@ -198,7 +252,6 @@ export function SidebarUpdatePill() {
198252function SidebarUpdateControl() {
199253 const state = useDesktopUpdateState();
200254 const [isActionPending, setIsActionPending] = useState(false);
201 const [isUpdatingEverything, setIsUpdatingEverything] = useState(false);
202255 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
203256 const [checkAnimationKey, setCheckAnimationKey] = useState(0);
204257 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
......@@ -224,7 +277,9 @@ function SidebarUpdateControl() {
224277 const everything = useUpdateEverything(localUpdateVersion);
225278 const runsEverything = everything.hasUpdates && !isDownloading;
226279 // Providers leave the list once queued, so the run keeps its own state.
227 const isRunningEverything = isUpdatingEverything && !isDownloading;
280 const run = useUpdateEverythingRun();
281 const isRunningEverything = run !== null;
282 const runTooltip = useUpdateRunTooltip(run, state, action === "install");
228283 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
229284 isAnimationLatched: isCheckAnimationLatched,
230285 isChecking: state?.status === "checking",
......@@ -232,21 +287,23 @@ function SidebarUpdateControl() {
232287 });
233288 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
234289 action,
235 showsEverything: runsEverything || isRunningEverything,
290 showsEverything: runsEverything,
291 run,
236292 isDownloading,
237293 showCheckIcon,
238294 });
239 const tooltip = isRunningEverything
240 ? "Updating…"
241 : runsEverything
242 ? everything.summary
243 : showUpdateDetails
244 ? state
245 ? getDesktopUpdateButtonTooltip(state)
246 : "Update available"
247 : showCheckIcon
248 ? "Checking for updates…"
249 : "Check for updates";
295 const tooltip =
296 runTooltip !== null
297 ? runTooltip
298 : runsEverything
299 ? everything.summary
300 : showUpdateDetails
301 ? state
302 ? getDesktopUpdateButtonTooltip(state)
303 : "Update available"
304 : showCheckIcon
305 ? "Checking for updates…"
306 : "Check for updates";
250307 const disabled = showCheckIcon
251308 ? true
252309 : runsEverything
......@@ -254,11 +311,10 @@ function SidebarUpdateControl() {
254311 : showUpdateDetails
255312 ? isDesktopUpdateButtonDisabled(state)
256313 : !canCheckForUpdate(state);
257 const isInteractionDisabled = disabled || isActionPending;
258 const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover(
259 showUpdateDetails,
260 state,
261 );
314 const isInteractionDisabled = disabled || isActionPending || isRunningEverything;
315 // During a run the tooltip shows its progress instead of release notes.
316 const showReleaseNotesPopover =
317 !isRunningEverything && shouldUseSidebarUpdateReleaseNotesPopover(showUpdateDetails, state);
262318
263319 useEffect(() => {
264320 if (!showReleaseNotesPopover) {
......@@ -349,13 +405,17 @@ function SidebarUpdateControl() {
349405 const bridge = window.desktopBridge;
350406 setIsConfirmingEverything(false);
351407 setIsActionPending(true);
352 setIsUpdatingEverything(true);
353408 try {
354409 await everything.run({
355410 ...(bridge && action === "download"
356 ? { downloadLocal: () => downloadLocalUpdate(bridge, { notifyDownloaded: false }) }
411 ? {
412 downloadLocal: (reportError) =>
413 downloadLocalUpdate(bridge, { notifyDownloaded: false, reportError }),
414 }
415 : {}),
416 ...(bridge && action !== "none"
417 ? { installLocal: (reportError) => installLocalUpdate(bridge, reportError) }
357418 : {}),
358 ...(bridge && action !== "none" ? { installLocal: () => installLocalUpdate(bridge) } : {}),
359419 });
360420 } catch (error) {
361421 toastManager.add(
......@@ -366,7 +426,6 @@ function SidebarUpdateControl() {
366426 }),
367427 );
368428 } finally {
369 setIsUpdatingEverything(false);
370429 setIsActionPending(false);
371430 }
372431 }, [action, everything]);
......@@ -381,7 +440,7 @@ function SidebarUpdateControl() {
381440 }, [prefersReducedMotion, state?.status]);
382441
383442 // Web has no local app to check, so it only shows the button for remote work.
384 if (!isElectron && !runsEverything && !isUpdatingEverything) return null;
443 if (!isElectron && !runsEverything && !isRunningEverything) return null;
385444
386445 const updateButton = (
387446 <button
......@@ -400,7 +459,7 @@ function SidebarUpdateControl() {
400459 "text-(--sidebar-icon-color)",
401460 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
402461 ),
403 ((disabled && !showUpdateIconState) || isRunningEverything) && "opacity-60",
462 disabled && !showUpdateIconState && "opacity-60",
404463 )}
405464 onClick={handleAction}
406465 onBlur={() => {
......@@ -471,7 +530,7 @@ function SidebarUpdateControl() {
471530 side="top"
472531 variant={showUpdateDetails ? "glass" : "default"}
473532 >
474 {tooltip}
533 <span className="whitespace-pre-line">{tooltip}</span>
475534 </TooltipPopup>
476535 ) : null}
477536 </Tooltip>
......@@ -506,6 +565,7 @@ function SidebarUpdateControl() {
506565 summary={everything.summary}
507566 lines={everything.lines}
508567 restarts={everything.restarts}
568 skipped={everything.skipped}
509569 onConfirm={() => void runEverything()}
510570 />
511571 </SidebarMenuItem>
apps/web/src/components/sidebar/UpdateEverythingDialog.tsx+10-1
......@@ -13,7 +13,7 @@ import {
1313 AlertDialogTitle,
1414} from "../ui/alert-dialog";
1515import { Button } from "../ui/button";
16import type { RestartingMachine } from "./updateEverything.logic";
16import type { RestartingMachine, SkippedMachine } from "./updateEverything.logic";
1717
1818/** A thread an agent is working on now. A restart stops these; queued threads wait. */
1919const isRunning = (status: string) =>
......@@ -42,6 +42,8 @@ interface UpdateEverythingDialogProps {
4242 readonly summary: string;
4343 readonly lines: ReadonlyArray<string>;
4444 readonly restarts: ReadonlyArray<RestartingMachine>;
45 /** Behind machines the run leaves alone. */
46 readonly skipped: ReadonlyArray<SkippedMachine>;
4547 readonly onConfirm: () => void;
4648}
4749
......@@ -50,6 +52,7 @@ interface UpdateEverythingDialogProps {
5052 * restarts stop, and how many can resume afterwards because "Continue threads
5153 * after restarts" is on for their server. Recovery still skips some, such as a
5254 * thread waiting on the user, so the text says "can resume", not "will".
55 * Machines the run skips are listed with the reason, so none drop silently.
5356 */
5457export function UpdateEverythingDialog({
5558 open,
......@@ -70,6 +73,7 @@ function UpdateEverythingDialogContent({
7073 summary,
7174 lines,
7275 restarts,
76 skipped,
7377 onConfirm,
7478}: Omit<UpdateEverythingDialogProps, "open" | "onOpenChange">) {
7579 const stopped = useAtomValue(
......@@ -107,6 +111,11 @@ function UpdateEverythingDialogContent({
107111 : `${threads} will be interrupted. ${continuing} can resume after the restart.`}
108112 </p>
109113 ) : null}
114 {skipped.length > 0 ? (
115 <p className="whitespace-pre-line pt-2 text-sm">
116 {skipped.map((machine) => `Skips ${machine.label}: ${machine.reason}`).join("\n")}
117 </p>
118 ) : null}
110119 </AlertDialogHeader>
111120 <AlertDialogFooter>
112121 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
apps/web/src/components/sidebar/updateEverything.logic.test.ts+123-15
......@@ -4,9 +4,13 @@ import { describe, expect, it } from "vite-plus/test";
44import {
55 collectRestartingMachines,
66 collectServerUpdateTargets,
7 collectUpdateEverythingResult,
78 describeUpdateEverything,
9 getUpdateEverythingToastView,
10 resolveUpdateRunMachineStatus,
811 runUpdateEverything,
912 type UpdateEverythingMachine,
13 type UpdateRunMachine,
1014} from "./updateEverything.logic";
1115
1216function machine(
......@@ -41,16 +45,26 @@ function machine(
4145}
4246
4347describe("collectServerUpdateTargets", () => {
44 it("updates only remote servers that can move to the target on their channel", () => {
45 const targets = collectServerUpdateTargets(
48 it("updates remote servers that can move to the target and names every skipped one", () => {
49 const { targets, skipped } = collectServerUpdateTargets(
4650 [
4751 machine("this-app", "0.0.40", "desktop-managed", { hostedByThisApp: true }),
4852 machine("service", "0.0.40", "boot-service"),
4953 machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"),
5054 machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"),
5155 machine("offline", "0.0.40", "boot-service", { connected: false }),
56 machine("never-connected", "0.0.40", "boot-service", { serverConfig: null }),
5257 machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }),
58 machine("busy", "0.0.40", "boot-service", {
59 updateState: {
60 status: "running",
61 stage: "downloading",
62 fromVersion: "0.0.40",
63 targetVersion: "0.0.50",
64 },
65 }),
5366 machine("current", "0.0.50", "boot-service"),
67 machine("current-offline", "0.0.50", "boot-service", { connected: false }),
5468 machine("manual", "0.0.40", undefined),
5569 ],
5670 "0.0.50",
......@@ -60,6 +74,14 @@ describe("collectServerUpdateTargets", () => {
6074 ["service", "0.0.50"],
6175 ["other-desktop", "0.0.50"],
6276 ]);
77 expect(skipped.map((entry) => [entry.label, entry.reason])).toEqual([
78 ["nightly-service", "on the nightly channel"],
79 ["offline", "not connected"],
80 ["never-connected", "not connected"],
81 ["no-scope", "no permission"],
82 ["busy", "already updating"],
83 ["manual", "needs a manual update"],
84 ]);
6385 });
6486});
6587
......@@ -109,37 +131,35 @@ describe("collectRestartingMachines", () => {
109131});
110132
111133describe("runUpdateEverything", () => {
112 it("restarts servers after providers finish and installs this app last", async () => {
134 it("restarts servers after providers finish and returns every result", async () => {
113135 const calls: string[] = [];
114 let finishProviders = () => {};
136 let finishProviders = (_value: string) => {};
115137 const run = runUpdateEverything({
116138 downloadLocal: async () => {
117139 calls.push("download");
118140 return true;
119141 },
120142 updateProviders: () =>
121 new Promise<void>((resolve) => {
143 new Promise<string>((resolve) => {
122144 calls.push("providers");
123145 finishProviders = resolve;
124146 }),
125147 updateServers: async () => {
126148 calls.push("servers");
127 },
128 installLocal: async () => {
129 calls.push("install");
149 return 2;
130150 },
131151 });
132152
133153 await new Promise((resolve) => setTimeout(resolve, 0));
134154 expect(calls).toEqual(["download", "providers"]);
135 finishProviders();
136 await run;
137 expect(calls).toEqual(["download", "providers", "servers", "install"]);
155 finishProviders("provider results");
156 expect(await run).toEqual({ localReady: true, providers: "provider results", servers: 2 });
157 expect(calls).toEqual(["download", "providers", "servers"]);
138158 });
139159
140160 it("still updates remote machines when the local download fails", async () => {
141161 const calls: string[] = [];
142 await runUpdateEverything({
162 const result = await runUpdateEverything({
143163 downloadLocal: async () => false,
144164 updateProviders: async () => {
145165 calls.push("providers");
......@@ -147,12 +167,100 @@ describe("runUpdateEverything", () => {
147167 updateServers: async () => {
148168 calls.push("servers");
149169 },
150 installLocal: async () => {
151 calls.push("install");
152 },
153170 });
154171
155172 expect(calls).toEqual(["providers", "servers"]);
173 expect(result.localReady).toBe(false);
174 });
175});
176
177describe("resolveUpdateRunMachineStatus", () => {
178 const runMachine = (overrides: Partial<UpdateRunMachine> = {}): UpdateRunMachine => ({
179 environmentId: EnvironmentId.make("alvin"),
180 label: "alvin",
181 updatesProviders: true,
182 updatesServer: true,
183 result: null,
184 ...overrides,
185 });
186 const running = (stage: "downloading" | "installing" | "resuming") =>
187 ({ status: "running", stage, fromVersion: "0.0.40", targetVersion: "0.0.50" }) as const;
188
189 it("follows the run phase, then the server's own update stage", () => {
190 const idle = { status: "idle" } as const;
191 expect(resolveUpdateRunMachineStatus(runMachine(), "providers", idle)).toBe("providers");
192 expect(resolveUpdateRunMachineStatus(runMachine(), "servers", idle)).toBe("waiting");
193 expect(resolveUpdateRunMachineStatus(runMachine(), "servers", running("installing"))).toBe(
194 "downloading",
195 );
196 expect(resolveUpdateRunMachineStatus(runMachine(), "servers", running("resuming"))).toBe(
197 "restarting",
198 );
199 expect(
200 resolveUpdateRunMachineStatus(
201 runMachine({ updatesProviders: false }),
202 "providers",
203 running("downloading"),
204 ),
205 ).toBe("downloading");
206 expect(resolveUpdateRunMachineStatus(runMachine({ result: "failed" }), "servers", idle)).toBe(
207 "failed",
208 );
209 });
210});
211
212describe("update everything summary", () => {
213 const result = collectUpdateEverythingResult({
214 localVersion: "0.0.50",
215 localFailures: ["Could not download update: Network error."],
216 servers: [
217 { label: "alvin", outcome: { status: "failed", message: "Download failed." } },
218 { label: "box", outcome: { status: "updated" } },
219 { label: "studio", outcome: { status: "updated" } },
220 { label: "busy", outcome: { status: "skipped" } },
221 ],
222 providers: [
223 { status: "updated" },
224 { status: "interrupted" },
225 { status: "failed", line: "box · Codex: npm exited with code 1." },
226 ],
227 });
228
229 it("counts updates and names the machine of each failure", () => {
230 expect(result).toEqual({
231 localVersion: "0.0.50",
232 updatedServers: 2,
233 updatedProviders: 1,
234 failures: [
235 "This app: Could not download update: Network error.",
236 "alvin: Download failed.",
237 "box · Codex: npm exited with code 1.",
238 ],
239 });
240 });
241
242 it("includes this app only when the relaunch landed on its version", () => {
243 expect(getUpdateEverythingToastView(result, "0.0.50")).toEqual({
244 type: "error",
245 title: "Updated this app, 2 servers, and 1 provider",
246 description: [
247 "This app: Could not download update: Network error.",
248 "alvin: Download failed.",
249 "box · Codex: npm exited with code 1.",
250 ].join("\n"),
251 });
252 expect(getUpdateEverythingToastView({ ...result, failures: [] }, "0.0.40")).toEqual({
253 type: "success",
254 title: "Updated 2 servers and 1 provider",
255 });
256 });
257
258 it("reports a failure when nothing updated, and nothing when nothing finished", () => {
259 const empty = { localVersion: null, updatedServers: 0, updatedProviders: 0 };
260 expect(
261 getUpdateEverythingToastView({ ...empty, failures: ["alvin: offline"] }, "0.0.50"),
262 ).toEqual({ type: "error", title: "Update failed", description: "alvin: offline" });
263 expect(getUpdateEverythingToastView({ ...empty, failures: [] }, "0.0.50")).toBeNull();
156264 });
157265});
158266
apps/web/src/components/sidebar/updateEverything.logic.ts+215-45
......@@ -8,7 +8,7 @@ import {
88 supportsDesktopAppUpdate,
99 supportsServerUpdateThreadContinuation,
1010} from "~/versionSkew";
11import type { ServerUpdateTarget } from "../ServerUpdateAction";
11import type { ServerUpdateOutcome, ServerUpdateTarget } from "../ServerUpdateAction";
1212
1313export interface UpdateEverythingMachine {
1414 readonly environmentId: EnvironmentId;
......@@ -63,78 +63,248 @@ export function collectRestartingMachines(
6363 });
6464}
6565
66/** A machine the run leaves alone, with a short reason such as "not connected". */
67export interface SkippedMachine {
68 readonly environmentId: EnvironmentId;
69 readonly label: string;
70 readonly reason: string;
71}
72
6673/**
6774 * The servers that "update everything" moves to `targetVersion`: connected,
68 * permitted, able to update over RPC, and behind the target. Two kinds stay
69 * out. Servers this app hosts would relaunch the app mid-run, and the app's
70 * own install updates them anyway. A service on another release channel
71 * stays there; a desktop-managed server follows its own app's update feed, so
72 * the channel rule does not apply to it.
75 * permitted, able to update over RPC, behind the target, and on its release
76 * channel. Every other behind server comes back in `skipped` with a reason for
77 * the confirmation; a machine that never connected has an unknown version, so
78 * it counts as skipped. Two kinds are in neither list: servers already on the
79 * target, and servers this app hosts, which would relaunch the app mid-run
80 * and update with the app's own install anyway. A desktop-managed server
81 * follows its own app's update feed, so the channel rule does not apply to it.
7382 */
7483export function collectServerUpdateTargets(
7584 machines: ReadonlyArray<UpdateEverythingMachine>,
7685 targetVersion: string,
77): ServerUpdateTarget[] {
78 return machines.flatMap((machine) => {
86): { readonly targets: ServerUpdateTarget[]; readonly skipped: SkippedMachine[] } {
87 const targets: ServerUpdateTarget[] = [];
88 const skipped: SkippedMachine[] = [];
89 for (const machine of machines) {
7990 const config = machine.serverConfig;
91 if (machine.hostedByThisApp) continue;
8092 if (
81 config === null ||
82 !machine.connected ||
83 machine.hostedByThisApp ||
84 !machine.canMaintain ||
85 machine.updateState.status === "running"
93 config !== null &&
94 resolveVersionMismatch(config.environment.serverVersion, targetVersion) === null
8695 ) {
87 return [];
96 continue;
97 }
98 const skip = (reason: string) =>
99 skipped.push({ environmentId: machine.environmentId, label: machine.label, reason });
100 if (config === null || !machine.connected) {
101 skip("not connected");
102 continue;
103 }
104 if (!machine.canMaintain) {
105 skip("no permission");
106 continue;
107 }
108 if (machine.updateState.status === "running") {
109 skip("already updating");
110 continue;
88111 }
89112 const selfUpdate = resolveServerSelfUpdateCapability(config);
90113 const desktopAppUpdate = supportsDesktopAppUpdate(config);
91 if (selfUpdate === null || (selfUpdate === "desktop-managed" && !desktopAppUpdate)) {
92 return [];
114 if (selfUpdate === null) {
115 skip("needs a manual update");
116 continue;
117 }
118 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
119 skip("needs a newer desktop app");
120 continue;
93121 }
94122 const serverVersion = config.environment.serverVersion;
95 if (resolveVersionMismatch(serverVersion, targetVersion) === null) return [];
96123 if (
97124 selfUpdate !== "desktop-managed" &&
98125 cliReleaseChannelOf(serverVersion) !== cliReleaseChannelOf(targetVersion)
99126 ) {
100 return [];
127 skip(`on the ${cliReleaseChannelOf(serverVersion)} channel`);
128 continue;
101129 }
102 return [
103 {
104 environmentId: machine.environmentId,
105 serverLabel: machine.label,
106 selfUpdate,
107 installation: config.environment.capabilities.serverInstallation,
108 desktopAppUpdate,
109 threadContinuation: supportsServerUpdateThreadContinuation(config),
110 continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false,
111 targetVersion,
112 },
113 ];
114 });
130 targets.push({
131 environmentId: machine.environmentId,
132 serverLabel: machine.label,
133 selfUpdate,
134 installation: config.environment.capabilities.serverInstallation,
135 desktopAppUpdate,
136 threadContinuation: supportsServerUpdateThreadContinuation(config),
137 continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false,
138 targetVersion,
139 });
140 }
141 return { targets, skipped };
115142}
116143
117export interface UpdateEverythingSteps {
118 /** Downloads this app's update. Resolves true when it is ready to install. */
144export interface UpdateEverythingSteps<Providers, Servers> {
145 /** Downloads this app's update. Resolves true when it is ready to install.
146 Without it, `localReady` is true: there is nothing to download. */
119147 readonly downloadLocal?: () => Promise<boolean>;
120 readonly updateProviders: () => Promise<void>;
121 readonly updateServers: () => Promise<void>;
122 /** Installs this app's update. The relaunch ends the run. */
123 readonly installLocal?: () => Promise<void>;
148 readonly updateProviders: () => Promise<Providers>;
149 readonly updateServers: () => Promise<Servers>;
124150}
125151
126152/**
127 * Runs one "update everything" pass. Providers
128 * finish before any server restarts, so no install is cut off. The local download runs alongside the
129 * remote work. This app installs last because its relaunch ends the run; a
130 * failed download skips only that install. Steps report their own results.
153 * Runs the remote half of one "update everything" pass and returns each
154 * step's results. Providers finish before any server restarts, so no install
155 * is cut off. The local download runs alongside the remote work. The caller
156 * installs this app afterwards when `localReady`, because the relaunch ends
157 * the run; a failed download skips only that install.
131158 */
132export async function runUpdateEverything(steps: UpdateEverythingSteps): Promise<void> {
133 const [localReady] = await Promise.all([
159export async function runUpdateEverything<Providers, Servers>(
160 steps: UpdateEverythingSteps<Providers, Servers>,
161): Promise<{
162 readonly localReady: boolean;
163 readonly providers: Providers;
164 readonly servers: Servers;
165}> {
166 const [localReady, remote] = await Promise.all([
134167 steps.downloadLocal?.() ?? Promise.resolve(true),
135 steps.updateProviders().then(steps.updateServers),
168 steps
169 .updateProviders()
170 .then(async (providers) => ({ providers, servers: await steps.updateServers() })),
136171 ]);
137 if (localReady) await steps.installLocal?.();
172 return { localReady, ...remote };
173}
174
175/** The current state of one machine in a running pass. */
176export type UpdateRunMachineStatus =
177 | "providers"
178 | "waiting"
179 | "downloading"
180 | "restarting"
181 | "done"
182 | "failed";
183
184/** One machine in a running "update everything" pass. */
185export interface UpdateRunMachine {
186 readonly environmentId: EnvironmentId;
187 readonly label: string;
188 readonly updatesProviders: boolean;
189 readonly updatesServer: boolean;
190 /** Set when every update for this machine has finished. */
191 readonly result: "done" | "failed" | null;
192}
193
194/** A running "update everything" pass, kept while it runs. */
195export interface UpdateEverythingRun {
196 /** The version this app installs last, or null when it does not install. */
197 readonly localVersion: string | null;
198 /** Providers update first. Servers update after every provider finished. */
199 readonly phase: "providers" | "servers" | "installing";
200 readonly machines: ReadonlyArray<UpdateRunMachine>;
201}
202
203/** Status of one machine, from the run and that server's own update state. */
204export function resolveUpdateRunMachineStatus(
205 machine: UpdateRunMachine,
206 phase: UpdateEverythingRun["phase"],
207 serverState: ServerUpdateState,
208): UpdateRunMachineStatus {
209 if (machine.result !== null) return machine.result;
210 if (phase === "providers" && machine.updatesProviders) return "providers";
211 if (machine.updatesServer && serverState.status === "running") {
212 return serverState.stage === "resuming" ? "restarting" : "downloading";
213 }
214 return "waiting";
215}
216
217const RUN_STATUS_LABELS: Record<UpdateRunMachineStatus, string> = {
218 providers: "updating providers",
219 waiting: "waiting",
220 downloading: "downloading",
221 restarting: "restarting",
222 done: "done",
223 failed: "failed",
224};
225
226/**
227 * Tooltip lines for a running pass, such as "alvin: restarting". `local` is
228 * this app's own state, such as "downloading 40%", or null when it does not
229 * install.
230 */
231export function describeUpdateRun(
232 machines: ReadonlyArray<{ readonly label: string; readonly status: UpdateRunMachineStatus }>,
233 local: string | null,
234): ReadonlyArray<string> {
235 return [
236 "Updating…",
237 ...(local === null ? [] : [`This app: ${local}`]),
238 ...machines.map((machine) => `${machine.label}: ${RUN_STATUS_LABELS[machine.status]}`),
239 ];
240}
241
242/** What one pass did. It is saved before this app installs, so it outlives the relaunch. */
243export interface UpdateEverythingResult {
244 /** The version this app installed last, or null when it did not install. */
245 readonly localVersion: string | null;
246 readonly updatedServers: number;
247 readonly updatedProviders: number;
248 /** One line per failure, each naming its machine. */
249 readonly failures: ReadonlyArray<string>;
250}
251
252/** Counts what a pass updated and lists each failure with its machine. */
253export function collectUpdateEverythingResult(input: {
254 readonly localVersion: string | null;
255 /** This app's own download or install failures. */
256 readonly localFailures: ReadonlyArray<string>;
257 readonly servers: ReadonlyArray<{
258 readonly label: string;
259 readonly outcome: ServerUpdateOutcome;
260 }>;
261 readonly providers: ReadonlyArray<
262 | { readonly status: "interrupted" | "updated" }
263 | { readonly status: "failed"; readonly line: string }
264 >;
265}): UpdateEverythingResult {
266 return {
267 localVersion: input.localVersion,
268 updatedServers: input.servers.filter(({ outcome }) => outcome.status === "updated").length,
269 updatedProviders: input.providers.filter((outcome) => outcome.status === "updated").length,
270 failures: [
271 ...input.localFailures.map((failure) => `This app: ${failure}`),
272 ...input.servers.flatMap(({ label, outcome }) =>
273 outcome.status === "failed" ? [`${label}: ${outcome.message}`] : [],
274 ),
275 ...input.providers.flatMap((outcome) => (outcome.status === "failed" ? [outcome.line] : [])),
276 ],
277 };
278}
279
280/**
281 * The one toast for a finished pass, such as "Updated this app, 8 servers,
282 * and 3 providers" with a line per failure. `appVersion` is the version this
283 * app runs now: after the relaunch it tells whether the local install
284 * applied. Null when nothing finished, for example every request was
285 * interrupted.
286 */
287export function getUpdateEverythingToastView(
288 result: UpdateEverythingResult,
289 appVersion: string,
290): {
291 readonly type: "success" | "error";
292 readonly title: string;
293 readonly description?: string;
294} | null {
295 const parts = [
296 result.localVersion !== null && result.localVersion === appVersion ? "this app" : null,
297 countLabel(result.updatedServers, "server"),
298 countLabel(result.updatedProviders, "provider"),
299 ].filter((part) => part !== null);
300 if (result.failures.length === 0) {
301 return parts.length === 0 ? null : { type: "success", title: `Updated ${joinList(parts)}` };
302 }
303 return {
304 type: "error",
305 title: parts.length === 0 ? "Update failed" : `Updated ${joinList(parts)}`,
306 description: result.failures.join("\n"),
307 };
138308}
139309
140310export interface UpdateEverythingPlan {
apps/web/src/components/sidebar/updateEverythingRun.tsx created+142
......@@ -0,0 +1,142 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { EnvironmentId } from "@t3tools/contracts";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import { Atom } from "effect/reactivity";
6import { useEffect } from "react";
7
8import { APP_VERSION } from "~/branding";
9import { appAtomRegistry } from "~/rpc/atomRegistry";
10import { useDesktopUpdateState } from "~/state/desktopUpdate";
11import { stackedThreadToast, toastManager } from "../ui/toast";
12import {
13 getUpdateEverythingToastView,
14 type UpdateEverythingResult,
15 type UpdateEverythingRun,
16} from "./updateEverything.logic";
17
18/** The running "update everything" pass, or null. Outlives the sidebar button. */
19const updateEverythingRunAtom = Atom.make<UpdateEverythingRun | null>(null).pipe(
20 Atom.keepAlive,
21 Atom.withLabel("update-everything-run"),
22);
23
24export function useUpdateEverythingRun(): UpdateEverythingRun | null {
25 return useAtomValue(updateEverythingRunAtom);
26}
27
28/** Starts, changes, or ends (null) the running pass. */
29export function setUpdateEverythingRun(
30 update: (run: UpdateEverythingRun | null) => UpdateEverythingRun | null,
31): void {
32 appAtomRegistry.update(updateEverythingRunAtom, update);
33}
34
35/** Records the result of every update for one machine in the running pass. */
36export function settleUpdateRunMachine(environmentId: EnvironmentId, failed: boolean): void {
37 setUpdateEverythingRun(
38 (run) =>
39 run && {
40 ...run,
41 machines: run.machines.map((machine) =>
42 machine.environmentId === environmentId
43 ? { ...machine, result: failed ? "failed" : "done" }
44 : machine,
45 ),
46 },
47 );
48}
49
50const SAVED_RESULT_KEY = "t3code:update-everything-result:v1";
51// A result older than this belongs to an install that never relaunched the app.
52const SAVED_RESULT_MAX_AGE_MS = 10 * 60 * 1000;
53
54const SavedResult = Schema.Struct({
55 savedAt: Schema.Number,
56 localVersion: Schema.NullOr(Schema.String),
57 updatedServers: Schema.Number,
58 updatedProviders: Schema.Number,
59 failures: Schema.Array(Schema.String),
60});
61const decodeSavedResult = Schema.decodeUnknownOption(Schema.fromJsonString(SavedResult));
62
63/** Keeps a result through this app's relaunch. Call it right before the install. */
64export function saveUpdateEverythingResult(result: UpdateEverythingResult): void {
65 try {
66 window.localStorage.setItem(
67 SAVED_RESULT_KEY,
68 JSON.stringify({ ...result, savedAt: Date.now() } satisfies typeof SavedResult.Type),
69 );
70 } catch {
71 // Without storage the summary is lost with the relaunch; the update still runs.
72 }
73}
74
75export function clearSavedUpdateEverythingResult(): void {
76 try {
77 window.localStorage.removeItem(SAVED_RESULT_KEY);
78 } catch {
79 // Nothing to clear.
80 }
81}
82
83function takeSavedUpdateEverythingResult(): UpdateEverythingResult | null {
84 try {
85 const raw = window.localStorage.getItem(SAVED_RESULT_KEY);
86 if (raw === null) return null;
87 window.localStorage.removeItem(SAVED_RESULT_KEY);
88 return Option.getOrNull(
89 Option.filter(
90 decodeSavedResult(raw),
91 (saved) => Date.now() - saved.savedAt < SAVED_RESULT_MAX_AGE_MS,
92 ),
93 );
94 } catch {
95 return null;
96 }
97}
98
99/** Shows the one summary toast for a finished pass. */
100export function showUpdateEverythingResult(result: UpdateEverythingResult): void {
101 const view = getUpdateEverythingToastView(result, APP_VERSION);
102 if (!view) return;
103 toastManager.add(
104 stackedThreadToast({
105 type: view.type,
106 title: view.title,
107 ...(view.description
108 ? { description: <span className="whitespace-pre-line">{view.description}</span> }
109 : {}),
110 }),
111 );
112}
113
114let notifiedNotAppliedVersion: string | null = null;
115
116/**
117 * Desktop launch notices for updates: the summary of a pass that ended with
118 * this app's relaunch, and a warning when the last install did not apply.
119 * Mount once at the app root.
120 */
121export function DesktopUpdateLaunchNotice() {
122 const notAppliedVersion = useDesktopUpdateState()?.notAppliedVersion ?? null;
123
124 useEffect(() => {
125 const result = takeSavedUpdateEverythingResult();
126 if (result) showUpdateEverythingResult(result);
127 }, []);
128
129 useEffect(() => {
130 if (notAppliedVersion === null || notifiedNotAppliedVersion === notAppliedVersion) return;
131 notifiedNotAppliedVersion = notAppliedVersion;
132 toastManager.add(
133 stackedThreadToast({
134 type: "error",
135 title: "Update did not apply",
136 description: `T3 Code is still on ${APP_VERSION}. Install ${notAppliedVersion} again from the update button.`,
137 }),
138 );
139 }, [notAppliedVersion]);
140
141 return null;
142}
apps/web/src/components/sidebar/useUpdateEverything.ts+126-10
......@@ -10,21 +10,37 @@ import { useEnvironments } from "~/state/environments";
1010import { useNpmReleasePublished } from "~/state/npmRelease";
1111import { serverEnvironment } from "~/state/server";
1212import { environmentSession } from "~/state/session";
13import { getProviderUpdateRunOutcome } from "../ProviderUpdateLaunchNotification.logic";
1314import { useProviderUpdates } from "../ProviderUpdatesAction";
1415import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction";
1516import {
1617 collectRestartingMachines,
1718 collectServerUpdateTargets,
19 collectUpdateEverythingResult,
1820 describeUpdateEverything,
1921 runUpdateEverything,
22 type SkippedMachine,
23 type UpdateRunMachine,
2024} from "./updateEverything.logic";
25import {
26 clearSavedUpdateEverythingResult,
27 saveUpdateEverythingResult,
28 setUpdateEverythingRun,
29 settleUpdateRunMachine,
30 showUpdateEverythingResult,
31} from "./updateEverythingRun";
32
33/** Where this app's own download or install reports a failure. */
34export type ReportLocalUpdateError = (title: string, description: string) => void;
2135
2236/**
2337 * The remote half of the sidebar update button: outdated providers on every
2438 * connected machine, and servers behind the version this client runs or is
2539 * about to install (`localUpdateVersion`). Servers that install from npm appear
2640 * only once npm confirms that version. `run` performs the whole pass, including
27 * this app's own download and install when given.
41 * this app's own download and install when given. It updates quietly, tracks
42 * each machine in the run state, and reports one summary toast at the end.
43 * When this app installs, the summary waits for the relaunch.
2844 */
2945export function useUpdateEverything(localUpdateVersion: string | null) {
3046 const targetVersion = localUpdateVersion ?? APP_VERSION;
......@@ -50,7 +66,10 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
5066 [environments],
5167 );
5268 const machines = useAtomValue(machinesAtom);
53 const behindServers = collectServerUpdateTargets(machines, targetVersion);
69 const { targets: behindServers, skipped: unreachable } = collectServerUpdateTargets(
70 machines,
71 targetVersion,
72 );
5473 // A desktop-managed server installs from its own app's update feed, so only
5574 // servers that download t3 from npm wait for npm to publish the version.
5675 const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed");
......@@ -58,6 +77,16 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
5877 const servers = published
5978 ? behindServers
6079 : behindServers.filter((server) => server.selfUpdate === "desktop-managed");
80 const skipped: ReadonlyArray<SkippedMachine> = [
81 ...unreachable,
82 ...behindServers
83 .filter((server) => !servers.includes(server))
84 .map((server) => ({
85 environmentId: server.environmentId,
86 label: server.serverLabel,
87 reason: `t3@${targetVersion} is not on npm yet`,
88 })),
89 ];
6190 const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null);
6291 const providers = useProviderUpdates();
6392 const updateServer = useServerUpdate();
......@@ -82,13 +111,100 @@ export function useUpdateEverything(localUpdateVersion: string | null) {
82111 lines,
83112 /** The servers this run restarts, which stops their running threads. */
84113 restarts,
85 run: (local: { downloadLocal?: () => Promise<boolean>; installLocal?: () => Promise<void> }) =>
86 runUpdateEverything({
87 ...local,
88 updateProviders: providers.updateAll,
89 updateServers: async () => {
90 await Promise.all(servers.map((server) => updateServer(server)));
91 },
92 }),
114 /** Behind machines the run leaves alone, with the reason. */
115 skipped,
116 /** Runs the pass. `installLocal` resolves true when this app is relaunching.
117 Both report failures to the pass, which lists them in its summary. */
118 run: async ({
119 downloadLocal,
120 installLocal,
121 }: {
122 downloadLocal?: (reportError: ReportLocalUpdateError) => Promise<boolean>;
123 installLocal?: (reportError: ReportLocalUpdateError) => Promise<boolean>;
124 }) => {
125 const localFailures: Array<string> = [];
126 const reportLocalError: ReportLocalUpdateError = (title, description) => {
127 localFailures.push(`${title}: ${description}`);
128 };
129 const machinesById = new Map<UpdateRunMachine["environmentId"], UpdateRunMachine>();
130 for (const machine of providers.machines) {
131 machinesById.set(machine.environmentId, {
132 environmentId: machine.environmentId,
133 label: machine.label,
134 updatesProviders: true,
135 updatesServer: false,
136 result: null,
137 });
138 }
139 for (const server of servers) {
140 const existing = machinesById.get(server.environmentId);
141 machinesById.set(server.environmentId, {
142 environmentId: server.environmentId,
143 label: server.serverLabel,
144 updatesProviders: existing?.updatesProviders ?? false,
145 updatesServer: true,
146 result: null,
147 });
148 }
149 const localVersion = installLocal ? localUpdateVersion : null;
150 setUpdateEverythingRun(() => ({
151 localVersion,
152 phase: "providers",
153 machines: Array.from(machinesById.values()),
154 }));
155 const failedProviderMachines = new Set<UpdateRunMachine["environmentId"]>();
156 try {
157 const outcome = await runUpdateEverything({
158 ...(downloadLocal ? { downloadLocal: () => downloadLocal(reportLocalError) } : {}),
159 updateProviders: async () => {
160 const runs = await providers.updateAll({ quiet: true });
161 const outcomes = runs.map((run) => {
162 const runOutcome = getProviderUpdateRunOutcome(run);
163 // An interrupted update did not finish either, so its row is not "done".
164 if (runOutcome.status !== "updated") failedProviderMachines.add(run.environmentId);
165 return runOutcome;
166 });
167 for (const machine of machinesById.values()) {
168 if (!machine.updatesServer) {
169 settleUpdateRunMachine(
170 machine.environmentId,
171 failedProviderMachines.has(machine.environmentId),
172 );
173 }
174 }
175 setUpdateEverythingRun((run) => run && { ...run, phase: "servers" });
176 return outcomes;
177 },
178 updateServers: () =>
179 Promise.all(
180 servers.map(async (server) => {
181 const serverOutcome = await updateServer(server, { quiet: true });
182 settleUpdateRunMachine(
183 server.environmentId,
184 serverOutcome.status === "failed" ||
185 failedProviderMachines.has(server.environmentId),
186 );
187 return { label: server.serverLabel, outcome: serverOutcome };
188 }),
189 ),
190 });
191 const result = () =>
192 collectUpdateEverythingResult({
193 localVersion: null,
194 localFailures,
195 servers: outcome.servers,
196 providers: outcome.providers,
197 });
198 if (outcome.localReady && installLocal) {
199 setUpdateEverythingRun((run) => run && { ...run, phase: "installing" });
200 saveUpdateEverythingResult({ ...result(), localVersion });
201 if (await installLocal(reportLocalError)) return;
202 clearSavedUpdateEverythingResult();
203 }
204 showUpdateEverythingResult(result());
205 } finally {
206 setUpdateEverythingRun(() => null);
207 }
208 },
93209 };
94210}
apps/web/src/routes/__root.tsx+2
......@@ -30,6 +30,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp
3030import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator";
3131import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
3232import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
33import { DesktopUpdateLaunchNotice } from "../components/sidebar/updateEverythingRun";
3334import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
3435import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
3536import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
......@@ -265,6 +266,7 @@ function RootRouteView() {
265266 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
266267 ) : null}
267268 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
269 {isElectron ? <DesktopUpdateLaunchNotice /> : null}
268270 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
269271 <NightlyMobileBetaNotice />
270272 {appShell}
docs/internals/server-updates.md+7-4
......@@ -64,8 +64,11 @@ opt-out. The
6464[v2 recovery service](../../apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts)
6565requires matching durable run, provider thread, session, and native resume identity.
6666Queued runs never started, so recovery holds them and continues the run they wait
67behind. A finished run qualifies only when the restart cancelled its background work;
68its continuation tells the provider what will not report back.
67behind. A settled run qualifies only when the restart cancels work the agent waits on
68(a monitor, subagent, or background task) and its session was live, so stale records
69from an older crash never wake it. Its continuation prompt is the note of what will not
70report back. A shell alone does not wake a settled run: most are dev servers nobody
71waits on, so that note waits for the next user message.
6972
7073Recovery retires effects tied to the lost process and records continuation intent
7174in the durable outbox. That intent survives another restart before provider startup.
......@@ -77,8 +80,8 @@ cannot be overwritten by a stale cancellation.
7780The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts)
7881rechecks the preference, provider selection, newer user work, a stop the user requested,
7982and maintenance turns such as `/compact` before dispatching. Intent and delivery both
80refuse a thread that is archived, settled, or still snoozed, a provider turn that already
81ended, and a run waiting on the user: an approval, question, or secret that was pending,
83refuse a thread that is archived, settled, or still snoozed, a cut turn whose provider
84turn already ended, and a run waiting on the user: an approval, question, or secret that was pending,
8285or that recovery closed on the cut turn. Message-capable questions stay answerable, so
8386they do not block. Delivery also stops after three consecutive continuations in one
8487`restartContinuationOfRunId` chain, so a turn that crashes the server cannot loop. Stable
docs/user/updating.md+10-3
......@@ -16,9 +16,13 @@ installs the latest release from its own channel. Hover the button to see what
1616it will update.
1717
1818Server updates appear only after that version is published to npm. Remote
19desktop apps do not wait for npm. Servers that need
20a manual update are not included. Use the notice or **Settings → Connections**
21for those.
19desktop apps do not wait for npm. The confirmation lists each machine it skips
20and why, such as a machine that is offline or needs a manual update. Use the
21notice or **Settings → Connections** for those.
22
23If a desktop app restarts on its old version, T3 Code tells you the update did
24not apply. It does not install that version again on its own, even when another
25device asks. Use the update button on that machine to retry.
2226
2327The confirmation counts the running threads that the restarts will interrupt,
2428and how many can resume afterwards. See **Continue threads after restarts**
......@@ -37,6 +41,9 @@ different value, use **Apply to all** in Settings after it connects.
3741T3 Code must start again on that machine;
3842the setting does not enable automatic startup. Terminal commands may still be
3943interrupted, and threads without saved provider resume state need a new message.
44A thread that finished its turn to wait on a monitor, subagent, or background task
45is woken and told that this work stopped. A background shell, such as a dev server,
46does not wake it; the thread learns about it with your next message.
4047A thread is not resumed while it waits for your approval, answer, or secret, or
4148while it is archived, snoozed, or settled. After three automatic resumes in a row,
4249the thread waits for your next message.
packages/contracts/src/ipc.ts+7
......@@ -284,6 +284,12 @@ export interface DesktopUpdateState {
284284 message: string | null;
285285 errorContext: "check" | "download" | "install" | null;
286286 canRetry: boolean;
287 /**
288 * The version the last install handed to the OS installer that did not
289 * apply: the app came back on its old version. Server-triggered updates do
290 * not install it again; the user can retry it from the update button.
291 */
292 notAppliedVersion?: string;
287293}
288294
289295export interface DesktopUpdateReleaseNote {
......@@ -315,6 +321,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({
315321 message: Schema.NullOr(Schema.String),
316322 errorContext: Schema.NullOr(Schema.Literals(["check", "download", "install"])),
317323 canRetry: Schema.Boolean,
324 notAppliedVersion: Schema.optionalKey(Schema.String),
318325});
319326
320327/** The desktop app's `t3` command on PATH, managed from Settings. */
packages/shared/src/AgentScope.ts+6
......@@ -25,6 +25,12 @@ export interface AgentScopeShape {
2525 readonly name: string;
2626 readonly threadId?: string | undefined;
2727 readonly env?: NodeJS.ProcessEnv | undefined;
28 /**
29 * Set for terminals, so a job the user detached (tmux, nohup) outlives a
30 * server restart. Agents leave it unset: the next server stops agents a
31 * killed server left running before it resumes their sessions.
32 */
33 readonly survivesRestart?: boolean | undefined;
2834 }) => Effect.Effect<AgentScopeCommand>;
2935 /**
3036 * Whether the latest agent scope of this thread was killed because the