authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-15 06:20:20+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-15 03:20:20+00:00
log21bedfb175b9eec8652adc35d7882bdc3c3c9d59
treeb7c484f3a589fd093f16aeb89cafa848fa0c81cb
parentc814e51af2ba962c9291c9459ee64b4f57e0b5ce
signature Signed by PGP key B5690EEEBB952194

feat(storage): clean up settled thread worktrees (#15208)

Co-authored-by: maria-rcks <maria@kuuro.net>

28 files changed, 1594 insertions(+), 473 deletions(-)

apps/server/src/environment/ServerEnvironment.test.ts+7
......@@ -118,6 +118,13 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
118118 expect(descriptor.capabilities.serverSelfUpdate).toBe(
119119 mode === "web" ? undefined : "desktop-managed",
120120 );
121 expect(descriptor.capabilities).toMatchObject({
122 settledWorktreeCleanup: true,
123 storageCleanupRun: true,
124 projectWorktreeCleanup: true,
125 worktreesDirectory: true,
126 threadAutoSettlementScope: true,
127 });
121128 }
122129 }),
123130 );
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -231,6 +231,7 @@ export const make = Effect.gen(function* () {
231231 storageCleanupRun: true,
232232 otlpEndpointCheck: true,
233233 projectWorktreeCleanup: true,
234 settledWorktreeCleanup: true,
234235 worktreesDirectory: true,
235236 threadAutoSettlementScope: true,
236237 threadRestartContinuation: true,
apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts+25
......@@ -16,7 +16,9 @@ import * as Layer from "effect/Layer";
1616import * as Option from "effect/Option";
1717import * as Path from "effect/Path";
1818import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
19import * as GitWorkflowService from "../git/GitWorkflowService.ts";
1920import * as CheckpointRollbackService from "./CheckpointRollbackService.ts";
21import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts";
2022import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
2123import { CheckpointServiceV2 } from "./CheckpointService.ts";
2224import { EventSinkV2 } from "./EventSink.ts";
......@@ -105,6 +107,7 @@ it.effect.each([
105107 NodeServices.layer,
106108 ThreadCommandExecutor.layer,
107109 IdAllocator.layer,
110 Layer.mock(GitWorkflowService.GitWorkflowService)({}),
108111 Layer.mock(ProjectStore.ProjectStoreV2)({
109112 get: () => Effect.succeed(Option.some({ workspaceRoot: parent } as never)),
110113 }),
......@@ -197,3 +200,25 @@ it.effect.each([
197200 }
198201 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
199202);
203
204it.effect("lets rollback recreate a worktree storage cleanup removed", () =>
205 Effect.gen(function* () {
206 const fileSystem = yield* FileSystem.FileSystem;
207 const path = yield* Path.Path;
208 const parent = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-v2-restore-removed-" });
209 const worktreePath = path.join(parent, "worktree");
210 const isolated = yield* isCheckpointRestoreIsolated(
211 { id: ThreadId.make("restore-removed"), worktreePath },
212 { cwd: worktreePath },
213 {
214 fileSystem,
215 path,
216 projections: {
217 getShellSnapshot: () => Effect.succeed({ threads: [], archivedThreads: [] }),
218 } as never,
219 projects: {} as never,
220 },
221 );
222 assert.isTrue(isolated);
223 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
224);
apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts+13-2
......@@ -33,8 +33,19 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi
3333 const worktreePath = thread.worktreePath;
3434 let shared = worktreePath == null;
3535 if (!shared && worktreePath !== null) {
36 const cwd = yield* fileSystem.realPath(scope.cwd);
37 const worktreeCwd = yield* fileSystem.realPath(worktreePath);
36 // Storage cleanup may have removed the worktree; rollback recreates it.
37 const resolve = (target: string) =>
38 fileSystem
39 .realPath(target)
40 .pipe(
41 Effect.catch((error) =>
42 error.reason._tag === "NotFound"
43 ? Effect.succeed(path.resolve(target))
44 : Effect.fail(error),
45 ),
46 );
47 const cwd = yield* resolve(scope.cwd);
48 const worktreeCwd = yield* resolve(worktreePath);
3849 shared = cwd !== worktreeCwd;
3950 if (!shared) {
4051 const shell = yield* projections.getShellSnapshot();
apps/server/src/orchestration-v2/CheckpointRollbackService.test.ts+436-206
......@@ -4,18 +4,24 @@ import {
44 CheckpointId,
55 CheckpointScopeId,
66 type OrchestrationV2ThreadProjection,
7 ProjectId,
78 ProviderInstanceId,
89 ProviderSessionId,
910 ProviderThreadId,
1011 ThreadId,
1112} from "@t3tools/contracts";
1213import * as Effect from "effect/Effect";
14import * as Deferred from "effect/Deferred";
15import * as Fiber from "effect/Fiber";
1316import * as FileSystem from "effect/FileSystem";
1417import * as Path from "effect/Path";
1518import * as Option from "effect/Option";
1619import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1720import * as Layer from "effect/Layer";
1821
22import * as GitWorkflow from "../git/GitWorkflowService.ts";
23import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
24import { ProviderWorkspaceMissingError } from "../provider/Errors.ts";
1925import { resolveCodexRollbackTurnCount } from "./Adapters/CodexAdapterV2.ts";
2026import { isCheckpointRestoreIsolated } from "./CheckpointRestoreSafety.ts";
2127import * as CheckpointService from "./CheckpointService.ts";
......@@ -38,76 +44,121 @@ const layerCheckpointRollbackService = CheckpointRollbackService.layer.pipe(
3844 Layer.provide(
3945 Layer.mergeAll(
4046 NodeServices.layer,
47 Layer.mock(GitWorkflow.GitWorkflowService)({}),
4148 Layer.mock(ProjectStore.ProjectStoreV2)({ get: () => Effect.succeed(unrelatedProject) }),
4249 ),
4350 ),
4451);
4552
46it.effect("rejects a non-ready checkpoint before opening a session or restoring files", () => {
47 const threadId = ThreadId.make("thread:rollback-non-ready");
48 const providerThreadId = ProviderThreadId.make("provider-thread:rollback-non-ready");
49 const providerSessionId = ProviderSessionId.make("provider-session:rollback-non-ready");
50 const checkpointId = CheckpointId.make("checkpoint:rollback-non-ready");
51 const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-non-ready");
52 const providerInstanceId = ProviderInstanceId.make("provider_rollback_non_ready");
53 const restore = vi.fn(() => Effect.die("checkpoint restore must not run"));
54 const open = vi.fn(() => Effect.die("provider session open must not run"));
55 const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run"));
56 const projection = {
57 thread: {
58 worktreePath: process.cwd(),
59 activeProviderThreadId: providerThreadId,
60 modelSelection: { instanceId: providerInstanceId, model: "test-model" },
61 },
62 providerThreads: [{ id: providerThreadId, providerSessionId, providerInstanceId }],
63 checkpoints: [{ id: checkpointId, scopeId, status: "stale" }],
64 checkpointScopes: [{ id: scopeId, cwd: process.cwd() }],
65 } as unknown as OrchestrationV2ThreadProjection;
66 const layerTest = layerCheckpointRollbackService.pipe(
67 Layer.provide(
68 Layer.mergeAll(
69 ThreadCommandExecutor.layer,
70 Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }),
71 Layer.mock(EventSink.EventSinkV2)({}),
72 IdAllocator.layer,
73 Layer.mock(ProjectionStore.ProjectionStoreV2)({
74 getThreadRecords: () => Effect.succeed(projection),
75 getShellSnapshot: () =>
76 Effect.succeed({
77 schemaVersion: 1,
78 snapshotSequence: 0,
79 threads: [],
80 archivedThreads: [],
81 }),
82 }),
83 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }),
84 Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy }),
53it.effect.each(["initial", "waiting"])(
54 "rejects a non-ready checkpoint from %s before opening a session or restoring files",
55 (state) => {
56 const threadId = ThreadId.make("thread:rollback-non-ready");
57 const providerThreadId = ProviderThreadId.make("provider-thread:rollback-non-ready");
58 const providerSessionId = ProviderSessionId.make("provider-session:rollback-non-ready");
59 const checkpointId = CheckpointId.make("checkpoint:rollback-non-ready");
60 const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-non-ready");
61 const providerInstanceId = ProviderInstanceId.make("provider_rollback_non_ready");
62 const restore = vi.fn(() => Effect.die("checkpoint restore must not run"));
63 const open = vi.fn(() => Effect.die("provider session open must not run"));
64 const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run"));
65 const readEntered = Deferred.makeUnsafe<void>();
66 const release = Deferred.makeUnsafe<void>();
67 let readCount = 0;
68 let projection = {
69 thread: {
70 worktreePath: process.cwd(),
71 activeProviderThreadId: providerThreadId,
72 modelSelection: { instanceId: providerInstanceId, model: "test-model" },
73 },
74 providerThreads: [{ id: providerThreadId, providerSessionId, providerInstanceId }],
75 checkpoints: [
76 {
77 id: checkpointId,
78 scopeId,
79 status: state === "waiting" ? "ready" : "stale",
80 appRunOrdinal: 0,
81 },
82 ],
83 checkpointScopes: [{ id: scopeId, cwd: process.cwd() }],
84 } as unknown as OrchestrationV2ThreadProjection;
85 const getThreadRecords = vi.fn<ProjectionStore.ProjectionStoreV2Shape["getThreadRecords"]>(
86 () => {
87 const current = projection;
88 readCount++;
89 return readCount === 1
90 ? Deferred.succeed(readEntered, undefined).pipe(Effect.as(current))
91 : Effect.succeed(current);
92 },
93 );
94 const testLayer = layerCheckpointRollbackService.pipe(
95 Layer.provide(
96 Layer.mergeAll(
97 ThreadCommandExecutor.layer,
98 Layer.mock(CheckpointService.CheckpointServiceV2)({ restore }),
99 Layer.mock(EventSink.EventSinkV2)({}),
100 IdAllocator.layer,
101 Layer.mock(ProjectionStore.ProjectionStoreV2)({
102 getThreadRecords,
103 getShellSnapshot: () =>
104 Effect.succeed({
105 schemaVersion: 1,
106 snapshotSequence: 0,
107 threads: [],
108 archivedThreads: [],
109 }),
110 }),
111 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }),
112 Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: resolveRuntimePolicy }),
113 ),
85114 ),
86 ),
87 );
115 );
88116
89 return Effect.gen(function* () {
90 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2;
91 const error = yield* service
92 .execute({
93 threadId,
94 providerThreadId,
95 checkpointId,
96 scopeId,
97 })
98 .pipe(Effect.flip);
117 return Effect.gen(function* () {
118 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2;
119 const holder =
120 state === "waiting"
121 ? yield* withWorkspaceLease(process.cwd(), Deferred.await(release)).pipe(
122 Effect.forkChild({ startImmediately: true }),
123 )
124 : undefined;
125 const rollback = yield* service
126 .execute({
127 threadId,
128 providerThreadId,
129 checkpointId,
130 scopeId,
131 })
132 .pipe(Effect.flip, Effect.forkChild({ startImmediately: true }));
133 if (state === "waiting") {
134 yield* Deferred.await(readEntered);
135 projection = {
136 ...projection,
137 checkpoints: projection.checkpoints.map((checkpoint) => ({
138 ...checkpoint,
139 status: "stale",
140 })),
141 };
142 yield* Deferred.succeed(release, undefined);
143 if (holder !== undefined) yield* Fiber.join(holder);
144 }
145 const error = yield* Fiber.join(rollback);
99146
100 assert.equal(error.reason, "rollback-target-invalid");
101 assert.equal(
102 error.message,
103 `Rollback target ${checkpointId} for provider thread ${providerThreadId} on thread ${threadId} is incomplete or invalid.`,
104 );
105 assert.equal(error.cause, undefined);
106 assert.equal(resolveRuntimePolicy.mock.calls.length, 0);
107 assert.equal(open.mock.calls.length, 0);
108 assert.equal(restore.mock.calls.length, 0);
109 }).pipe(Effect.provide(layerTest));
110});
147 assert.equal(getThreadRecords.mock.calls.length, 2);
148 assert.deepEqual(getThreadRecords.mock.calls[0]?.[1], []);
149 assert.equal(getThreadRecords.mock.calls[1]?.[1].includes("checkpoints"), true);
150 assert.equal(error.reason, "rollback-target-invalid");
151 assert.equal(
152 error.message,
153 `Rollback target ${checkpointId} for provider thread ${providerThreadId} on thread ${threadId} is incomplete or invalid.`,
154 );
155 assert.equal(error.cause, undefined);
156 assert.equal(resolveRuntimePolicy.mock.calls.length, 0);
157 assert.equal(open.mock.calls.length, 0);
158 assert.equal(restore.mock.calls.length, 0);
159 }).pipe(Effect.provide(testLayer));
160 },
161);
111162
112163it.effect("rejects a rollback when another provider thread became active", () => {
113164 const threadId = ThreadId.make("thread:rollback-inactive-provider-thread");
......@@ -128,6 +179,7 @@ it.effect("rejects a rollback when another provider thread became active", () =>
128179 const resolveRuntimePolicy = vi.fn(() => Effect.die("runtime policy resolution must not run"));
129180 const projection = {
130181 thread: {
182 worktreePath: null,
131183 activeProviderThreadId,
132184 modelSelection: { instanceId: providerInstanceId, model: "test-model" },
133185 },
......@@ -268,7 +320,7 @@ it.effect("rejects a rollback when provider selection changed before execution",
268320 }).pipe(Effect.provide(layerTest));
269321});
270322
271it.effect("reports a missing provider turn as a structured rollback failure", () => {
323it.effect("rejects an unavailable rollback target before opening a session", () => {
272324 const threadId = ThreadId.make("thread:rollback-provider-turn-unavailable");
273325 const providerThreadId = ProviderThreadId.make(
274326 "provider-thread:rollback-provider-turn-unavailable",
......@@ -280,9 +332,11 @@ it.effect("reports a missing provider turn as a structured rollback failure", ()
280332 const scopeId = CheckpointScopeId.make("checkpoint-scope:rollback-provider-turn-unavailable");
281333 const providerInstanceId = ProviderInstanceId.make("provider_rollback_provider_turn_unavailable");
282334 const restore = vi.fn(() => Effect.die("checkpoint restore must not run"));
335 const open = vi.fn(() => Effect.die("provider session open must not run"));
283336 const projection = {
284337 thread: {
285 worktreePath: process.cwd(),
338 worktreePath: "/tmp/missing-invalid-rollback-worktree",
339 branch: "feature/rollback",
286340 activeProviderThreadId: providerThreadId,
287341 modelSelection: { instanceId: providerInstanceId, model: "test-model" },
288342 },
......@@ -312,7 +366,7 @@ it.effect("reports a missing provider turn as a structured rollback failure", ()
312366 }),
313367 }),
314368 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
315 open: () => Effect.succeed({} as never),
369 open,
316370 }),
317371 Layer.mock(RuntimePolicy.RuntimePolicyV2)({
318372 resolve: () => Effect.succeed({} as never),
......@@ -324,14 +378,9 @@ it.effect("reports a missing provider turn as a structured rollback failure", ()
324378 return Effect.gen(function* () {
325379 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2;
326380 const error = yield* service
327 .execute({
328 threadId,
329 providerThreadId,
330 checkpointId,
331 scopeId,
332 })
381 .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles: false })
333382 .pipe(Effect.flip);
334
383 assert.equal(open.mock.calls.length, 0);
335384 assert.equal(error.reason, "provider-turn-unavailable");
336385 assert.equal(
337386 error.message,
......@@ -342,6 +391,169 @@ it.effect("reports a missing provider turn as a structured rollback failure", ()
342391 }).pipe(Effect.provide(layerTest));
343392});
344393
394it.effect.each([
395 { order: "normal", restoreFiles: true },
396 { order: "cleanup-first", restoreFiles: true },
397 { order: "cleanup-first", restoreFiles: false },
398 { order: "rollback-first", restoreFiles: true },
399])("locks rollback with %s", ({ order, restoreFiles }) =>
400 Effect.gen(function* () {
401 const fileSystem = yield* FileSystem.FileSystem;
402 const path = yield* Path.Path;
403 const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-rollback-" });
404 const worktreePath = path.join(workspaceRoot, "worktree");
405 yield* fileSystem.makeDirectory(worktreePath);
406 const threadId = ThreadId.make("rollback-worktree");
407 const projectId = ProjectId.make("rollback-worktree-project");
408 const providerThreadId = ProviderThreadId.make("rollback-worktree-provider-thread");
409 const providerSessionId = ProviderSessionId.make("rollback-worktree-provider-session");
410 const providerInstanceId = ProviderInstanceId.make("rollback-worktree-provider-instance");
411 const checkpointId = CheckpointId.make("rollback-worktree-checkpoint");
412 const scopeId = CheckpointScopeId.make("rollback-worktree-scope");
413 const branch = "feature/rollback";
414 const release = Deferred.makeUnsafe<void>();
415 const openEntered = Deferred.makeUnsafe<void>();
416 const calls: string[] = [];
417 let cleanupEntered = false;
418 const providerThread = { id: providerThreadId, providerSessionId, providerInstanceId };
419 const projection = {
420 thread: {
421 id: threadId,
422 projectId,
423 worktreePath,
424 branch,
425 activeProviderThreadId: providerThreadId,
426 modelSelection: { instanceId: providerInstanceId, model: "test-model" },
427 },
428 providerThreads: [providerThread],
429 providerSessions: [],
430 checkpoints: [{ id: checkpointId, scopeId, status: "ready", appRunOrdinal: 0 }],
431 checkpointScopes: [{ id: scopeId, cwd: worktreePath }],
432 runs: [],
433 attempts: [],
434 nodes: [],
435 providerTurns: [],
436 } as unknown as OrchestrationV2ThreadProjection;
437 const testLayer = CheckpointRollbackService.layer.pipe(
438 Layer.provide(
439 Layer.mergeAll(
440 ThreadCommandExecutor.layer,
441 NodeServices.layer,
442 Layer.mock(ProjectStore.ProjectStoreV2)({
443 get: (id) => {
444 assert.equal(id, projectId);
445 return Effect.succeed(Option.some({ workspaceRoot } as never));
446 },
447 }),
448 Layer.mock(GitWorkflow.GitWorkflowService)({
449 pruneWorktrees: (input) =>
450 Effect.sync(() => {
451 assert.deepEqual(input, { cwd: workspaceRoot });
452 calls.push("prune");
453 }),
454 createWorktree: (input) =>
455 Effect.gen(function* () {
456 assert.deepEqual(input, {
457 cwd: workspaceRoot,
458 refName: branch,
459 path: worktreePath,
460 });
461 calls.push("create");
462 yield* fileSystem.makeDirectory(worktreePath).pipe(Effect.orDie);
463 return {} as never;
464 }),
465 }),
466 Layer.mock(CheckpointService.CheckpointServiceV2)({
467 restore: () =>
468 Effect.gen(function* () {
469 assert.isTrue(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie));
470 calls.push("files");
471 }),
472 }),
473 Layer.mock(EventSink.EventSinkV2)({
474 write: () =>
475 Effect.sync(() => {
476 calls.push("projection");
477 return [];
478 }),
479 }),
480 IdAllocator.layer,
481 Layer.mock(ProjectionStore.ProjectionStoreV2)({
482 getThreadRecords: () => Effect.succeed(projection),
483 getShellSnapshot: () =>
484 Effect.succeed({
485 schemaVersion: 1,
486 snapshotSequence: 0,
487 threads: [],
488 archivedThreads: [],
489 }),
490 }),
491 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
492 open: (input) =>
493 Effect.gen(function* () {
494 assert.equal(input.runtimePolicy.cwd, worktreePath);
495 if (!(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie))) {
496 return yield* new ProviderWorkspaceMissingError({ threadId, cwd: worktreePath });
497 }
498 calls.push("session");
499 yield* Deferred.succeed(openEntered, undefined);
500 if (order === "rollback-first") yield* Deferred.await(release);
501 return {} as never;
502 }),
503 }),
504 Layer.mock(RuntimePolicy.RuntimePolicyV2)({
505 resolve: ({ thread }) => {
506 assert.equal(thread.worktreePath, worktreePath);
507 return Effect.succeed({ cwd: thread.worktreePath } as never);
508 },
509 }),
510 ),
511 ),
512 );
513 yield* Effect.gen(function* () {
514 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2;
515 const cleanupEffect = withWorkspaceLease(
516 worktreePath,
517 Effect.gen(function* () {
518 cleanupEntered = true;
519 if (order === "cleanup-first") yield* Deferred.await(release);
520 yield* fileSystem.remove(worktreePath, { recursive: true, force: true });
521 calls.push("cleanup");
522 }),
523 );
524 const cleanup =
525 order === "cleanup-first"
526 ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true }))
527 : undefined;
528 const rollback = yield* service
529 .execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles })
530 .pipe(Effect.forkChild({ startImmediately: true }));
531 if (order === "cleanup-first") assert.deepEqual(calls, []);
532 if (order === "rollback-first") yield* Deferred.await(openEntered);
533 const laterCleanup =
534 order === "rollback-first"
535 ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true }))
536 : undefined;
537 if (order === "rollback-first") assert.isFalse(cleanupEntered);
538 yield* Deferred.succeed(release, undefined);
539 if (cleanup !== undefined) yield* Fiber.join(cleanup);
540 yield* Fiber.join(rollback);
541 if (laterCleanup !== undefined) yield* Fiber.join(laterCleanup);
542 const rollbackCalls = restoreFiles
543 ? ["session", "files", "projection"]
544 : ["session", "projection"];
545 assert.deepEqual(
546 calls,
547 order === "cleanup-first"
548 ? ["cleanup", "prune", "create", ...rollbackCalls]
549 : order === "rollback-first"
550 ? [...rollbackCalls, "cleanup"]
551 : rollbackCalls,
552 );
553 }).pipe(Effect.provide(testLayer));
554 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
555);
556
345557it.effect.each([
346558 { restoreFiles: true, shared: "none" },
347559 { restoreFiles: false, shared: "root" },
......@@ -350,143 +562,161 @@ it.effect.each([
350562 { restoreFiles: false, shared: "worktree" },
351563 { restoreFiles: true, shared: "historical" },
352564 { restoreFiles: false, shared: "none", targetOrdinal: 1 },
353])("rewinds safely with %s", ({ restoreFiles, shared, targetOrdinal = 0 }) => {
354 const threadId = ThreadId.make("rewind-files");
355 const providerThreadId = ProviderThreadId.make("rewind-provider");
356 const providerSessionId = ProviderSessionId.make("rewind-session");
357 const instanceId = ProviderInstanceId.make("rewind-instance");
358 const checkpointId = CheckpointId.make("rewind-start");
359 const scopeId = CheckpointScopeId.make("rewind-scope");
360 const calls: string[] = [];
361 let lockedThreads = Effect.succeed<ReadonlyArray<ThreadId>>([]);
362 const layerThreadCommands = Layer.effect(
363 ThreadCommandExecutor.ThreadCommandExecutor,
364 Effect.tap(KeyedLock.make<ThreadId>(), (lock) =>
365 Effect.sync(() => {
366 lockedThreads = lock.activeKeys;
367 }),
368 ),
369 );
370 const providerThread = {
371 id: providerThreadId,
372 providerSessionId,
373 providerInstanceId: instanceId,
374 };
375 const projection = {
376 thread: {
377 worktreePath: shared === "root" ? null : process.cwd(),
378 activeProviderThreadId: providerThreadId,
379 modelSelection: { instanceId, model: "test" },
380 },
381 providerThreads: [providerThread],
382 providerSessions: [],
383 // Turn 3 remains in the audit history after an earlier rollback.
384 providerTurns: [1, 2, 3].map((ordinal) => ({
385 id: `turn-${ordinal}`,
386 providerThreadId,
387 runAttemptId: `attempt-${ordinal}`,
388 ordinal,
389 status: "completed",
390 })),
391 nodes: [],
392 attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, runId: `run-${ordinal}` })),
393 checkpoints: [
394 { id: checkpointId, scopeId, status: "ready", appRunOrdinal: targetOrdinal || null },
395 ],
396 checkpointScopes: [{ id: scopeId, cwd: process.cwd() }],
397 runs: [1, 2, 3].map((ordinal) => ({
398 id: `run-${ordinal}`,
399 ordinal,
400 status: ordinal === 3 ? "rolled_back" : "completed",
401 rootNodeId: null,
402 activeAttemptId: `attempt-${ordinal}`,
403 })),
404 } as unknown as OrchestrationV2ThreadProjection;
405 const layerTest = layerCheckpointRollbackService.pipe(
406 Layer.provide(
407 Layer.mergeAll(
408 layerThreadCommands,
409 Layer.mock(CheckpointService.CheckpointServiceV2)({
410 restore: () =>
411 Effect.sync(() => {
412 calls.push("files");
413 }),
414 }),
415 Layer.mock(EventSink.EventSinkV2)({
416 write: ({ events }) =>
417 Effect.map(lockedThreads, (locked) => {
418 // A thread command that planned against the old provider thread
419 // must not commit after this and undo the rollback.
420 assert.include(locked, threadId);
421 assert.ok(
422 events.some(
423 (event) => event.type === "run.updated" && event.payload.status === "rolled_back",
424 ),
425 );
426 calls.push("projection");
427 return [];
428 }),
429 }),
430 IdAllocator.layer,
431 Layer.mock(ProjectionStore.ProjectionStoreV2)({
432 getThreadRecords: () => Effect.succeed(projection),
433 getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never),
434 getCheckpointContext: () =>
435 Effect.succeed({
436 checkpointScopes: [{ cwd: process.cwd() }],
437 runs: [],
438 checkpoints: [],
439 } as never),
440 getShellSnapshot: () =>
441 Effect.succeed({
442 schemaVersion: 1,
443 snapshotSequence: 0,
444 threads: [],
445 archivedThreads:
446 shared === "worktree" || shared === "historical"
447 ? [
448 {
449 id: ThreadId.make("other-thread"),
450 deletedAt: null,
451 worktreePath: shared === "worktree" ? process.cwd() : null,
452 } as never,
453 ]
454 : [],
455 }),
456 }),
457 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
458 open: () =>
459 Effect.succeed({
460 rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) =>
461 Effect.gen(function* () {
462 const count = yield* resolveCodexRollbackTurnCount(input);
463 assert.equal(count, 2 - targetOrdinal);
464 calls.push("provider");
465 return { providerThread };
466 }),
467 } as never),
565 { restoreFiles: false, shared: "none", targetOrdinal: 1, multipleTurns: true },
566])(
567 "rewinds safely with %s",
568 ({ restoreFiles, shared, targetOrdinal = 0, multipleTurns = false }) => {
569 const threadId = ThreadId.make("rewind-files");
570 const providerThreadId = ProviderThreadId.make("rewind-provider");
571 const providerSessionId = ProviderSessionId.make("rewind-session");
572 const instanceId = ProviderInstanceId.make("rewind-instance");
573 const checkpointId = CheckpointId.make("rewind-start");
574 const scopeId = CheckpointScopeId.make("rewind-scope");
575 const calls: string[] = [];
576 let lockedThreads = Effect.succeed<ReadonlyArray<ThreadId>>([]);
577 const layerThreadCommands = Layer.effect(
578 ThreadCommandExecutor.ThreadCommandExecutor,
579 Effect.tap(KeyedLock.make<ThreadId>(), (lock) =>
580 Effect.sync(() => {
581 lockedThreads = lock.activeKeys;
468582 }),
469 Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }),
470583 ),
471 ),
472 );
473 return Effect.gen(function* () {
474 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2;
475 if (restoreFiles && shared !== "none") {
476 const error = yield* Effect.flip(
477 service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }),
478 );
479 assert.equal(error.reason, "shared-workspace");
480 assert.deepEqual(calls, []);
481 return;
482 }
483 yield* service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles });
484 assert.deepEqual(
485 calls,
486 restoreFiles ? ["provider", "files", "projection"] : ["provider", "projection"],
487584 );
488 }).pipe(Effect.provide(layerTest));
489});
585 const providerThread = {
586 id: providerThreadId,
587 providerSessionId,
588 providerInstanceId: instanceId,
589 };
590 const projection = {
591 thread: {
592 worktreePath: shared === "root" ? null : process.cwd(),
593 activeProviderThreadId: providerThreadId,
594 modelSelection: { instanceId, model: "test" },
595 },
596 providerThreads: [providerThread],
597 providerSessions: [],
598 // Turn 3 remains in the audit history after an earlier rollback.
599 providerTurns: [
600 ...(multipleTurns
601 ? [
602 {
603 id: "turn-0",
604 providerThreadId,
605 runAttemptId: "attempt-1",
606 ordinal: 0,
607 status: "completed",
608 },
609 ]
610 : []),
611 ...[1, 2, 3].map((ordinal) => ({
612 id: `turn-${ordinal}`,
613 providerThreadId,
614 runAttemptId: `attempt-${ordinal}`,
615 ordinal,
616 status: "completed",
617 })),
618 ],
619 nodes: [],
620 attempts: [1, 2, 3].map((ordinal) => ({ id: `attempt-${ordinal}`, runId: `run-${ordinal}` })),
621 checkpoints: [
622 { id: checkpointId, scopeId, status: "ready", appRunOrdinal: targetOrdinal || null },
623 ],
624 checkpointScopes: [{ id: scopeId, cwd: process.cwd() }],
625 runs: [1, 2, 3].map((ordinal) => ({
626 id: `run-${ordinal}`,
627 ordinal,
628 status: ordinal === 3 ? "rolled_back" : "completed",
629 rootNodeId: null,
630 activeAttemptId: `attempt-${ordinal}`,
631 })),
632 } as unknown as OrchestrationV2ThreadProjection;
633 const layerTest = layerCheckpointRollbackService.pipe(
634 Layer.provide(
635 Layer.mergeAll(
636 layerThreadCommands,
637 Layer.mock(CheckpointService.CheckpointServiceV2)({
638 restore: () =>
639 Effect.sync(() => {
640 calls.push("files");
641 }),
642 }),
643 Layer.mock(EventSink.EventSinkV2)({
644 write: ({ events }) =>
645 Effect.map(lockedThreads, (locked) => {
646 // A thread command that planned against the old provider thread
647 // must not commit after this and undo the rollback.
648 assert.include(locked, threadId);
649 assert.ok(
650 events.some(
651 (event) =>
652 event.type === "run.updated" && event.payload.status === "rolled_back",
653 ),
654 );
655 calls.push("projection");
656 return [];
657 }),
658 }),
659 IdAllocator.layer,
660 Layer.mock(ProjectionStore.ProjectionStoreV2)({
661 getThreadRecords: () => Effect.succeed(projection),
662 getThreadProviderContext: () => Effect.succeed({ providerSessions: [] } as never),
663 getCheckpointContext: () =>
664 Effect.succeed({
665 checkpointScopes: [{ cwd: process.cwd() }],
666 runs: [],
667 checkpoints: [],
668 } as never),
669 getShellSnapshot: () =>
670 Effect.succeed({
671 schemaVersion: 1,
672 snapshotSequence: 0,
673 threads: [],
674 archivedThreads:
675 shared === "worktree" || shared === "historical"
676 ? [
677 {
678 id: ThreadId.make("other-thread"),
679 deletedAt: null,
680 worktreePath: shared === "worktree" ? process.cwd() : null,
681 } as never,
682 ]
683 : [],
684 }),
685 }),
686 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
687 open: () =>
688 Effect.succeed({
689 rollbackThread: (input: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) =>
690 Effect.gen(function* () {
691 const count = yield* resolveCodexRollbackTurnCount(input);
692 assert.equal(count, 2 - targetOrdinal);
693 calls.push("provider");
694 return { providerThread };
695 }),
696 } as never),
697 }),
698 Layer.mock(RuntimePolicy.RuntimePolicyV2)({ resolve: () => Effect.succeed({} as never) }),
699 ),
700 ),
701 );
702 return Effect.gen(function* () {
703 const service = yield* CheckpointRollbackService.CheckpointRollbackServiceV2;
704 if (restoreFiles && shared !== "none") {
705 const error = yield* Effect.flip(
706 service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles }),
707 );
708 assert.equal(error.reason, "shared-workspace");
709 assert.deepEqual(calls, []);
710 return;
711 }
712 yield* service.execute({ threadId, providerThreadId, checkpointId, scopeId, restoreFiles });
713 assert.deepEqual(
714 calls,
715 restoreFiles ? ["provider", "files", "projection"] : ["provider", "projection"],
716 );
717 }).pipe(Effect.provide(layerTest));
718 },
719);
490720
491721it.effect.skipIf(!symlinksSupported)(
492722 "rejects an archived thread sharing a worktree through a symlink",
apps/server/src/orchestration-v2/CheckpointRollbackService.ts+58-33
......@@ -11,9 +11,12 @@ import * as DateTime from "effect/DateTime";
1111import * as Effect from "effect/Effect";
1212import * as FileSystem from "effect/FileSystem";
1313import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
1415import * as Path from "effect/Path";
1516import * as Schema from "effect/Schema";
1617
18import * as GitWorkflowService from "../git/GitWorkflowService.ts";
19import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
1720import {
1821 isCheckpointRestoreIsolated,
1922 SHARED_WORKSPACE_RESTORE_MESSAGE,
......@@ -84,6 +87,7 @@ export const layer: Layer.Layer<
8487 CheckpointRollbackServiceV2,
8588 never,
8689 | CheckpointServiceV2
90 | GitWorkflowService.GitWorkflowService
8791 | EventSinkV2
8892 | IdAllocator.IdAllocatorV2
8993 | ProjectionStoreV2
......@@ -97,6 +101,7 @@ export const layer: Layer.Layer<
97101 CheckpointRollbackServiceV2,
98102 Effect.gen(function* () {
99103 const checkpoints = yield* CheckpointServiceV2;
104 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
100105 const eventSink = yield* EventSinkV2;
101106 const ids = yield* IdAllocator.IdAllocatorV2;
102107 const projections = yield* ProjectionStoreV2;
......@@ -158,6 +163,52 @@ export const layer: Layer.Layer<
158163 });
159164 }
160165
166 const targetOrdinal = checkpoint.appRunOrdinal ?? 0;
167 const rollbackTarget: ProviderAdapter.ProviderAdapterV2RollbackTarget =
168 targetOrdinal === 0
169 ? {
170 type: "thread_start",
171 checkpointId: checkpoint.id,
172 appRunOrdinal: 0,
173 }
174 : yield* Effect.gen(function* () {
175 const targetRun = projection.runs.find((run) => run.ordinal === targetOrdinal);
176 const targetAttempt = projection.attempts.find(
177 (attempt) => attempt.id === targetRun?.activeAttemptId,
178 );
179 // A goal run can span several native turns; roll back to its last.
180 const targetTurn =
181 latestProviderTurnForAttempt(projection.providerTurns, targetAttempt?.id) ??
182 projection.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId);
183 if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {
184 return yield* new CheckpointRollbackExecutionError({
185 reason: "provider-turn-unavailable",
186 threadId: input.threadId,
187 providerThreadId: input.providerThreadId,
188 checkpointId: input.checkpointId,
189 });
190 }
191 return {
192 type: "provider_turn" as const,
193 checkpointId: checkpoint.id,
194 appRunOrdinal: targetOrdinal,
195 providerTurn: targetTurn,
196 };
197 });
198
199 const { worktreePath, branch } = projection.thread;
200 if (worktreePath != null && branch != null && !(yield* fileSystem.exists(worktreePath))) {
201 const project = yield* projects.get(projection.thread.projectId);
202 if (Option.isSome(project)) {
203 yield* gitWorkflow.pruneWorktrees({ cwd: project.value.workspaceRoot });
204 yield* gitWorkflow.createWorktree({
205 cwd: project.value.workspaceRoot,
206 refName: branch,
207 path: worktreePath,
208 });
209 }
210 }
211
161212 if (
162213 input.restoreFiles !== false &&
163214 !(yield* isCheckpointRestoreIsolated(projection.thread, scope, {
......@@ -199,7 +250,6 @@ export const layer: Layer.Layer<
199250 }),
200251 });
201252
202 const targetOrdinal = checkpoint.appRunOrdinal ?? 0;
203253 // Stopped and failed runs after the target leave the provider
204254 // conversation too, so they must not stay visible.
205255 const runsToRollback = projection.runs.filter(
......@@ -225,37 +275,6 @@ export const layer: Layer.Layer<
225275 turn.providerThreadId === providerThread.id &&
226276 (turn.runAttemptId === null || !rolledBackAttemptIds.has(turn.runAttemptId)),
227277 );
228 const rollbackTarget: ProviderAdapter.ProviderAdapterV2RollbackTarget =
229 targetOrdinal === 0
230 ? {
231 type: "thread_start",
232 checkpointId: checkpoint.id,
233 appRunOrdinal: 0,
234 }
235 : yield* Effect.gen(function* () {
236 const targetRun = projection.runs.find((run) => run.ordinal === targetOrdinal);
237 const targetAttempt = projection.attempts.find(
238 (attempt) => attempt.id === targetRun?.activeAttemptId,
239 );
240 // A goal run can span several native turns; roll back to its last.
241 const targetTurn =
242 latestProviderTurnForAttempt(projection.providerTurns, targetAttempt?.id) ??
243 projection.providerTurns.find((turn) => turn.id === targetAttempt?.providerTurnId);
244 if (targetTurn === undefined || targetTurn.providerThreadId !== providerThread.id) {
245 return yield* new CheckpointRollbackExecutionError({
246 reason: "provider-turn-unavailable",
247 threadId: input.threadId,
248 providerThreadId: input.providerThreadId,
249 checkpointId: input.checkpointId,
250 });
251 }
252 return {
253 type: "provider_turn" as const,
254 checkpointId: checkpoint.id,
255 appRunOrdinal: targetOrdinal,
256 providerTurn: targetTurn,
257 };
258 });
259278
260279 const snapshot =
261280 runsToRollback.length === 0
......@@ -355,7 +374,13 @@ export const layer: Layer.Layer<
355374
356375 return CheckpointRollbackServiceV2.of({
357376 execute: (input) =>
358 execute(input).pipe(
377 Effect.gen(function* () {
378 // Storage cleanup removes worktrees under the same lease.
379 const { thread } = yield* projections.getThreadRecords(input.threadId, []);
380 yield* thread.worktreePath === null
381 ? execute(input)
382 : withWorkspaceLease(path.resolve(thread.worktreePath), execute(input));
383 }).pipe(
359384 Effect.mapError((cause) =>
360385 isCheckpointRollbackExecutionError(cause)
361386 ? cause
apps/server/src/orchestration-v2/EffectWorker.test.ts+1
......@@ -105,6 +105,7 @@ function layerExecutorFor(input: {
105105 shutdown: Effect.void,
106106 open: () => Effect.die("unused open"),
107107 get: () => Effect.succeed(Option.none()),
108 isLive: () => Effect.succeed(false),
108109 close: () => Effect.void,
109110 closeInstance: () => Effect.void,
110111 release: () => record("release"),
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+19
......@@ -2899,6 +2899,25 @@ it.effect(
28992899 }),
29002900);
29012901
2902it.effect("ProviderSessionManagerV2 checks liveness without delaying idle release", () =>
2903 Effect.gen(function* () {
2904 const state = yield* Ref.make(emptyState);
2905 const effect = Effect.gen(function* () {
2906 const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
2907 const { providerSessionId } = yield* openSessionWithBackgroundCommand(
2908 "provider-session-manager-is-live",
2909 );
2910 yield* TestClock.adjust("600 millis");
2911 assert.isTrue(yield* manager.isLive(providerSessionId));
2912 yield* TestClock.adjust("600 millis");
2913 yield* Effect.yieldNow;
2914 assert.isFalse(yield* manager.isLive(providerSessionId));
2915 });
2916
2917 yield* effect.pipe(Effect.provide(layerTest({ state, idleTimeoutMs: 1000 })));
2918 }),
2919);
2920
29022921it.effect("ProviderSessionManagerV2 clears the background roster of an idle-released session", () =>
29032922 Effect.gen(function* () {
29042923 const state = yield* Ref.make(emptyState);
apps/server/src/orchestration-v2/ProviderSessionManager.ts+6
......@@ -169,6 +169,8 @@ export interface ProviderSessionManagerV2Shape {
169169 Option.Option<ProviderAdapter.ProviderAdapterV2SessionRuntime>,
170170 ProviderSessionManagerV2Error
171171 >;
172 /** Whether the session's runtime is running. Unlike `get`, not activity. */
173 readonly isLive: (providerSessionId: ProviderSessionId) => Effect.Effect<boolean>;
172174 readonly close: (
173175 providerSessionId: ProviderSessionId,
174176 ) => Effect.Effect<void, ProviderSessionManagerV2Error>;
......@@ -2346,6 +2348,10 @@ export const layerWithOptions = (
23462348 return exposedRuntime;
23472349 }),
23482350 ),
2351 isLive: (providerSessionId) =>
2352 Ref.get(sessions).pipe(
2353 Effect.map((current) => current.has(sessionKey(providerSessionId))),
2354 ),
23492355 get: (providerSessionId) =>
23502356 Effect.gen(function* () {
23512357 const entry = (yield* Ref.get(sessions)).get(sessionKey(providerSessionId));
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1
......@@ -273,6 +273,7 @@ it.effect(
273273 Effect.succeed(
274274 providerSessionId === oldSessionId ? Option.some(runtime) : Option.none(),
275275 ),
276 isLive: (providerSessionId) => Effect.succeed(providerSessionId === oldSessionId),
276277 close: () => Effect.void,
277278 closeInstance: () => Effect.void,
278279 release: () => Effect.void,
apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts+214-17
......@@ -1,3 +1,4 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
12import { expect, it, vi } from "vite-plus/test";
23import { it as effectIt } from "@effect/vitest";
34import {
......@@ -18,12 +19,16 @@ import {
1819} from "@t3tools/contracts";
1920import * as Cause from "effect/Cause";
2021import * as DateTime from "effect/DateTime";
22import * as Deferred from "effect/Deferred";
2123import * as Effect from "effect/Effect";
2224import * as FileSystem from "effect/FileSystem";
25import * as Fiber from "effect/Fiber";
2326import * as Layer from "effect/Layer";
2427import * as Option from "effect/Option";
28import * as Path from "effect/Path";
2529import * as Schema from "effect/Schema";
2630
31import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
2732import * as GitWorkflow from "../git/GitWorkflowService.ts";
2833import * as ProjectService from "../project/ProjectService.ts";
2934import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
......@@ -95,7 +100,8 @@ it("does not commit running state when inherited background routing cannot be re
95100 Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({}),
96101 Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }),
97102 IdAllocator.layer,
98 Layer.succeed(FileSystem.FileSystem, { exists: () => Effect.succeed(false) } as never),
103 Path.layer,
104 FileSystem.layerNoop({ exists: () => Effect.succeed(false) }),
99105 Layer.mock(GitWorkflow.GitWorkflowService)({ pruneWorktrees, createWorktree }),
100106 Layer.mock(ProjectService.ProjectService)({
101107 getById: () =>
......@@ -104,6 +110,10 @@ it("does not commit running state when inherited background routing cannot be re
104110 ),
105111 }),
106112 Layer.mock(ProjectionStore.ProjectionStoreV2)({
113 getThreadRecords: () => {
114 projectionReadCount += 1;
115 return Effect.succeed(projection);
116 },
107117 getTurnStartContext: () => {
108118 projectionReadCount += 1;
109119 return Effect.succeed({
......@@ -141,7 +151,7 @@ it("does not commit running state when inherited background routing cannot be re
141151 .pipe(Effect.flip);
142152
143153 expect(error._tag).toBe("ProviderTurnStartError");
144 expect(projectionReadCount).toBe(2);
154 expect(projectionReadCount).toBe(3);
145155 expect(pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-turn-start-project" });
146156 expect(createWorktree).toHaveBeenCalledWith({
147157 cwd: "/tmp/provider-turn-start-project",
......@@ -155,6 +165,8 @@ it("does not commit running state when inherited background routing cannot be re
155165
156166function makeLocalCommandHarness(input: {
157167 readonly text: string;
168 readonly worktreePath?: string;
169 readonly fileSystem?: FileSystem.FileSystem;
158170 readonly previousNativeSession?: boolean;
159171 readonly previousMessages?: ReadonlyArray<string>;
160172 readonly logoutFailure?: string;
......@@ -236,8 +248,9 @@ function makeLocalCommandHarness(input: {
236248 thread: {
237249 id: threadId,
238250 activeProviderThreadId: providerThreadId,
239 branch: null,
240 worktreePath: null,
251 projectId: ProjectId.make("project-native-account-command"),
252 branch: input.worktreePath === undefined ? null : "feature/restore",
253 worktreePath: input.worktreePath ?? null,
241254 } as OrchestrationV2ThreadProjection["thread"],
242255 runs: [
243256 ...(input.previousNativeSession
......@@ -483,6 +496,24 @@ function makeLocalCommandHarness(input: {
483496 return { committed, storedEvents: [] };
484497 }),
485498 );
499 const resolveRuntimePolicy = vi.fn(
500 ({ thread }: Parameters<RuntimePolicy.RuntimePolicyV2Shape["resolve"]>[0]) =>
501 Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never),
502 );
503 const createWorktree = vi.fn(() => Effect.succeed({} as never));
504 const getThreadRecords = vi.fn<ProjectionStore.ProjectionStoreV2Shape["getThreadRecords"]>(() =>
505 Effect.succeed(projection),
506 );
507 const getTurnStartContext = vi.fn(() =>
508 Effect.succeed({
509 ...projection,
510 hasConversation: projection.messages.some(
511 (m) =>
512 m.role === "user" &&
513 (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0),
514 ),
515 }),
516 );
486517 const layer = ProviderTurnStart.layer.pipe(
487518 Layer.provide(
488519 Layer.mergeAll(
......@@ -491,19 +522,21 @@ function makeLocalCommandHarness(input: {
491522 }),
492523 Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }),
493524 IdAllocator.layer,
494 FileSystem.layerNoop({}),
495 Layer.mock(GitWorkflow.GitWorkflowService)({}),
496 Layer.mock(ProjectService.ProjectService)({}),
525 Path.layer,
526 input.fileSystem === undefined
527 ? FileSystem.layerNoop({ exists: () => Effect.succeed(true) })
528 : Layer.succeed(FileSystem.FileSystem, input.fileSystem),
529 Layer.mock(GitWorkflow.GitWorkflowService)({
530 pruneWorktrees: () => Effect.void,
531 createWorktree,
532 }),
533 Layer.mock(ProjectService.ProjectService)({
534 getById: () =>
535 Effect.succeed(Option.some({ workspaceRoot: "/tmp/native-account-project" } as never)),
536 }),
497537 Layer.mock(ProjectionStore.ProjectionStoreV2)({
498 getTurnStartContext: () =>
499 Effect.succeed({
500 ...projection,
501 hasConversation: projection.messages.some(
502 (m) =>
503 m.role === "user" &&
504 (m.text.trim().toLowerCase() !== "/compact" || m.attachments.length > 0),
505 ),
506 }),
538 getThreadRecords,
539 getTurnStartContext,
507540 getRuntimeRecoveryProjection: () =>
508541 Effect.as(failReadIfRunning, {
509542 ...projection,
......@@ -525,12 +558,19 @@ function makeLocalCommandHarness(input: {
525558 Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }),
526559 Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }),
527560 Layer.mock(RuntimePolicy.RuntimePolicyV2)({
528 resolve: () => Effect.succeed({} as never),
561 resolve: resolveRuntimePolicy,
529562 }),
530563 ),
531564 ),
532565 );
533566 return {
567 getThreadRecords,
568 getTurnStartContext,
569 setProjection: (next: OrchestrationV2ThreadProjection) => {
570 projection = next;
571 },
572 resolveRuntimePolicy,
573 createWorktree,
534574 open,
535575 writeIfRunCurrent,
536576 startRootRun,
......@@ -553,6 +593,163 @@ function makeLocalCommandHarness(input: {
553593 };
554594}
555595
596effectIt.effect.each(["cleanup-first", "startup-first"])(
597 "coordinates provider startup with %s",
598 (order) =>
599 Effect.gen(function* () {
600 const fileSystem = yield* FileSystem.FileSystem;
601 const path = yield* Path.Path;
602 const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" });
603 const worktreePath = path.join(workspaceRoot, "worktree");
604 yield* fileSystem.makeDirectory(worktreePath);
605 const release = yield* Deferred.make<void>();
606 const openEntered = yield* Deferred.make<void>();
607 const harness = makeLocalCommandHarness({
608 text: "Continue",
609 worktreePath,
610 fileSystem,
611 failReadsAfterRunning: true,
612 });
613 harness.createWorktree.mockImplementation(() =>
614 fileSystem.makeDirectory(worktreePath).pipe(Effect.as({} as never), Effect.orDie),
615 );
616 const open = harness.open.getMockImplementation()!;
617 harness.open.mockImplementation(() =>
618 Effect.gen(function* () {
619 expect(yield* fileSystem.exists(worktreePath).pipe(Effect.orDie)).toBe(true);
620 yield* Deferred.succeed(openEntered, undefined);
621 if (order === "startup-first") yield* Deferred.await(release);
622 return yield* open();
623 }),
624 );
625 let cleanupEntered = false;
626 const cleanupEffect = withWorkspaceLease(
627 worktreePath,
628 Effect.gen(function* () {
629 cleanupEntered = true;
630 if (order === "cleanup-first") yield* Deferred.await(release);
631 else {
632 expect(harness.projection().providerSessions).toHaveLength(1);
633 expect(harness.projection().runs.at(-1)?.status).toBe("running");
634 }
635 yield* fileSystem.remove(worktreePath, { recursive: true, force: true });
636 }),
637 );
638 const cleanup =
639 order === "cleanup-first"
640 ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true }))
641 : undefined;
642 const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true }));
643 if (order === "cleanup-first") expect(harness.open).not.toHaveBeenCalled();
644 if (order === "startup-first") yield* Deferred.await(openEntered);
645 const laterCleanup =
646 order === "startup-first"
647 ? yield* cleanupEffect.pipe(Effect.forkChild({ startImmediately: true }))
648 : undefined;
649 if (order === "startup-first") expect(cleanupEntered).toBe(false);
650 yield* Deferred.succeed(release, undefined);
651 if (cleanup !== undefined) yield* Fiber.join(cleanup);
652 yield* Fiber.join(startup);
653 if (laterCleanup !== undefined) yield* Fiber.join(laterCleanup);
654 if (order === "cleanup-first") {
655 expect(harness.createWorktree).toHaveBeenCalledWith({
656 cwd: "/tmp/native-account-project",
657 refName: "feature/restore",
658 path: worktreePath,
659 });
660 }
661 expect(harness.resolveRuntimePolicy).toHaveBeenCalledWith(
662 expect.objectContaining({
663 thread: expect.objectContaining({ worktreePath }),
664 }),
665 );
666 expect(harness.open).toHaveBeenCalledWith(
667 expect.objectContaining({
668 runtimePolicy: expect.objectContaining({ cwd: worktreePath }),
669 }),
670 );
671 expect(harness.startRootRun).toHaveBeenCalledWith(
672 expect.objectContaining({
673 appThread: expect.objectContaining({ worktreePath }),
674 runtimePolicy: expect.objectContaining({ cwd: worktreePath }),
675 }),
676 );
677 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
678);
679
680effectIt.effect("reads only thread state before taking the startup lease", () =>
681 Effect.gen(function* () {
682 const harness = makeLocalCommandHarness({ text: "/logout", previousNativeSession: true });
683 yield* harness.start;
684 expect(harness.getThreadRecords).toHaveBeenCalledExactlyOnceWith(
685 harness.projection().thread.id,
686 [],
687 );
688 expect(harness.getTurnStartContext).toHaveBeenCalledOnce();
689 expect(harness.projection().runs.at(-1)?.status).toBe("completed");
690 }),
691);
692
693effectIt.effect("reads current startup state after waiting for the workspace lease", () =>
694 Effect.gen(function* () {
695 const worktreePath = "/tmp/provider-start-fresh-state";
696 const release = yield* Deferred.make<void>();
697 const readEntered = yield* Deferred.make<void>();
698 const harness = makeLocalCommandHarness({
699 text: "Continue",
700 worktreePath,
701 failReadsAfterRunning: true,
702 });
703 harness.getThreadRecords.mockImplementationOnce(() =>
704 Deferred.succeed(readEntered, undefined).pipe(Effect.as(harness.projection())),
705 );
706 const holder = yield* withWorkspaceLease(worktreePath, Deferred.await(release)).pipe(
707 Effect.forkChild({ startImmediately: true }),
708 );
709 const startup = yield* harness.start.pipe(Effect.forkChild({ startImmediately: true }));
710 yield* Deferred.await(readEntered);
711 expect(harness.getTurnStartContext).not.toHaveBeenCalled();
712 const projection = harness.projection();
713 harness.setProjection({
714 ...projection,
715 runs: projection.runs.map((run) => ({ ...run, status: "running" })),
716 });
717 yield* Deferred.succeed(release, undefined);
718 yield* Fiber.join(holder);
719 yield* Fiber.join(startup);
720 expect(harness.open).not.toHaveBeenCalled();
721 expect(harness.startRootRun).not.toHaveBeenCalled();
722 }),
723);
724
725effectIt.effect("starts another provider in the same checkout while a turn is pending", () =>
726 Effect.gen(function* () {
727 const worktreePath = "/tmp/provider-start-shared-checkout";
728 const turnEntered = yield* Deferred.make<void>();
729 const releaseTurn = yield* Deferred.make<void>();
730 const first = makeLocalCommandHarness({
731 text: "Continue",
732 worktreePath,
733 failReadsAfterRunning: true,
734 });
735 const second = makeLocalCommandHarness({
736 text: "Continue",
737 worktreePath,
738 failReadsAfterRunning: true,
739 });
740 first.startRootRun.mockImplementation(() =>
741 Deferred.succeed(turnEntered, undefined).pipe(Effect.andThen(Deferred.await(releaseTurn))),
742 );
743 const pending = yield* first.start.pipe(Effect.forkChild({ startImmediately: true }));
744 yield* Deferred.await(turnEntered);
745 yield* second.start;
746 expect(second.startRootRun).toHaveBeenCalledOnce();
747 expect(second.projection().runs.at(-1)?.status).toBe("running");
748 yield* Deferred.succeed(releaseTurn, undefined);
749 yield* Fiber.join(pending);
750 }),
751);
752
556753effectIt.effect("terminalizes a starting run when its provider session cannot open", () =>
557754 Effect.gen(function* () {
558755 const harness = makeLocalCommandHarness({
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+14-2
......@@ -19,9 +19,11 @@ import * as Effect from "effect/Effect";
1919import * as FileSystem from "effect/FileSystem";
2020import * as Layer from "effect/Layer";
2121import * as Option from "effect/Option";
22import * as Path from "effect/Path";
2223import * as Predicate from "effect/Predicate";
2324import * as Schema from "effect/Schema";
2425
26import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
2527import * as GitWorkflowService from "../git/GitWorkflowService.ts";
2628import * as ProjectService from "../project/ProjectService.ts";
2729import * as ProviderAuthService from "../provider/ProviderAuthService.ts";
......@@ -90,6 +92,7 @@ export const layer: Layer.Layer<
9092 | ContextHandoffService.ContextHandoffServiceV2
9193 | IdAllocator.IdAllocatorV2
9294 | FileSystem.FileSystem
95 | Path.Path
9396 | GitWorkflowService.GitWorkflowService
9497 | ProjectService.ProjectService
9598 | ProviderAuthService.ProviderAuthService
......@@ -104,6 +107,7 @@ export const layer: Layer.Layer<
104107 const contextHandoffService = yield* ContextHandoffService.ContextHandoffServiceV2;
105108 const idAllocator = yield* IdAllocator.IdAllocatorV2;
106109 const fileSystem = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
107111 const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
108112 const projects = yield* ProjectService.ProjectService;
109113 const providerAuth = yield* ProviderAuthService.ProviderAuthService;
......@@ -1224,7 +1228,7 @@ export const layer: Layer.Layer<
12241228 !noteContinuation
12251229 ? session
12261230 : makeDeliverySession(session, startWithHandoffs);
1227 yield* runExecution.startRootRun({
1231 return runExecution.startRootRun({
12281232 commandId: CommandId.make(`command:effect:provider-turn.start:${run.id}`),
12291233 appThread: projection.thread,
12301234 providerSessionId,
......@@ -1279,7 +1283,15 @@ export const layer: Layer.Layer<
12791283
12801284 return ProviderTurnStartServiceV2.of({
12811285 start: (input) =>
1282 start(input).pipe(
1286 Effect.gen(function* () {
1287 // Storage cleanup removes worktrees under the same lease. Only startup
1288 // holds it; the provider turn runs after it is released.
1289 const { thread } = yield* projectionStore.getThreadRecords(input.threadId, []);
1290 const providerTurn = yield* thread.worktreePath === null
1291 ? start(input)
1292 : withWorkspaceLease(path.resolve(thread.worktreePath), start(input));
1293 if (providerTurn !== undefined) yield* providerTurn;
1294 }).pipe(
12831295 Effect.mapError((cause) =>
12841296 isProviderTurnStartError(cause)
12851297 ? cause
apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs+13-1
......@@ -7,10 +7,11 @@ const mode = process.argv[3];
77const withHandoff = mode.startsWith("handoff");
88const require = NodeModule.createRequire(root + "/apps/server/package.json");
99const load = (name) => import(NodeURL.pathToFileURL(require.resolve("effect/" + name)));
10const [Effect, Layer, FileSystem] = await Promise.all([
10const [Effect, Layer, FileSystem, Path] = await Promise.all([
1111 load("Effect"),
1212 load("Layer"),
1313 load("FileSystem"),
14 load("Path"),
1415]);
1516const app = (file) => import(NodeURL.pathToFileURL(root + "/apps/server/src/" + file + ".ts"));
1617const providerCore = (file) =>
......@@ -31,6 +32,7 @@ const [Start, Projection, Run, Sessions, Policy, Id, Sink, Handoff, Git, Project
3132 ]);
3233let current;
3334let fullReads = 0;
35let threadReads = 0;
3436const liveRuns = [];
3537const refs = [];
3638const checkpoints = [];
......@@ -48,6 +50,7 @@ const dependencies = Layer.mergeAll(
4850 Layer.mock(Handoff.ContextHandoffServiceV2)({}),
4951 Id.layer,
5052 FileSystem.layerNoop({}),
53 Path.layer,
5154 Layer.mock(Git.GitWorkflowService)({}),
5255 Layer.mock(Project.ProjectService)({}),
5356 Layer.mock(Auth.ProviderAuthService)({}),
......@@ -58,6 +61,13 @@ const dependencies = Layer.mergeAll(
5861 throw new Error("full transcript read");
5962 }),
6063 hasUnpairedRunInterruptRequest: () => Effect.succeed(false),
64 getThreadRecords: (threadId, fields) =>
65 Effect.sync(() => {
66 NodeAssert.equal(threadId, current.thread.id);
67 NodeAssert.deepEqual(fields, []);
68 threadReads++;
69 return { thread: current.thread };
70 }),
6171 getTurnStartContext: () =>
6272 Effect.sync(() => {
6373 fullReads++;
......@@ -181,8 +191,10 @@ await Effect.runPromise(
181191 NodeAssert.equal(yield* controls.shouldFinalizeRun(), false);
182192 NodeAssert.deepEqual(yield* controls.loadInheritedBackgroundTurnItems(), []);
183193 NodeAssert.equal(fullReads, i + 1);
194 NodeAssert.equal(threadReads, i + 1);
184195 NodeAssert.equal(yield* controls.hasUnpairedRunInterruptRequest(), false);
185196 NodeAssert.equal(fullReads, i + 1);
197 NodeAssert.equal(threadReads, i + 1);
186198 current = null;
187199 if (i === Math.floor(count / 2) - 1 || i === count - 1) {
188200 yield* Effect.promise(async () => {
apps/server/src/serverSettings.test.ts+23-1
......@@ -580,7 +580,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
580580 ).pipe(Effect.provide(layerServerSettings())),
581581 );
582582
583 it.effect("persists custom usage prices and removes them from the settings file", () =>
583 it.effect("preserves saved usage settings during cleanup edits and removes custom prices", () =>
584584 Effect.scoped(
585585 Effect.gen(function* () {
586586 const serverConfig = yield* ServerConfig.ServerConfig;
......@@ -596,8 +596,30 @@ it.layer(NodeServices.layer)("server settings", (it) => {
596596 .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings))));
597597
598598 yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": prices } });
599 const usageModelAliases = { "example-preview": "example-model" };
600 const sourceId = UsageLimitSourceId.make("hub");
601 const usageLimitSources = {
602 [sourceId]: {
603 kind: "cliproxy" as const,
604 url: "https://hub.example.test",
605 managementKey: "",
606 enabled: true,
607 },
608 };
609 const saved = yield* serverSettings.updateSettings({
610 usageModelAliases,
611 usageLimitSources,
612 });
613 const updated = yield* serverSettings.updateSettings({
614 storageCleanup: { worktreeSettledAfterDays: 0, worktreeKeepWhen: "any-local-files" },
615 });
616 assert.deepStrictEqual(updated.usageLimitSources, saved.usageLimitSources);
599617 const persisted = yield* readPersisted;
600618 assert.deepStrictEqual(persisted.usagePriceOverrides, { "example-model": prices });
619 assert.deepStrictEqual(persisted.usageModelAliases, usageModelAliases);
620 assert.deepStrictEqual(persisted.usageLimitSources, usageLimitSources);
621 assert.equal(persisted.storageCleanup.worktreeSettledAfterDays, 0);
622 assert.equal(persisted.storageCleanup.worktreeKeepWhen, "any-local-files");
601623
602624 yield* serverSettings.updateSettings({ usagePriceOverrides: { "example-model": null } });
603625 const restored = yield* readPersisted;
apps/server/src/storageCleanup.test.ts+410-187
......@@ -9,11 +9,14 @@ import type * as Path from "effect/Path";
99import type * as Scope from "effect/Scope";
1010import * as Stream from "effect/Stream";
1111import * as Schema from "effect/Schema";
12import { SCAN_CACHE_FILE_NAME } from "./usage/usageScanCache.ts";
13import { TestClock } from "effect/testing";
1214import * as SqlClient from "effect/sql/SqlClient";
1315import {
1416 DEFAULT_SERVER_SETTINGS,
1517 StorageCleanupReport,
1618 type WorktreeKeepWhen,
19 type StorageCleanupSettings,
1720} from "@t3tools/contracts";
1821import * as ServerConfig from "./config.ts";
1922import * as StorageCleanup from "./storageCleanup.ts";
......@@ -21,6 +24,7 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
2124import * as GitManager from "./git/GitManager.ts";
2225import * as Settings from "./serverSettings.ts";
2326import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts";
27import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts";
2428import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
2529import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
2630import * as TerminalManager from "./terminal/Manager.ts";
......@@ -231,178 +235,197 @@ describe("merged pull request cleanup", () => {
231235 });
232236});
233237
234const cleanupFixture = Effect.gen(function* () {
235 const fs = yield* FileSystem.FileSystem;
236 const config = yield* ServerConfig.ServerConfig;
237 const git = yield* GitVcsDriver.GitVcsDriver;
238 const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" });
239 const command = (cwd: string, args: string[]) =>
240 git.execute({ operation: "cleanup-test", cwd, args });
241 yield* command(repo, ["init", "-b", "main"]);
242 yield* command(repo, ["config", "user.email", "cleanup@example.test"]);
243 yield* command(repo, ["config", "user.name", "Cleanup test"]);
244 yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n");
245 yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n");
246 yield* command(repo, ["add", "."]);
247 yield* command(repo, ["commit", "-m", "initial"]);
248 yield* fs.makeDirectory(config.worktreesDir, { recursive: true });
249 const worktree = `${config.worktreesDir}/feature`;
250 yield* command(repo, ["worktree", "add", "-b", "feature", worktree]);
251 // worktreeOnDelete lets domain events request sweeps once a test calls activate.
252 let settings: import("@t3tools/contracts").ServerSettings = {
253 ...DEFAULT_SERVER_SETTINGS,
254 storageCleanup: {
255 ...DEFAULT_SERVER_SETTINGS.storageCleanup,
256 worktreeAfterDays: 8,
257 worktreeOnDelete: true,
258 },
259 };
260 let threads = [shell({ branch: "feature", worktreePath: worktree })];
261 let editBeforeRemoval = false;
262 let openSessionCwd: string | null = null;
263 let fullStatusCalls = 0;
264 let snapshotReads = 0;
265 let events: Stream.Stream<OrchestrationV2DomainEvent> = Stream.empty;
266 const activated = yield* Deferred.make<void>();
267 const context = yield* Layer.build(StorageCleanup.layer).pipe(
268 Effect.provideService(GitVcsDriver.GitVcsDriver, {
269 ...git,
270 execute: (input) =>
271 Effect.gen(function* () {
272 if (editBeforeRemoval && input.args.includes("remove"))
273 yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n");
274 return yield* git.execute(input);
275 }),
276 statusDetailsLocal: (...args) => {
277 fullStatusCalls++;
278 return git.statusDetailsLocal(...args);
279 },
280 }),
281 Effect.provideService(Settings.ServerSettingsService, {
282 getSettings: Effect.sync(() => settings),
283 subscribeChanges: Effect.succeed(Stream.empty),
284 } as unknown as Settings.ServerSettingsService["Service"]),
285 Effect.provideService(ProjectStore.ProjectStoreV2, {
286 listShells: () => Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]),
287 } as unknown as ProjectStore.ProjectStoreV2["Service"]),
288 Effect.provideService(ProjectionStore.ProjectionStoreV2, {
289 getShellSnapshot: (input?: { location?: string }) =>
290 Effect.sync(() => {
291 snapshotReads++;
292 return { threads: input?.location === "archive" ? [] : threads };
293 }),
294 getThreadWorktreePaths: () =>
295 Effect.sync(() =>
296 threads.flatMap((thread) =>
297 thread.worktreePath === null
298 ? []
299 : [{ threadId: thread.id, worktreePath: thread.worktreePath }],
300 ),
301 ),
302 getThreadShell: (threadId: ThreadId) =>
303 Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null),
304 } as unknown as ProjectionStore.ProjectionStoreV2["Service"]),
305 Effect.provideService(Orchestrator.OrchestratorV2, {
306 streamDomainEvents: Stream.suspend(() => events),
307 } as unknown as Orchestrator.OrchestratorV2["Service"]),
308 Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) =>
309 Effect.sync(() =>
310 openSessionCwd !== null && strings.join("").includes("projection_provider_sessions")
311 ? [
312 {
313 payload_json: JSON.stringify({
314 id: "session-1",
315 driver: "codex",
316 providerInstanceId: "codex",
317 status: "ready",
318 cwd: openSessionCwd,
319 model: null,
320 capabilities: CodexProviderCapabilitiesV2,
321 createdAt: "2026-06-01T12:00:00.000Z",
322 updatedAt: "2026-06-01T12:00:00.000Z",
323 lastError: null,
324 }),
325 },
326 ]
327 : [],
328 )) as unknown as SqlClient.SqlClient),
329 Effect.provideService(GitManager.GitManager, {
330 invalidateStatus: () => Effect.void,
331 } as unknown as GitManager.GitManager["Service"]),
332 Effect.provideService(TerminalManager.TerminalManager, {
333 subscribeMetadata: () => Effect.succeed(() => {}),
334 } as unknown as TerminalManager.TerminalManager["Service"]),
335 Effect.provideService(ServerActivation, Deferred.await(activated)),
336 );
337 return {
338 service: Context.get(context, StorageCleanup.StorageCleanup),
339 editBeforeRemoval: () => {
340 editBeforeRemoval = true;
341 },
342 fs,
343 worktree,
344 config,
345 command,
346 enableFiles: () => {
347 settings = {
348 ...settings,
349 storageCleanup: {
350 ...settings.storageCleanup,
351 browserArtifactsAfterDays: 8,
352 logsAfterDays: 8,
353 },
354 };
355 },
356 setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => {
357 settings = { ...settings, storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen } };
358 },
359 disable: () => {
360 settings = {
361 ...settings,
362 storageCleanup: {
363 ...settings.storageCleanup,
364 worktreeAfterDays: null,
365 worktreeOnDelete: false,
238const cleanupFixture = (
239 storageCleanup: StorageCleanupSettings = {
240 ...DEFAULT_SERVER_SETTINGS.storageCleanup,
241 worktreeAfterDays: 8,
242 worktreeOnDelete: true,
243 },
244) =>
245 Effect.gen(function* () {
246 const fs = yield* FileSystem.FileSystem;
247 const config = yield* ServerConfig.ServerConfig;
248 const git = yield* GitVcsDriver.GitVcsDriver;
249 const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" });
250 const command = (cwd: string, args: string[]) =>
251 git.execute({ operation: "cleanup-test", cwd, args });
252 yield* command(repo, ["init", "-b", "main"]);
253 yield* command(repo, ["config", "user.email", "cleanup@example.test"]);
254 yield* command(repo, ["config", "user.name", "Cleanup test"]);
255 yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n");
256 yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n");
257 yield* command(repo, ["add", "."]);
258 yield* command(repo, ["commit", "-m", "initial"]);
259 yield* fs.makeDirectory(config.worktreesDir, { recursive: true });
260 const worktree = `${config.worktreesDir}/feature`;
261 yield* command(repo, ["worktree", "add", "-b", "feature", worktree]);
262 // worktreeOnDelete lets domain events request sweeps once a test calls activate.
263 let settings: import("@t3tools/contracts").ServerSettings = {
264 ...DEFAULT_SERVER_SETTINGS,
265 storageCleanup,
266 };
267 let threads = [shell({ branch: "feature", worktreePath: worktree })];
268 let editBeforeRemoval = false;
269 let openSessionCwd: string | null = null;
270 let sessionLive = true;
271 let fullStatusCalls = 0;
272 let snapshotReads = 0;
273 let events: Stream.Stream<OrchestrationV2DomainEvent> = Stream.empty;
274 const activated = yield* Deferred.make<void>();
275 const context = yield* Layer.build(StorageCleanup.layer).pipe(
276 Effect.provideService(GitVcsDriver.GitVcsDriver, {
277 ...git,
278 execute: (input) =>
279 Effect.gen(function* () {
280 if (editBeforeRemoval && input.args.includes("remove"))
281 yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n");
282 return yield* git.execute(input);
283 }),
284 statusDetailsLocal: (...args) => {
285 fullStatusCalls++;
286 return git.statusDetailsLocal(...args);
366287 },
367 };
368 },
369 setThreads: (next: typeof threads) => {
370 threads = next;
371 },
372 setOpenSessionCwd: (cwd: string | null) => {
373 openSessionCwd = cwd;
374 },
375 fullStatusCalls: () => fullStatusCalls,
376 snapshotReads: () => snapshotReads,
377 /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */
378 activate: (domainEvents: Stream.Stream<OrchestrationV2DomainEvent>) => {
379 events = domainEvents;
380 return Deferred.succeed(activated, undefined);
381 },
382 };
383});
288 }),
289 Effect.provideService(Settings.ServerSettingsService, {
290 getSettings: Effect.sync(() => settings),
291 subscribeChanges: Effect.succeed(Stream.empty),
292 } as unknown as Settings.ServerSettingsService["Service"]),
293 Effect.provideService(ProjectStore.ProjectStoreV2, {
294 listShells: () =>
295 Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]),
296 } as unknown as ProjectStore.ProjectStoreV2["Service"]),
297 Effect.provideService(ProjectionStore.ProjectionStoreV2, {
298 getShellSnapshot: (input?: { location?: string }) =>
299 Effect.sync(() => {
300 snapshotReads++;
301 return { threads: input?.location === "archive" ? [] : threads };
302 }),
303 getThreadWorktreePaths: () =>
304 Effect.sync(() =>
305 threads.flatMap((thread) =>
306 thread.worktreePath === null
307 ? []
308 : [{ threadId: thread.id, worktreePath: thread.worktreePath }],
309 ),
310 ),
311 getThreadShell: (threadId: ThreadId) =>
312 Effect.sync(() => threads.find((thread) => thread.id === threadId) ?? null),
313 } as unknown as ProjectionStore.ProjectionStoreV2["Service"]),
314 Effect.provideService(ProviderSessionManager.ProviderSessionManagerV2, {
315 isLive: () => Effect.sync(() => sessionLive),
316 } as unknown as ProviderSessionManager.ProviderSessionManagerV2["Service"]),
317 Effect.provideService(Orchestrator.OrchestratorV2, {
318 streamDomainEvents: Stream.suspend(() => events),
319 } as unknown as Orchestrator.OrchestratorV2["Service"]),
320 Effect.provideService(SqlClient.SqlClient, ((strings: TemplateStringsArray) =>
321 Effect.sync(() =>
322 openSessionCwd !== null && strings.join("").includes("projection_provider_sessions")
323 ? [
324 {
325 payload_json: JSON.stringify({
326 id: "session-1",
327 driver: "codex",
328 providerInstanceId: "codex",
329 status: "ready",
330 cwd: openSessionCwd,
331 model: null,
332 capabilities: CodexProviderCapabilitiesV2,
333 createdAt: "2026-06-01T12:00:00.000Z",
334 updatedAt: "2026-06-01T12:00:00.000Z",
335 lastError: null,
336 }),
337 },
338 ]
339 : [],
340 )) as unknown as SqlClient.SqlClient),
341 Effect.provideService(GitManager.GitManager, {
342 invalidateStatus: () => Effect.void,
343 } as unknown as GitManager.GitManager["Service"]),
344 Effect.provideService(TerminalManager.TerminalManager, {
345 subscribeMetadata: () => Effect.succeed(() => {}),
346 } as unknown as TerminalManager.TerminalManager["Service"]),
347 Effect.provideService(ServerActivation, Deferred.await(activated)),
348 );
349 return {
350 service: Context.get(context, StorageCleanup.StorageCleanup),
351 repo,
352 editBeforeRemoval: () => {
353 editBeforeRemoval = true;
354 },
355 fs,
356 worktree,
357 config,
358 command,
359 enableFiles: () => {
360 settings = {
361 ...settings,
362 storageCleanup: {
363 ...settings.storageCleanup,
364 browserArtifactsAfterDays: 8,
365 logsAfterDays: 8,
366 },
367 };
368 },
369 setSettings: (next: Partial<typeof settings>) => {
370 settings = { ...settings, ...next };
371 },
372 setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => {
373 settings = {
374 ...settings,
375 storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen },
376 };
377 },
378 disable: () => {
379 settings = {
380 ...settings,
381 storageCleanup: {
382 ...settings.storageCleanup,
383 worktreeAfterDays: null,
384 worktreeOnDelete: false,
385 },
386 };
387 },
388 setThreads: (next: typeof threads) => {
389 threads = next;
390 },
391 setSessionLive: (live: boolean) => {
392 sessionLive = live;
393 },
394 setOpenSessionCwd: (cwd: string | null) => {
395 openSessionCwd = cwd;
396 },
397 fullStatusCalls: () => fullStatusCalls,
398 snapshotReads: () => snapshotReads,
399 /** Starts the hourly sweep and the subscription to `domainEvents`, which wait until now. */
400 activate: (domainEvents: Stream.Stream<OrchestrationV2DomainEvent>) => {
401 events = domainEvents;
402 return Deferred.succeed(activated, undefined);
403 },
404 };
405 });
384406/** The fixture, with the size walk of its worktree held until `resume` succeeds. */
385const pausedMeasurementFixture = Effect.gen(function* () {
386 const fs = yield* FileSystem.FileSystem;
387 const measuring = yield* Deferred.make<void>();
388 const resume = yield* Deferred.make<void>();
389 let measuredPath: string | undefined;
390 const fixture = yield* cleanupFixture.pipe(
391 Effect.provideService(FileSystem.FileSystem, {
392 ...fs,
393 readDirectory: (directory, options) =>
394 Effect.gen(function* () {
395 if (directory === measuredPath) {
396 yield* Deferred.succeed(measuring, undefined);
397 yield* Deferred.await(resume);
398 }
399 return yield* fs.readDirectory(directory, options);
400 }),
401 }),
402 );
403 measuredPath = yield* fs.realPath(fixture.worktree);
404 return { ...fixture, measuring, resume };
405});
407const pausedMeasurementFixture = (storageCleanup?: StorageCleanupSettings) =>
408 Effect.gen(function* () {
409 const fs = yield* FileSystem.FileSystem;
410 const measuring = yield* Deferred.make<void>();
411 const resume = yield* Deferred.make<void>();
412 let measuredPath: string | undefined;
413 const fixture = yield* cleanupFixture(storageCleanup).pipe(
414 Effect.provideService(FileSystem.FileSystem, {
415 ...fs,
416 readDirectory: (directory, options) =>
417 Effect.gen(function* () {
418 if (directory === measuredPath) {
419 yield* Deferred.succeed(measuring, undefined);
420 yield* Deferred.await(resume);
421 }
422 return yield* fs.readDirectory(directory, options);
423 }),
424 }),
425 );
426 measuredPath = yield* fs.realPath(fixture.worktree);
427 return { ...fixture, measuring, resume };
428 });
406429const sessionUpdated = (
407430 index: number,
408431 status: OrchestrationV2ProviderSession["status"],
......@@ -444,7 +467,7 @@ describe("storage cleanup reports and local file policies", () => {
444467 it.live("measures nested regular files without following symlinks", () =>
445468 runCleanupTest(
446469 Effect.gen(function* () {
447 const { service, fs, worktree, setPolicy } = yield* cleanupFixture;
470 const { service, fs, worktree, setPolicy } = yield* cleanupFixture();
448471 setPolicy("tracked-changes");
449472 const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) =>
450473 fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))),
......@@ -468,7 +491,7 @@ describe("storage cleanup reports and local file policies", () => {
468491 runCleanupTest(
469492 Effect.gen(function* () {
470493 const fs = yield* FileSystem.FileSystem;
471 const { service, worktree } = yield* cleanupFixture.pipe(
494 const { service, worktree } = yield* cleanupFixture().pipe(
472495 Effect.provideService(FileSystem.FileSystem, {
473496 ...fs,
474497 readDirectory: (directory) => fs.readDirectory(`${directory}/missing-directory`),
......@@ -498,7 +521,7 @@ describe("storage cleanup reports and local file policies", () => {
498521 ] as const)("keeps a worktree when %s during size measurement", ([, threadsWhileMeasuring]) =>
499522 runCleanupTest(
500523 Effect.gen(function* () {
501 const fixture = yield* pausedMeasurementFixture;
524 const fixture = yield* pausedMeasurementFixture();
502525 const { fs, worktree } = fixture;
503526 fixture.setPolicy("tracked-changes");
504527 yield* fs.writeFileString(`${worktree}/notes.txt`, "untracked\n");
......@@ -521,7 +544,7 @@ describe("storage cleanup reports and local file policies", () => {
521544 it.live("reads the thread snapshot once per sweep, not per worktree", () =>
522545 runCleanupTest(
523546 Effect.gen(function* () {
524 const { service, snapshotReads } = yield* cleanupFixture;
547 const { service, snapshotReads } = yield* cleanupFixture();
525548 expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" });
526549 expect(snapshotReads()).toBe(1);
527550 }),
......@@ -535,7 +558,7 @@ describe("storage cleanup reports and local file policies", () => {
535558 ([first, second, sweeps]) =>
536559 runCleanupTest(
537560 Effect.gen(function* () {
538 const fixture = yield* pausedMeasurementFixture;
561 const fixture = yield* pausedMeasurementFixture();
539562 const handled = yield* Deferred.make<void>();
540563 // 20 updates arrive while the first automatic sweep measures the worktree.
541564 yield* fixture.activate(
......@@ -560,7 +583,7 @@ describe("storage cleanup reports and local file policies", () => {
560583 Effect.gen(function* () {
561584 const fs = yield* FileSystem.FileSystem;
562585 const walked: Array<string> = [];
563 const fixture = yield* cleanupFixture.pipe(
586 const fixture = yield* cleanupFixture().pipe(
564587 Effect.provideService(FileSystem.FileSystem, {
565588 ...fs,
566589 readDirectory: (directory, options) => {
......@@ -578,7 +601,8 @@ describe("storage cleanup reports and local file policies", () => {
578601 bytes: null,
579602 });
580603 expect(walked.filter((directory) => directory.startsWith(root))).toEqual([]);
581 fixture.setOpenSessionCwd(null);
604 // The stored session stays ready after its runtime was released.
605 fixture.setSessionLive(false);
582606 const removed = yield* fixture.service.runNow;
583607 expect(removed.entries[0]).toMatchObject({ outcome: "removed" });
584608 expect(removed.entries[0]?.bytes).toBeGreaterThan(0);
......@@ -590,7 +614,7 @@ describe("storage cleanup reports and local file policies", () => {
590614 runCleanupTest(
591615 Effect.gen(function* () {
592616 const { service, fs, worktree, command, setThreads, fullStatusCalls } =
593 yield* cleanupFixture;
617 yield* cleanupFixture();
594618 const changed = { outcome: "kept", reason: "Repository or branch changed" };
595619 yield* command(worktree, ["checkout", "--detach"]);
596620 expect((yield* service.runNow).entries[0]).toMatchObject(changed);
......@@ -656,7 +680,7 @@ describe("storage cleanup reports and local file policies", () => {
656680 ] as const)("applies %j", ([policy, file, outcome]) =>
657681 runCleanupTest(
658682 Effect.gen(function* () {
659 const { service, fs, worktree, setPolicy } = yield* cleanupFixture;
683 const { service, fs, worktree, setPolicy } = yield* cleanupFixture();
660684 setPolicy(policy);
661685 expect(yield* service.latestReport).toBeNull();
662686 yield* fs.writeFileString(`${worktree}/${file}`, "local data\n");
......@@ -690,7 +714,7 @@ describe("storage cleanup reports and local file policies", () => {
690714 it.live("preserves tracked edits written after the final status check", () =>
691715 runCleanupTest(
692716 Effect.gen(function* () {
693 const fixture = yield* cleanupFixture;
717 const fixture = yield* cleanupFixture();
694718 fixture.setPolicy("tracked-changes");
695719 fixture.editBeforeRemoval();
696720 yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n");
......@@ -708,7 +732,7 @@ describe("storage cleanup reports and local file policies", () => {
708732 it.live("streams the current report and subsequent runs", () =>
709733 runCleanupTest(
710734 Effect.gen(function* () {
711 const { service } = yield* cleanupFixture;
735 const { service } = yield* cleanupFixture();
712736 const reports = yield* service.reports.pipe(
713737 Stream.take(2),
714738 Stream.tap((report) => (report === null ? service.runNow : Effect.void)),
......@@ -723,7 +747,7 @@ describe("storage cleanup reports and local file policies", () => {
723747 it.live("caps entries while preserving totals and prioritizing removals", () =>
724748 runCleanupTest(
725749 Effect.gen(function* () {
726 const { service, fs, worktree, setThreads } = yield* cleanupFixture;
750 const { service, fs, worktree, setThreads } = yield* cleanupFixture();
727751 const threads = [];
728752 for (let i = 0; i < 205; i++) {
729753 const worktreePath = `${worktree}-${i}`;
......@@ -752,7 +776,7 @@ describe("storage cleanup reports and local file policies", () => {
752776 it.live("keeps staged edits in tracked-only mode", () =>
753777 runCleanupTest(
754778 Effect.gen(function* () {
755 const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture;
779 const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture();
756780 setPolicy("tracked-changes");
757781 yield* fs.writeFileString(`${worktree}/tracked.txt`, "staged\n");
758782 yield* command(worktree, ["add", "tracked.txt"]);
......@@ -769,7 +793,7 @@ describe("storage cleanup reports and local file policies", () => {
769793 () =>
770794 runCleanupTest(
771795 Effect.gen(function* () {
772 const fixture = yield* cleanupFixture;
796 const fixture = yield* cleanupFixture();
773797 const { service, fs, worktree, setThreads } = fixture;
774798 setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]);
775799 expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running");
......@@ -790,7 +814,7 @@ describe("storage cleanup reports and local file policies", () => {
790814 it.live("serializes concurrent requests and returns each run's report", () =>
791815 runCleanupTest(
792816 Effect.gen(function* () {
793 const { service } = yield* cleanupFixture;
817 const { service } = yield* cleanupFixture();
794818 const reports = yield* Effect.all([service.runNow, service.runNow], {
795819 concurrency: "unbounded",
796820 });
......@@ -805,7 +829,7 @@ describe("storage cleanup reports and local file policies", () => {
805829 it.live("aggregates expired artifacts and rotated logs while retaining current logs", () =>
806830 runCleanupTest(
807831 Effect.gen(function* () {
808 const { service, fs, config, enableFiles, disable } = yield* cleanupFixture;
832 const { service, fs, config, enableFiles, disable } = yield* cleanupFixture();
809833 disable();
810834 enableFiles();
811835 yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true });
......@@ -848,7 +872,7 @@ describe("storage cleanup reports and local file policies", () => {
848872 it.live("reports git failures without aborting the remaining categories", () =>
849873 runCleanupTest(
850874 Effect.gen(function* () {
851 const { service, worktree, command, enableFiles } = yield* cleanupFixture;
875 const { service, worktree, command, enableFiles } = yield* cleanupFixture();
852876 enableFiles();
853877 yield* command(worktree, ["worktree", "lock", worktree]);
854878 const report = yield* service.runNow;
......@@ -863,3 +887,202 @@ describe("storage cleanup reports and local file policies", () => {
863887 ),
864888 );
865889});
890
891describe("settled worktree retention", () => {
892 it.effect.each([
893 "immediate",
894 "settle-script",
895 "expired",
896 "boundary",
897 "wait",
898 "recent-update",
899 "active",
900 "dirty",
901 "project-off",
902 "project-custom",
903 "inactive-settling",
904 ] as const)("applies retention and safety checks (%s)", (scenario) =>
905 Effect.gen(function* () {
906 yield* TestClock.setTime(NOW_MS);
907 const fixture = yield* cleanupFixture();
908 const days = ["wait", "boundary", "expired", "recent-update"].includes(scenario) ? 8 : 0;
909 const rules = {
910 ...DEFAULT_SERVER_SETTINGS.storageCleanup,
911 worktreeSettledAfterDays:
912 scenario === "project-custom" || scenario === "inactive-settling" ? null : days,
913 // Another rule must not bypass the settle-script grace either.
914 worktreeAfterDays: scenario === "inactive-settling" ? 1 : null,
915 };
916 fixture.setSettings({
917 storageCleanup: rules,
918 projectSettingsOverrides: {
919 [ProjectId.make("project-1")]:
920 scenario === "project-off"
921 ? { worktreeCleanup: { mode: "off" } }
922 : scenario === "project-custom"
923 ? {
924 worktreeCleanup: {
925 mode: "custom",
926 rules: { ...rules, worktreeSettledAfterDays: 0 },
927 },
928 }
929 : {},
930 },
931 });
932 const thread = shell({
933 branch: "feature",
934 worktreePath: fixture.worktree,
935 settledAt:
936 scenario === "active"
937 ? null
938 : at(
939 scenario === "expired" ||
940 scenario === "recent-update" ||
941 scenario === "inactive-settling"
942 ? -20 * DAY_MS
943 : scenario === "boundary"
944 ? -8 * DAY_MS
945 : scenario === "wait"
946 ? -8 * DAY_MS + 1
947 : scenario === "settle-script"
948 ? 0
949 : -60_000,
950 ),
951 updatedAt: at(
952 scenario === "recent-update" ||
953 scenario === "settle-script" ||
954 scenario === "inactive-settling"
955 ? 0
956 : -10 * DAY_MS,
957 ),
958 });
959 fixture.setThreads([thread]);
960 if (scenario === "dirty")
961 yield* fixture.fs.writeFileString(`${fixture.worktree}/tracked.txt`, "edit\n");
962 const report = yield* fixture.service.runNow;
963 const removed = ["immediate", "expired", "boundary", "project-custom"].includes(scenario);
964 expect(yield* fixture.fs.exists(fixture.worktree)).toBe(!removed);
965 expect(report.counts.removed).toBe(removed ? 1 : 0);
966 if (scenario !== "project-off") {
967 const reason =
968 scenario === "active"
969 ? "Not settled"
970 : scenario === "wait"
971 ? "Settled for 7 of 8 days"
972 : ["settle-script", "recent-update", "inactive-settling"].includes(scenario)
973 ? "Settled moments ago"
974 : scenario === "dirty"
975 ? "Has uncommitted changes (1 file)"
976 : `Settled for ${scenario === "expired" ? 20 : scenario === "boundary" ? 8 : 0} days`;
977 expect(report.entries[0]).toMatchObject({ outcome: removed ? "removed" : "kept", reason });
978 }
979 expect(thread.branch).toBe("feature");
980 expect(
981 (yield* fixture.command(fixture.repo, ["rev-parse", "--verify", "refs/heads/feature"]))
982 .exitCode,
983 ).toBe(0);
984 }).pipe(Effect.provide(cleanupTestLayer), Effect.scoped),
985 );
986});
987
988describe("settled worktree rechecks", () => {
989 it.live.each([{ settledAt: null }, { settledAt: at(DAY_MS) }])(
990 "keeps a checkout changed during measurement (%j)",
991 (change) =>
992 runCleanupTest(
993 Effect.gen(function* () {
994 const fixture = yield* pausedMeasurementFixture();
995 const thread = shell({
996 branch: "feature",
997 worktreePath: fixture.worktree,
998 settledAt: at(0),
999 });
1000 fixture.setSettings({
1001 storageCleanup: {
1002 ...DEFAULT_SERVER_SETTINGS.storageCleanup,
1003 worktreeSettledAfterDays: 0,
1004 },
1005 });
1006 fixture.setThreads([thread]);
1007 const run = yield* fixture.service.runNow.pipe(Effect.forkChild);
1008 yield* Deferred.await(fixture.measuring);
1009 fixture.setThreads([{ ...thread, ...change }]);
1010 yield* Deferred.succeed(fixture.resume, undefined);
1011 const report = yield* Fiber.join(run);
1012 expect(report.entries[0]?.reason).toBe(
1013 "Thread activity or shared worktree changed since check",
1014 );
1015 expect(yield* fixture.fs.exists(fixture.worktree)).toBe(true);
1016 }),
1017 ),
1018 );
1019});
1020
1021it.live("keeps saved usage data when removing a settled checkout", () =>
1022 runCleanupTest(
1023 Effect.gen(function* () {
1024 const fixture = yield* cleanupFixture();
1025 fixture.setSettings({
1026 storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeSettledAfterDays: 0 },
1027 });
1028 fixture.setThreads([
1029 shell({ branch: "feature", worktreePath: fixture.worktree, settledAt: at(0) }),
1030 ]);
1031 yield* fixture.fs.makeDirectory(fixture.config.stateDir, { recursive: true });
1032 const cachePath = `${fixture.config.stateDir}/${SCAN_CACHE_FILE_NAME}`;
1033 const savedUsage = JSON.stringify({ tokens: 12345, cost: 1.25 });
1034 yield* fixture.fs.writeFileString(cachePath, savedUsage);
1035 expect((yield* fixture.service.runNow).counts.removed).toBe(1);
1036 expect(yield* fixture.fs.readFileString(cachePath)).toBe(savedUsage);
1037 }),
1038 ),
1039);
1040
1041it.live.each(["thread.settled", "stopped", "ready"] as const)(
1042 "leaves settled cleanup to the regular sweeps after %s",
1043 (trigger) =>
1044 runCleanupTest(
1045 Effect.gen(function* () {
1046 const fixture = yield* pausedMeasurementFixture({
1047 ...DEFAULT_SERVER_SETTINGS.storageCleanup,
1048 worktreeSettledAfterDays: 0,
1049 });
1050 fixture.setSettings({
1051 storageCleanup: {
1052 ...DEFAULT_SERVER_SETTINGS.storageCleanup,
1053 worktreeSettledAfterDays: 0,
1054 },
1055 });
1056 const thread = shell({
1057 branch: "feature",
1058 worktreePath: fixture.worktree,
1059 settledAt: at(0),
1060 });
1061 fixture.setThreads([thread]);
1062 const handled = yield* Deferred.make<void>();
1063 yield* fixture.activate(
1064 Stream.fromEffect(Deferred.await(fixture.measuring)).pipe(
1065 Stream.flatMap(() => Stream.range(0, 19)),
1066 Stream.map((index): OrchestrationV2DomainEvent =>
1067 trigger === "thread.settled"
1068 ? {
1069 id: EventId.make(`settled-${index}`),
1070 type: "thread.settled",
1071 threadId: thread.id,
1072 occurredAt: at(0),
1073 payload: { ...thread, lastVisitedAt: null },
1074 }
1075 : sessionUpdated(index, trigger),
1076 ),
1077 Stream.concat(
1078 Stream.fromEffect(Deferred.succeed(handled, undefined)).pipe(Stream.drain),
1079 ),
1080 ),
1081 );
1082 yield* Deferred.await(handled);
1083 yield* Deferred.succeed(fixture.resume, undefined);
1084 yield* fixture.service.drain;
1085 expect(fixture.snapshotReads()).toBe(1);
1086 }),
1087 ),
1088);
apps/server/src/storageCleanup.ts+47-13
......@@ -38,6 +38,7 @@ import * as GitManager from "./git/GitManager.ts";
3838import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
3939import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
4040import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts";
41import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts";
4142import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts";
4243import { forkParked } from "./serverActivation.ts";
4344import * as Settings from "./serverSettings.ts";
......@@ -56,6 +57,9 @@ const decodeCleanupSession = Schema.decodeUnknownEffect(
5657);
5758
5859const DAY_MS = 86_400_000;
60// After a settle, the project's settle script needs time to open its
61// terminal, which then protects the worktree.
62const SETTLE_SCRIPT_GRACE_MS = 60_000;
5963
6064const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);
6165const REPORT_ENTRY_LIMIT = 200;
......@@ -69,6 +73,7 @@ function cleanupFailureReason(error: { readonly message: string }) {
6973
7074const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) =>
7175 rules.worktreeAfterDays !== null ||
76 rules.worktreeSettledAfterDays !== null ||
7277 rules.worktreeOnMerge ||
7378 rules.worktreeOnDelete ||
7479 rules.worktreeUnchanged;
......@@ -172,6 +177,7 @@ const make = Effect.gen(function* () {
172177 const projectStore = yield* ProjectStore.ProjectStoreV2;
173178 const engine = yield* Orchestrator.OrchestratorV2;
174179 const projections = yield* ProjectionStore.ProjectionStoreV2;
180 const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
175181 const sql = yield* SqlClient.SqlClient;
176182 const git = yield* GitVcsDriver.GitVcsDriver;
177183 const gitManager = yield* GitManager.GitManager;
......@@ -483,15 +489,35 @@ const make = Effect.gen(function* () {
483489 return keep("repository or branch changed");
484490 const changes = yield* localChangesReason(worktreePath, settings);
485491 if (changes !== null) return keep(changes);
492 // The project's settle script starts in the worktree right after a
493 // settle. Settling stamps updatedAt now, even when automatic
494 // settlement backdates settledAt to the thread's last activity.
495 if (
496 !deleted &&
497 thread.settledAt !== null &&
498 now - DateTime.toEpochMillis(thread.updatedAt) < SETTLE_SCRIPT_GRACE_MS
499 )
500 return keep("settled moments ago");
486501 const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" });
487502 const old =
488503 !deleted &&
489504 settings.worktreeAfterDays !== null &&
490505 storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS;
491 let eligible = deleted || old;
506 const settledFor =
507 deleted || thread.settledAt === null
508 ? null
509 : now - DateTime.toEpochMillis(thread.settledAt);
510 const settledDays = settledFor === null ? null : Math.floor(settledFor / DAY_MS);
511 const settled =
512 settledFor !== null &&
513 settings.worktreeSettledAfterDays !== null &&
514 settledFor >= settings.worktreeSettledAfterDays * DAY_MS;
515 let eligible = deleted || old || settled;
492516 let removalReason = deleted
493517 ? "thread was deleted"
494 : `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`;
518 : old
519 ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`
520 : `settled for ${settledDays} days`;
495521 if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) {
496522 const repositoryCwd = path.resolve(project.workspaceRoot);
497523 const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd);
......@@ -539,9 +565,13 @@ const make = Effect.gen(function* () {
539565 return keep(
540566 !deleted && settings.worktreeAfterDays !== null
541567 ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days`
542 : settings.worktreeOnMerge
543 ? "not merged"
544 : "has commits beyond the default branch",
568 : settings.worktreeSettledAfterDays !== null
569 ? settledFor === null
570 ? "not settled"
571 : `settled for ${settledDays} of ${settings.worktreeSettledAfterDays} days`
572 : settings.worktreeOnMerge
573 ? "not merged"
574 : "has commits beyond the default branch",
545575 );
546576 // Threads, queued deletion work and provider sessions as they are now, so a
547577 // queued turn, resumed session or new thread sharing this path cancels
......@@ -577,7 +607,8 @@ const make = Effect.gen(function* () {
577607 if (
578608 latestShell === null ||
579609 !storageCleanupThreadIdle(latestShell, now) ||
580 storageCleanupActivityAt(latestShell) !== storageCleanupActivityAt(thread)
610 storageCleanupActivityAt(latestShell) !== storageCleanupActivityAt(thread) ||
611 !Equal.equals(latestShell.settledAt, thread.settledAt)
581612 )
582613 return "thread activity or shared worktree changed since check";
583614 }
......@@ -589,13 +620,16 @@ const make = Effect.gen(function* () {
589620 const sessions = yield* Effect.forEach(sessionRows, (row) =>
590621 decodeCleanupSession(row.payload_json),
591622 );
592 if (
593 sessions.some((session) => {
594 const cwd = path.resolve(session.cwd);
595 return cwd === worktreePath || inside(worktreePath, cwd);
596 })
597 )
598 return "provider session is still open";
623 // A stored session can stay ready after its runtime was released,
624 // for example once every thread detached; only a live one holds it.
625 for (const session of sessions) {
626 const cwd = path.resolve(session.cwd);
627 if (
628 (cwd === worktreePath || inside(worktreePath, cwd)) &&
629 (yield* providerSessions.isLive(session.id))
630 )
631 return "provider session is still open";
632 }
599633 return null;
600634 });
601635 // Most cancelled removals are caught here, before the worktree is walked.
apps/web/src/components/settings/StorageSettings.test.tsx created+168
......@@ -0,0 +1,168 @@
1// @vitest-environment jsdom
2import { RegistryProvider } from "@effect/atom-react";
3import { DEFAULT_SERVER_SETTINGS, ProjectId, type StorageCleanupReport } from "@t3tools/contracts";
4import { act, type ReactNode } from "react";
5import { createRoot, type Root } from "react-dom/client";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 settings: {} as typeof DEFAULT_SERVER_SETTINGS,
10 run: vi.fn(),
11}));
12
13vi.mock("../../state/server", async () => {
14 const { Atom } = await import("effect/reactivity");
15 const report = Atom.make({ _tag: "Success", value: null, waiting: false });
16 const allowed = Atom.make(true);
17 return {
18 serverEnvironment: {
19 storageCleanupReport: () => report,
20 runStorageCleanup: { permissionAtom: () => allowed },
21 },
22 };
23});
24vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.run }));
25vi.mock("./SettingsScopeContext", () => ({
26 useSettingsScope: () => {
27 const environment = {
28 environmentId: "env",
29 label: "Machine",
30 serverConfig: {
31 settings: state.settings,
32 environment: {
33 capabilities: {
34 storageCleanup: true,
35 storageCleanupRun: true,
36 settledWorktreeCleanup: true,
37 },
38 },
39 },
40 };
41 return {
42 scope: { kind: "environment" },
43 environments: [environment],
44 connectedEnvironments: [environment],
45 targets: [],
46 target: null,
47 };
48 },
49}));
50vi.mock("./useScopedSettings", () => ({
51 useScopedSettings: () => state.settings,
52 useScopedSettingsMixed: () => false,
53 useUpdateScopedSettings: () => vi.fn(),
54 useClearScopedSettings: () => vi.fn(),
55}));
56vi.mock("./settingsLayout", () => ({
57 SettingsPageContainer: ({ children }: { children: ReactNode }) => children,
58 SettingsSection: ({ children }: { children: ReactNode }) => children,
59 SettingsRow: ({ description, control }: { description: ReactNode; control: ReactNode }) => (
60 <div>
61 {description}
62 {control}
63 </div>
64 ),
65 SettingResetButton: () => null,
66 useRelativeTimeTick: () => undefined,
67}));
68vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } }));
69
70import { StorageSettingsPanel } from "./StorageSettings";
71
72let root: Root;
73let container: HTMLDivElement;
74
75beforeEach(() => {
76 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
77 state.settings = {
78 ...DEFAULT_SERVER_SETTINGS,
79 storageCleanup: {
80 worktreeKeepWhen: "uncommitted-changes",
81 worktreeAfterDays: null,
82 worktreeSettledAfterDays: null,
83 worktreeOnMerge: false,
84 worktreeOnDelete: false,
85 worktreeUnchanged: false,
86 browserArtifactsAfterDays: null,
87 logsAfterDays: null,
88 },
89 projectSettingsOverrides: {},
90 };
91 state.run.mockReset();
92 container = document.createElement("div");
93 document.body.append(container);
94 root = createRoot(container);
95});
96
97afterEach(async () => {
98 await act(async () => root.unmount());
99 container.remove();
100 vi.unstubAllGlobals();
101});
102
103async function render() {
104 await act(async () =>
105 root.render(
106 <RegistryProvider>
107 <StorageSettingsPanel />
108 </RegistryProvider>,
109 ),
110 );
111 return Array.from(container.querySelectorAll("button")).find(
112 (button) => button.textContent === "Delete now",
113 )!;
114}
115
116it.each(["machine-wide immediate", "project custom"])(
117 "runs cleanup when only %s settled retention is enabled",
118 async (scope) => {
119 if (scope === "machine-wide immediate") {
120 state.settings = {
121 ...state.settings,
122 storageCleanup: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 0 },
123 };
124 } else {
125 state.settings = {
126 ...state.settings,
127 projectSettingsOverrides: {
128 [ProjectId.make("project")]: {
129 worktreeCleanup: {
130 mode: "custom",
131 rules: { ...state.settings.storageCleanup, worktreeSettledAfterDays: 7 },
132 },
133 },
134 },
135 };
136 }
137 const report: StorageCleanupReport = {
138 trigger: "manual",
139 startedAt: "2026-10-14T12:00:00.000Z",
140 finishedAt: "2026-10-14T12:00:01.000Z",
141 entries: [],
142 counts: { removed: 0, kept: 0, failed: 0 },
143 omittedCount: 0,
144 bytesFreed: 0,
145 };
146 let complete!: (result: { _tag: "Success"; value: StorageCleanupReport }) => void;
147 state.run.mockImplementation(() => new Promise((resolve) => (complete = resolve)));
148 const button = await render();
149
150 expect(button.disabled).toBe(false);
151 await act(async () => button.click());
152 expect(state.run).toHaveBeenCalledExactlyOnceWith({ environmentId: "env", input: {} });
153 expect(button.disabled).toBe(true);
154 expect(button.textContent).toBe("Deleting…");
155
156 await act(async () => complete({ _tag: "Success", value: report }));
157 expect(button.disabled).toBe(false);
158 expect(button.textContent).toBe("Delete now");
159 },
160);
161
162it("keeps cleanup disabled when settled retention and all other rules are off", async () => {
163 const button = await render();
164
165 expect(button.disabled).toBe(true);
166 await act(async () => button.click());
167 expect(state.run).not.toHaveBeenCalled();
168});
apps/web/src/components/settings/StorageSettings.tsx+36-3
......@@ -106,10 +106,12 @@ function RetentionControl({
106106 label,
107107 value,
108108 onChange,
109 minimum = 1,
109110}: {
110111 label: string;
111112 value: number | null;
112113 onChange: (value: number | null) => void;
114 minimum?: number;
113115}) {
114116 const [draft, setDraft] = useState(value);
115117 const [savedValue, setSavedValue] = useState(value);
......@@ -123,7 +125,7 @@ function RetentionControl({
123125 {value !== null ? (
124126 <NumberField
125127 value={draft}
126 min={1}
128 min={minimum}
127129 max={3650}
128130 step={1}
129131 size="sm"
......@@ -132,7 +134,7 @@ function RetentionControl({
132134 onValueCommitted={(next) => {
133135 if (next === null) setDraft(value);
134136 else {
135 const days = Math.min(3650, Math.max(1, Math.round(next)));
137 const days = Math.min(3650, Math.max(minimum, Math.round(next)));
136138 setDraft(days);
137139 onChange(days);
138140 }
......@@ -271,6 +273,7 @@ function CleanupSection() {
271273 if (!settings) return false;
272274 const enabled = (rules: WorktreeCleanupRules) =>
273275 rules.worktreeAfterDays !== null ||
276 rules.worktreeSettledAfterDays !== null ||
274277 rules.worktreeOnMerge ||
275278 rules.worktreeOnDelete ||
276279 rules.worktreeUnchanged;
......@@ -397,7 +400,7 @@ function CleanupSection() {
397400}
398401
399402export function StorageSettingsPanel() {
400 const { scope, connectedEnvironments, targets, target } = useSettingsScope();
403 const { scope, environments, connectedEnvironments, targets, target } = useSettingsScope();
401404 const scopedSettings = useScopedSettings();
402405 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
403406 const settings = {
......@@ -538,6 +541,36 @@ export function StorageSettingsPanel() {
538541 />
539542 }
540543 />
544 {connectedEnvironments.length > 0 &&
545 connectedEnvironments.every(
546 (environment) =>
547 environment.serverConfig?.environment.capabilities.settledWorktreeCleanup ===
548 true,
549 ) && (
550 <SettingsRow
551 title="Delete settled worktrees"
552 status={[
553 ruleStatus("worktreeSettledAfterDays"),
554 environments.length > connectedEnvironments.length
555 ? "Offline machines will not get this setting"
556 : undefined,
557 ]
558 .filter(Boolean)
559 .join(". ")}
560 description="Remove worktrees this many days after their thread settles. Set 0 days to remove them at the next cleanup. Branches and thread history are kept."
561 serverScoped={!isProjectScope}
562 control={
563 <RetentionControl
564 label="Delete settled worktrees"
565 value={settings.worktreeSettledAfterDays}
566 minimum={0}
567 onChange={(worktreeSettledAfterDays) =>
568 updateWorktree({ worktreeSettledAfterDays })
569 }
570 />
571 }
572 />
573 )}
541574 <SettingsRow
542575 title="Delete merged worktrees"
543576 status={ruleStatus("worktreeOnMerge")}
apps/web/src/components/settings/scopedSettings.test.ts+15
......@@ -158,6 +158,7 @@ describe("scoped settings writes", () => {
158158 mode: "custom",
159159 rules: {
160160 worktreeAfterDays: 12,
161 worktreeSettledAfterDays: null,
161162 worktreeOnDelete: true,
162163 worktreeOnMerge: true,
163164 worktreeUnchanged: false,
......@@ -182,6 +183,7 @@ describe("scoped settings writes", () => {
182183 expect(policies).toEqual([
183184 {
184185 worktreeAfterDays: 12,
186 worktreeSettledAfterDays: null,
185187 worktreeOnDelete: false,
186188 worktreeOnMerge: true,
187189 worktreeUnchanged: false,
......@@ -189,6 +191,7 @@ describe("scoped settings writes", () => {
189191 },
190192 {
191193 worktreeAfterDays: null,
194 worktreeSettledAfterDays: null,
192195 worktreeOnDelete: false,
193196 worktreeOnMerge: false,
194197 worktreeUnchanged: false,
......@@ -381,6 +384,7 @@ describe("scoped settings writes", () => {
381384 mode: "custom",
382385 rules: {
383386 worktreeAfterDays: 8,
387 worktreeSettledAfterDays: null,
384388 worktreeOnDelete: false,
385389 worktreeOnMerge: true,
386390 worktreeUnchanged: false,
......@@ -394,6 +398,15 @@ describe("scoped settings writes", () => {
394398 const plan = planScopedSettingsPatch(project, [machine, customized], {
395399 worktreeCleanup: { mode: "custom", rules: { worktreeOnDelete: true } },
396400 });
401 const settledPlan = planScopedSettingsPatch(project, [machine, customized], {
402 worktreeCleanup: { mode: "custom", rules: { worktreeSettledAfterDays: 0 } },
403 });
404 expect(
405 settledPlan.serverWrites.map((write) => write.patch.projectSettingsOverrides),
406 ).toMatchObject([
407 { [projectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } },
408 { [laptopProjectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } },
409 ]);
397410 expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([
398411 {
399412 [projectId]: {
......@@ -402,6 +415,7 @@ describe("scoped settings writes", () => {
402415 mode: "custom",
403416 rules: {
404417 worktreeAfterDays: 8,
418 worktreeSettledAfterDays: null,
405419 worktreeOnDelete: true,
406420 worktreeOnMerge: true,
407421 worktreeUnchanged: false,
......@@ -416,6 +430,7 @@ describe("scoped settings writes", () => {
416430 mode: "custom",
417431 rules: {
418432 worktreeAfterDays: 30,
433 worktreeSettledAfterDays: null,
419434 worktreeOnDelete: true,
420435 worktreeOnMerge: false,
421436 worktreeUnchanged: false,
docs/user/project-settings.md+4-3
......@@ -149,9 +149,10 @@ Inherit follows each machine's rules; Off keeps that project's worktrees until y
149149manually. Custom applies separate worktree rules to the selected project or checkout. Browser
150150captures and log retention remain machine-wide.
151151
152Worktrees can be removed after a chosen number of inactive days, after merging, or when they
153have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active
154sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults
152Worktrees can be removed after a chosen number of inactive days, after a thread settles, after
153merging, or when they have no commits beyond the default branch. Set settled retention to 0 days
154to remove a worktree at the next cleanup after settlement. Only T3-managed worktrees are eligible.
155Active sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults
155156to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored
156157files such as `.env` and build output. **Any local files** also protects ignored files other than
157158`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and
packages/contracts/src/environment.test.ts+14
......@@ -14,6 +14,20 @@ const descriptor = {
1414} as const;
1515
1616describe("ExecutionEnvironmentDescriptor", () => {
17 it("keeps settled cleanup optional alongside current worktree capabilities", () => {
18 expect(decodeDescriptor(descriptor).capabilities.settledWorktreeCleanup).toBeUndefined();
19 const capabilities = {
20 settledWorktreeCleanup: true,
21 storageCleanupRun: true,
22 projectWorktreeCleanup: true,
23 worktreesDirectory: true,
24 threadAutoSettlementScope: true,
25 };
26 expect(decodeDescriptor({ ...descriptor, capabilities }).capabilities).toMatchObject(
27 capabilities,
28 );
29 });
30
1731 it("decodes old, recognized and future manual installation descriptors", () => {
1832 expect(decodeDescriptor(descriptor).capabilities.serverInstallation).toBeUndefined();
1933 for (const installation of [{ kind: "npx" }, { kind: "npm-global", prefix: "/opt/node" }]) {
packages/contracts/src/environment.ts+1
......@@ -129,6 +129,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
129129 /** Server checks OTLP endpoints for the telemetry export settings. */
130130 otlpEndpointCheck: Schema.optionalKey(Schema.Boolean),
131131 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
132 settledWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
132133 /** Server honors the `worktreesDirectory` setting. */
133134 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
134135 /** Server can exclude PR-linked threads from inactivity settlement. */
packages/contracts/src/settings.test.ts+31-1
......@@ -1,7 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22import * as Schema from "effect/Schema";
33
4import type { CommandId, ProjectId } from "./baseSchemas.ts";
4import { type CommandId, ProjectId } from "./baseSchemas.ts";
55import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
66import {
77 ClientSettingsSchema,
......@@ -56,6 +56,7 @@ describe("storage cleanup settings", () => {
5656 expect(decodeServerSettings({}).worktreeCleanup).toBeNull();
5757 expect(decodeServerSettings({}).storageCleanup).toEqual({
5858 worktreeAfterDays: null,
59 worktreeSettledAfterDays: null,
5960 worktreeOnMerge: false,
6061 worktreeOnDelete: false,
6162 worktreeUnchanged: false,
......@@ -72,6 +73,9 @@ describe("storage cleanup settings", () => {
7273 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: null } })).toEqual({
7374 storageCleanup: { worktreeAfterDays: null },
7475 });
76 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: 0 } })).toEqual({
77 storageCleanup: { worktreeSettledAfterDays: 0 },
78 });
7579 });
7680
7781 it("accepts partial custom patches but requires complete stored project rules", () => {
......@@ -87,6 +91,26 @@ describe("storage cleanup settings", () => {
8791 },
8892 }),
8993 ).toThrow();
94 expect(
95 decodeServerSettings({
96 projectSettingsOverrides: {
97 project: {
98 worktreeCleanup: {
99 mode: "custom",
100 rules: {
101 worktreeAfterDays: null,
102 worktreeOnMerge: false,
103 worktreeOnDelete: false,
104 worktreeUnchanged: false,
105 },
106 },
107 },
108 },
109 }).projectSettingsOverrides[ProjectId.make("project")]?.worktreeCleanup,
110 ).toMatchObject({
111 mode: "custom",
112 rules: { worktreeSettledAfterDays: null, worktreeKeepWhen: "uncommitted-changes" },
113 });
90114 });
91115
92116 it("decodes older custom rules with the default local-file policy", () => {
......@@ -117,6 +141,12 @@ describe("storage cleanup settings", () => {
117141 decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }),
118142 ).toThrow();
119143 });
144
145 it.each([-1, 1.5, 3651])("rejects invalid settled retention %s", (days) => {
146 expect(() =>
147 decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: days } }),
148 ).toThrow();
149 });
120150});
121151
122152describe("ClientSettings rich text composer", () => {
packages/contracts/src/settings.ts+11
......@@ -934,6 +934,9 @@ export type ResponseStreamingMode = typeof ResponseStreamingMode.Type;
934934const StorageRetentionDays = Schema.NullOr(
935935 Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })),
936936);
937const SettledWorktreeRetentionDays = Schema.NullOr(
938 Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3650 })),
939);
937940
938941export const WorktreeKeepWhen = Schema.Literals([
939942 "any-local-files",
......@@ -947,6 +950,9 @@ export const WorktreeCleanupRules = Schema.Struct({
947950 Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")),
948951 ),
949952 worktreeAfterDays: StorageRetentionDays,
953 worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe(
954 Schema.withDecodingDefault(Effect.succeed(null)),
955 ),
950956 worktreeOnMerge: Schema.Boolean,
951957 worktreeOnDelete: Schema.Boolean,
952958 worktreeUnchanged: Schema.Boolean,
......@@ -1089,6 +1095,9 @@ export const StorageCleanupSettings = Schema.Struct({
10891095 Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")),
10901096 ),
10911097 worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1098 worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe(
1099 Schema.withDecodingDefault(Effect.succeed(null)),
1100 ),
10921101 worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
10931102 worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
10941103 worktreeUnchanged: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
......@@ -1519,6 +1528,7 @@ export const ServerSettingsPatch = Schema.Struct({
15191528 rules: Schema.Struct({
15201529 worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen),
15211530 worktreeAfterDays: Schema.optionalKey(StorageRetentionDays),
1531 worktreeSettledAfterDays: Schema.optionalKey(SettledWorktreeRetentionDays),
15221532 worktreeOnMerge: Schema.optionalKey(Schema.Boolean),
15231533 worktreeOnDelete: Schema.optionalKey(Schema.Boolean),
15241534 worktreeUnchanged: Schema.optionalKey(Schema.Boolean),
......@@ -1531,6 +1541,7 @@ export const ServerSettingsPatch = Schema.Struct({
15311541 Schema.Struct({
15321542 worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen),
15331543 worktreeAfterDays: Schema.optionalKey(StorageRetentionDays),
1544 worktreeSettledAfterDays: Schema.optionalKey(SettledWorktreeRetentionDays),
15341545 worktreeOnMerge: Schema.optionalKey(Schema.Boolean),
15351546 worktreeOnDelete: Schema.optionalKey(Schema.Boolean),
15361547 worktreeUnchanged: Schema.optionalKey(Schema.Boolean),
packages/shared/src/projectSettings.test.ts+21-4
......@@ -422,9 +422,19 @@ describe("projectSettingsOverrides patches", () => {
422422describe("resolveWorktreeCleanup", () => {
423423 it("inherits machine rules, disables one project and keeps custom rules isolated", () => {
424424 const machine = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
425 storageCleanup: { worktreeAfterDays: 8, worktreeOnDelete: true, logsAfterDays: 3 },
425 storageCleanup: {
426 worktreeAfterDays: 8,
427 worktreeSettledAfterDays: 2,
428 worktreeKeepWhen: "any-local-files",
429 worktreeOnDelete: true,
430 logsAfterDays: 3,
431 },
426432 });
427433 const inherited = resolveWorktreeCleanup(machine, projectId);
434 expect(inherited).toMatchObject({
435 worktreeSettledAfterDays: 2,
436 worktreeKeepWhen: "any-local-files",
437 });
428438 const off = applyServerSettingsPatch(machine, {
429439 projectSettingsOverrides: {
430440 [projectId]: { worktreeCleanup: { mode: "off" } },
......@@ -432,20 +442,26 @@ describe("resolveWorktreeCleanup", () => {
432442 });
433443 expect(resolveWorktreeCleanup(off, projectId)).toEqual({
434444 worktreeAfterDays: null,
445 worktreeSettledAfterDays: null,
435446 worktreeOnDelete: false,
436447 worktreeOnMerge: false,
437448 worktreeUnchanged: false,
438 worktreeKeepWhen: "uncommitted-changes",
449 worktreeKeepWhen: "any-local-files",
439450 });
440451 expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited);
441452 const custom = applyServerSettingsPatch(off, {
442453 projectSettingsOverrides: {
443454 [projectId]: {
444 worktreeCleanup: { mode: "custom", rules: { ...inherited, worktreeAfterDays: 15 } },
455 worktreeCleanup: {
456 mode: "custom",
457 rules: { ...inherited, worktreeAfterDays: 15, worktreeSettledAfterDays: 0 },
458 },
445459 },
446460 },
447461 });
448462 expect(resolveWorktreeCleanup(custom, projectId).worktreeAfterDays).toBe(15);
463 expect(resolveWorktreeCleanup(custom, projectId).worktreeSettledAfterDays).toBe(0);
464 expect(resolveWorktreeCleanup(custom, otherProjectId)).toEqual(inherited);
449465 expect(custom.storageCleanup.logsAfterDays).toBe(3);
450466 const reset = applyServerSettingsPatch(custom, {
451467 projectSettingsOverrides: {
......@@ -456,7 +472,7 @@ describe("resolveWorktreeCleanup", () => {
456472 });
457473 it("completes partial machine custom rules and preserves them across edits", () => {
458474 const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
459 storageCleanup: { worktreeAfterDays: 8, worktreeOnDelete: true },
475 storageCleanup: { worktreeAfterDays: 8, worktreeSettledAfterDays: 0, worktreeOnDelete: true },
460476 });
461477 const custom = applyServerSettingsPatch(initial, {
462478 worktreeCleanup: { mode: "custom", rules: { worktreeOnMerge: true } },
......@@ -466,6 +482,7 @@ describe("resolveWorktreeCleanup", () => {
466482 });
467483 expect(resolveWorktreeCleanup(edited, null)).toEqual({
468484 worktreeAfterDays: 15,
485 worktreeSettledAfterDays: 0,
469486 worktreeOnDelete: true,
470487 worktreeOnMerge: true,
471488 worktreeUnchanged: false,
packages/shared/src/projectSettings.ts+3
......@@ -279,6 +279,7 @@ export function resolveWorktreeCleanup(
279279 return {
280280 worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen,
281281 worktreeAfterDays: null,
282 worktreeSettledAfterDays: null,
282283 worktreeOnMerge: false,
283284 worktreeOnDelete: false,
284285 worktreeUnchanged: false,
......@@ -286,6 +287,7 @@ export function resolveWorktreeCleanup(
286287 const {
287288 worktreeKeepWhen,
288289 worktreeAfterDays,
290 worktreeSettledAfterDays,
289291 worktreeOnMerge,
290292 worktreeOnDelete,
291293 worktreeUnchanged,
......@@ -293,6 +295,7 @@ export function resolveWorktreeCleanup(
293295 return {
294296 worktreeKeepWhen,
295297 worktreeAfterDays,
298 worktreeSettledAfterDays,
296299 worktreeOnMerge,
297300 worktreeOnDelete,
298301 worktreeUnchanged,
packages/shared/src/serverSettings.test.ts+1
......@@ -63,6 +63,7 @@ describe("serverSettings helpers", () => {
6363 }).storageCleanup,
6464 ).toEqual({
6565 worktreeAfterDays: null,
66 worktreeSettledAfterDays: null,
6667 worktreeOnMerge: true,
6768 worktreeOnDelete: false,
6869 worktreeUnchanged: false,
packages/shared/src/serverSettings.ts+1
......@@ -523,6 +523,7 @@ export function applyServerSettingsPatch(
523523 rules: {
524524 worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen,
525525 worktreeAfterDays: next.storageCleanup.worktreeAfterDays,
526 worktreeSettledAfterDays: next.storageCleanup.worktreeSettledAfterDays,
526527 worktreeOnMerge: next.storageCleanup.worktreeOnMerge,
527528 worktreeOnDelete: next.storageCleanup.worktreeOnDelete,
528529 worktreeUnchanged: next.storageCleanup.worktreeUnchanged,