# Files changed · PR \#19665 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/19665/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): subagent threads settle with their parent instead of sweeping on their own](https://git.cafe/pingdotgg/t3code/pull/19665.md)

Version: 9

Head commit: 1ba181e4212606109d6dfe4ae22fd1aee5dea4a9

Base commit: 1451e7cb23034d29774338608368a15d8845df36

Comparison base: 1451e7cb23034d29774338608368a15d8845df36

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/orchestration-v2/ProjectionSettlement.test.ts

Status: modified; +45; −0

```diff
@@ -423,6 +423,51 @@
   }).pipe(Effect.provide(testLayer)),
 );
 
+it.effect.each([
+  ["sql", layerSql],
+  ["memory", ProjectionStore.layerMemory],
+] as const)("%s: a subagent thread becomes a candidate once its parent is done", ([, testLayer]) =>
+  Effect.gen(function* () {
+    const store = yield* ProjectionStore.ProjectionStoreV2;
+    const parent = yield* createThread("subagent-parent");
+    const child = yield* createThread("subagent-child", {
+      lineage: { parentThreadId: parent, relationshipToParent: "subagent", rootThreadId: parent },
+    });
+    const ids = () =>
+      store.getSettlementCandidates().pipe(Effect.map((rows) => rows.map((row) => row.id)));
+    assert.deepEqual(yield* ids(), [parent]);
+
+    const parentThread = yield* store.getThread(parent);
+    for (const [type, overrides] of [
+      ["thread.settled", { settledOverride: "settled", settledAt: now }],
+      ["thread.archived", { archivedAt: now }],
+      ["thread.deleted", { deletedAt: now }],
+    ] satisfies ReadonlyArray<
+      readonly [
+        "thread.settled" | "thread.archived" | "thread.deleted",
+        Partial<OrchestrationV2AppThread>,
+      ]
+    >) {
+      yield* store.apply({
+        id: EventId.make(`event:settlement:subagent-parent:${type}`),
+        type,
+        threadId: parent,
+        occurredAt: now,
+        payload: { ...parentThread, ...overrides, updatedAt: now },
+      });
+      assert.deepEqual(yield* ids(), [child]);
+    }
+    const orphan = yield* createThread("orphan", {
+      lineage: {
+        parentThreadId: ThreadId.make("missing-parent"),
+        relationshipToParent: "subagent",
+        rootThreadId: parent,
+      },
+    });
+    assert.deepEqual(new Set(yield* ids()), new Set([child, orphan]));
+  }).pipe(Effect.provide(testLayer)),
+);
+
 it.effect(
   "reads settlement candidates and thread metadata without loading historical or archived payloads",
   () =>
```

## apps/server/src/orchestration-v2/ProjectionStore.ts

Status: modified; +27; −0

```diff
@@ -180,6 +180,7 @@
   | "projectId"
   | "branch"
   | "worktreePath"
+  | "lineage"
   | "pullRequests"
   | "linkedPullRequest"
   | "branchPullRequest"
@@ -5639,6 +5640,16 @@
               AND json_extract(t.payload_json, '$.settledOverride') IS NULL
               AND json_extract(t.payload_json, '$.pinnedAt') IS NULL
               AND json_extract(t.payload_json, '$.autoSettleDisabledAt') IS NULL
+              AND (
+                json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent'
+                OR NOT EXISTS (
+                  SELECT 1 FROM orchestration_v2_projection_threads parent
+                  WHERE parent.thread_id = json_extract(t.payload_json, '$.lineage.parentThreadId')
+                    AND parent.deleted_at IS NULL
+                    AND json_extract(parent.payload_json, '$.archivedAt') IS NULL
+                    AND json_extract(parent.payload_json, '$.settledOverride') IS NOT 'settled'
+                )
+              )
               AND NOT EXISTS (
                 SELECT 1 FROM orchestration_v2_projection_runs active
                 WHERE active.thread_id = t.thread_id
@@ -6214,6 +6225,21 @@
       getSettlementCandidates: (threadId) =>
         Effect.gen(function* () {
           const projections = (yield* Ref.get(replayState)).projections;
+          // A subagent thread follows its parent: it is a candidate only once
+          // the parent is settled, archived, deleted or gone.
+          const parentDone = (thread: Pick<OrchestrationV2AppThread, "lineage">) => {
+            if (thread.lineage.relationshipToParent !== "subagent") return true;
+            const parent =
+              thread.lineage.parentThreadId === null
+                ? undefined
+                : projections.get(thread.lineage.parentThreadId)?.thread;
+            return (
+              parent === undefined ||
+              parent.deletedAt !== null ||
+              parent.archivedAt !== null ||
+              parent.settledOverride === "settled"
+            );
+          };
           return [...projections.values()]
             .filter(
               ({ thread, runs, runtimeRequests }) =>
@@ -6223,6 +6249,7 @@
                 thread.settledOverride === null &&
                 thread.pinnedAt == null &&
                 thread.autoSettleDisabledAt == null &&
+                parentDone(thread) &&
                 !runs.some(isActivityRunForShell) &&
                 !runtimeRequests.some((request) => request.status === "pending"),
             )
```

## apps/server/src/orchestration-v2/ThreadSettlementService.test.ts

Status: modified; +433; −44

```diff
@@ -7,6 +7,9 @@
   EventId,
   ProviderInstanceId,
   ProviderSessionId,
+  MessageId,
+  RunId,
+  type OrchestrationV2Run,
   ThreadId,
   type OrchestrationProjectShell,
   type OrchestrationV2AppThread,
@@ -582,6 +585,7 @@
   ) => Effect.Effect<void>;
   readonly onCloseIdle?: () => Effect.Effect<void>;
   readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;
+  readonly projections?: ProjectionStore.ProjectionStoreV2Shape;
 }
 
 const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: HarnessOptions) {
@@ -607,6 +611,7 @@
   const summaryRecovery = yield* Ref.make<ReadonlyArray<boolean | undefined>>([]);
   const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
   const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();
+  const observedEvents = yield* Queue.unbounded<EventId>();
   const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>();
   const scriptRuns =
     yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();
@@ -672,18 +677,24 @@
       listShells: () => Ref.get(snapshots).pipe(Effect.map((snapshot) => snapshot.projects)),
     }),
     Layer.mock(ProjectionStore.ProjectionStoreV2)({
+      ...options.projections,
       getSettlementCandidates: (threadId) =>
         Ref.update(candidateReads, (reads) => [...reads, threadId]).pipe(
           Effect.andThen(Ref.updateAndGet(snapshotReadCount, (count) => count + 1)),
           Effect.tap((count) => Queue.offer(snapshotReads, count)),
+          Effect.andThen(
+            options.projections?.getSettlementCandidates(threadId) ??
-          Effect.andThen(Ref.get(snapshots)),
-          Effect.map((snapshot) =>
+              Ref.get(snapshots).pipe(
+                Effect.map((snapshot) =>
-            threadId === undefined
-              ? snapshot.threads
-              : snapshot.threads.filter((thread) => thread.id === threadId),
+                  threadId === undefined
+                    ? snapshot.threads
+                    : snapshot.threads.filter((thread) => thread.id === threadId),
+                ),
+              ),
           ),
         ),
       getThread: (threadId) => {
+        if (options.projections) return options.projections.getThread(threadId);
         const thread = options.currentThreads?.find((candidate) => candidate.id === threadId);
         return thread
           ? Effect.succeed(thread)
@@ -691,7 +702,9 @@
       },
     }),
     Layer.mock(Orchestrator.OrchestratorV2)({
+      streamDomainEvents: Stream.fromPubSub(domainEvents).pipe(
+        Stream.tap((event) => Queue.offer(observedEvents, event.id)),
-      streamDomainEvents: Stream.fromPubSub(domainEvents),
+      ),
       dispatch,
     }),
     Layer.mock(TerminalManager.TerminalManager)({
@@ -725,6 +738,7 @@
 
   return {
     activation,
+    settings,
     snapshots,
     snapshotReadCount,
     snapshotReads,
@@ -736,6 +750,7 @@
     invalidatedCwds,
     closedIdle,
     scriptRuns,
+    observedEvents,
     publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event),
     updateSettings,
     publishMerge: PubSub.publish(mergedPullRequests, {
@@ -759,6 +774,44 @@
   yield* reactor.drain;
 });
 
+const appThread = (
+  id: string,
+  settledOverride: OrchestrationV2AppThread["settledOverride"],
+  worktreePath: string | null = null,
+): OrchestrationV2AppThread => {
+  const threadId = ThreadId.make(id);
+  return {
+    createdBy: "user",
+    creationSource: "web",
+    id: threadId,
+    projectId: PROJECT_ID,
+    title: id,
+    providerInstanceId: ProviderInstanceId.make("codex"),
+    modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+    runtimeMode: "full-access",
+    interactionMode: "default",
+    branch: null,
+    worktreePath,
+    activeProviderThreadId: null,
+    lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
+    forkedFrom: null,
+    createdAt: DateTime.makeUnsafe(NOW),
+    updatedAt: DateTime.makeUnsafe(NOW),
+    archivedAt: null,
+    settledOverride,
+    settledAt: settledOverride === "settled" ? DateTime.makeUnsafe(NOW) : null,
+    lastVisitedAt: null,
+    deletedAt: null,
+  };
+};
+const settledEvent = (thread: OrchestrationV2AppThread): OrchestrationV2DomainEvent => ({
+  type: "thread.settled",
+  id: EventId.make(`event:settled:${thread.id}`),
+  threadId: thread.id,
+  occurredAt: DateTime.makeUnsafe(NOW),
+  payload: thread,
+});
+
 describe("ThreadSettlementServiceV2 worker", () => {
   it.effect("settles a merged pull request stored only in the thread links", () =>
     Effect.scoped(
@@ -855,6 +908,345 @@
     ),
   );
 
+  it.effect(
+    "drains idle subagents of a settled parent and retries busy children without PR lookups",
+    () =>
+      Effect.scoped(
+        Effect.gen(function* () {
+          yield* TestClock.setTime(Date.parse(NOW));
+          const store = yield* ProjectionStore.ProjectionStoreV2;
+          const parent = appThread("subagent-parent", "settled");
+          const child = (id: string, overrides: Partial<OrchestrationV2AppThread> = {}) => ({
+            ...appThread(id, null),
+            branch: "feature",
+            lineage: {
+              rootThreadId: parent.id,
+              parentThreadId: parent.id,
+              relationshipToParent: "subagent" as const,
+            },
+            ...overrides,
+          });
+          const empty = child("empty");
+          const recent = child("recent");
+          const busy = child("busy");
+          const marker = appThread("marker", "settled");
+          for (const thread of [
+            parent,
+            empty,
+            recent,
+            busy,
+            marker,
+            child("pinned", { pinnedAt: DateTime.makeUnsafe(NOW) }),
+            child("opted-out", { autoSettleDisabledAt: DateTime.makeUnsafe(NOW) }),
+          ]) {
+            yield* store.apply({
+              type: "thread.created",
+              id: EventId.make(`created:${thread.id}`),
+              threadId: thread.id,
+              occurredAt: thread.createdAt,
+              payload: thread,
+            });
+          }
+          const run = (
+            threadId: ThreadId,
+            status: "running" | "completed",
+          ): OrchestrationV2Run => ({
+            id: RunId.make(`run:${threadId}`),
+            threadId,
+            ordinal: 1,
+            providerInstanceId: parent.providerInstanceId,
+            modelSelection: parent.modelSelection,
+            providerThreadId: null,
+            userMessageId: MessageId.make(`message:${threadId}`),
+            rootNodeId: null,
+            activeAttemptId: null,
+            status,
+            requestedAt: DateTime.makeUnsafe(NOW),
+            startedAt: DateTime.makeUnsafe(NOW),
+            completedAt: status === "completed" ? DateTime.makeUnsafe(NOW) : null,
+            checkpointId: null,
+            contextHandoffId: null,
+          });
+          for (const payload of [run(recent.id, "completed"), run(busy.id, "running")]) {
+            yield* store.apply({
+              type: "run.created",
+              id: EventId.make(`created:${payload.id}`),
+              threadId: payload.threadId,
+              occurredAt: DateTime.makeUnsafe(NOW),
+              payload,
+            });
+          }
+          const fixture = yield* makeHarness({
+            snapshot: makeSnapshot([]),
+            projections: store,
+            settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 2 },
+            onDispatch: (command) =>
+              store.getThread(command.threadId).pipe(
+                Effect.flatMap((thread) =>
+                  store.apply(
+                    settledEvent({
+                      ...thread,
+                      settledOverride: "settled",
+                      settledAt: DateTime.makeUnsafe(NOW),
+                    }),
+                  ),
+                ),
+                Effect.orDie,
+              ),
+          });
+          yield* Effect.gen(function* () {
+            const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
+            yield* startHarness(service, fixture.activation, fixture.snapshotReads);
+            const commands = yield* Ref.get(fixture.commands);
+            expect(commands.map((command) => command.threadId).toSorted()).toEqual([
+              empty.id,
+              recent.id,
+            ]);
+            for (const command of commands) {
+              expect(command.snapshotAt).toEqual(DateTime.makeUnsafe(NOW));
+              expect(command.settledAt).toBeUndefined();
+            }
+            const finished: OrchestrationV2DomainEvent = {
+              type: "run.updated",
+              id: EventId.make("busy:completed"),
+              threadId: busy.id,
+              occurredAt: DateTime.makeUnsafe(NOW),
+              payload: run(busy.id, "completed"),
+            };
+            yield* store.apply(finished);
+            yield* fixture.publishEvent(finished);
+            // Cleanup of the following event marks delivery of the run event before draining.
+            yield* fixture.publishEvent(settledEvent(marker));
+            expect(yield* Queue.take(fixture.closedIdle)).toEqual({ threadId: marker.id });
+            yield* service.drain;
+            expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
+              empty.id,
+              recent.id,
+              busy.id,
+            ]);
+            expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
+            expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
+          }).pipe(Effect.provide(fixture.layer));
+        }).pipe(Effect.provide(ProjectionStore.layerMemory)),
+      ),
+  );
+
+  it.effect(
+    "manual parent settlement drains hidden children with automatic settlement disabled",
+    () =>
+      Effect.scoped(
+        Effect.gen(function* () {
+          yield* TestClock.setTime(Date.parse(NOW));
+          const store = yield* ProjectionStore.ProjectionStoreV2;
+          const parent = appThread("manual-parent", null);
+          const child = {
+            ...appThread("manual-child", null),
+            branch: "feature",
+            lineage: {
+              rootThreadId: parent.id,
+              parentThreadId: parent.id,
+              relationshipToParent: "subagent" as const,
+            },
+          };
+          const marker = appThread("marker", "settled");
+          for (const thread of [parent, child, marker]) {
+            yield* store.apply({
+              type: "thread.created",
+              id: EventId.make(`created:${thread.id}`),
+              threadId: thread.id,
+              occurredAt: thread.createdAt,
+              payload: thread,
+            });
+          }
+          const fixture = yield* makeHarness({
+            snapshot: makeSnapshot([]),
+            projections: store,
+            settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 2 },
+            onDispatch: (command) =>
+              store.getThread(command.threadId).pipe(
+                Effect.flatMap((thread) =>
+                  store.apply(
+                    settledEvent({
+                      ...thread,
+                      settledOverride: "settled",
+                      settledAt: DateTime.makeUnsafe(NOW),
+                    }),
+                  ),
+                ),
+                Effect.orDie,
+              ),
+          });
+          yield* Effect.gen(function* () {
+            const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
+            yield* startHarness(service, fixture.activation, fixture.snapshotReads);
+            expect(yield* Ref.get(fixture.commands)).toEqual([]);
+            expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
+            // Avoid a settings-change sweep: the parent event must trigger the child dispatch.
+            yield* Ref.set(fixture.settings, {
+              ...DEFAULT_SERVER_SETTINGS,
+              sidebarAutoSettleAfterDays: null,
+              sidebarAutoSettleOnMerge: false,
+            });
+            const event = settledEvent({
+              ...parent,
+              settledOverride: "settled",
+              settledAt: DateTime.makeUnsafe(NOW),
+            });
+            yield* store.apply(event);
+            yield* fixture.publishEvent(event);
+            yield* fixture.publishEvent(settledEvent(marker));
+            expect(yield* Queue.take(fixture.closedIdle)).toEqual({ threadId: parent.id });
+            expect(yield* Queue.take(fixture.closedIdle)).toEqual({ threadId: marker.id });
+            yield* service.drain;
+            expect(yield* Ref.get(fixture.commands)).toEqual([
+              expect.objectContaining({
+                type: "thread.auto-settle",
+                threadId: child.id,
+                snapshotAt: child.updatedAt,
+              }),
+            ]);
+            expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
+            expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
+          }).pipe(Effect.provide(fixture.layer));
+        }).pipe(Effect.provide(ProjectionStore.layerMemory)),
+      ),
+  );
+
+  it.effect(
+    "coalesces bulk cleanup wakes and still drains children discovered during a sweep",
+    () =>
+      Effect.scoped(
+        Effect.gen(function* () {
+          yield* TestClock.setTime(Date.parse(NOW));
+          const store = yield* ProjectionStore.ProjectionStoreV2;
+          const sweepStarted = yield* Deferred.make<void>();
+          const releaseSweep = yield* Deferred.make<void>();
+          const childSettled = yield* Deferred.make<void>();
+          const parent = appThread("bulk-parent", null);
+          const child = {
+            ...appThread("bulk-child", null),
+            lineage: {
+              parentThreadId: parent.id,
+              rootThreadId: parent.id,
+              relationshipToParent: "subagent" as const,
+            },
+          };
+          const marker = appThread("bulk-marker", "settled");
+          const parents = [
+            parent,
+            ...Array.from({ length: 233 }, (_, i) => appThread(`bulk-parent-${i}`, null)),
+          ];
+          for (const thread of [...parents, child, marker]) {
+            yield* store.apply({
+              type: "thread.created",
+              id: EventId.make(`created:${thread.id}`),
+              threadId: thread.id,
+              occurredAt: thread.createdAt,
+              payload: thread,
+            });
+          }
+          let blockNextSweep = false;
+          const fixture = yield* makeHarness({
+            snapshot: makeSnapshot([]),
+            settings: {
+              ...DEFAULT_SERVER_SETTINGS,
+              sidebarAutoSettleAfterDays: null,
+              sidebarAutoSettleOnMerge: false,
+            },
+            projections: {
+              ...store,
+              getSettlementCandidates: (threadId) =>
+                Effect.gen(function* () {
+                  const candidates = yield* store.getSettlementCandidates(threadId);
+                  if (blockNextSweep) {
+                    blockNextSweep = false;
+                    yield* Deferred.succeed(sweepStarted, undefined);
+                    yield* Deferred.await(releaseSweep);
+                  }
+                  return candidates;
+                }),
+            },
+            onDispatch: (command): Effect.Effect<void> =>
+              Effect.gen(function* () {
+                const thread = yield* store.getThread(command.threadId);
+                const event = settledEvent({
+                  ...thread,
+                  settledOverride: "settled",
+                  settledAt: DateTime.makeUnsafe(NOW),
+                });
+                yield* store.apply(event);
+                yield* fixture.publishEvent(event);
+                yield* Deferred.succeed(childSettled, undefined);
+              }).pipe(Effect.orDie),
+          });
+          yield* Effect.gen(function* () {
+            const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
+            yield* startHarness(service, fixture.activation, fixture.snapshotReads);
+            yield* Ref.set(fixture.candidateReads, []);
+            blockNextSweep = true;
+            yield* fixture.publishEvent(settledEvent(marker));
+            yield* Deferred.await(sweepStarted);
+
+            for (const [i, thread] of parents.entries()) {
+              const type = (["thread.settled", "thread.archived", "thread.deleted"] as const)[
+                i % 3
+              ]!;
+              const event: OrchestrationV2DomainEvent = {
+                type,
+                id: EventId.make(`bulk:${thread.id}`),
+                threadId: thread.id,
+                occurredAt: DateTime.makeUnsafe(NOW),
+                payload: {
+                  ...thread,
+                  ...(type === "thread.settled"
+                    ? { settledOverride: "settled" as const, settledAt: DateTime.makeUnsafe(NOW) }
+                    : type === "thread.archived"
+                      ? { archivedAt: DateTime.makeUnsafe(NOW) }
+                      : { deletedAt: DateTime.makeUnsafe(NOW) }),
+                },
+              };
+              yield* store.apply(event);
+              yield* fixture.publishEvent(event);
+            }
+            const markDelivery = (id: string) =>
+              fixture
+                .publishEvent({
+                  type: "thread.created",
+                  id: EventId.make(id),
+                  threadId: marker.id,
+                  occurredAt: marker.createdAt,
+                  payload: marker,
+                })
+                .pipe(
+                  // The next event is observed only after the previous handler has finished enqueueing.
+                  Effect.andThen(
+                    Queue.take(fixture.observedEvents).pipe(
+                      Effect.repeat({ until: (seen) => seen === id }),
+                    ),
+                  ),
+                );
+            yield* markDelivery("bulk-delivered");
+            yield* Deferred.succeed(releaseSweep, undefined);
+            yield* Deferred.await(childSettled);
+            yield* markDelivery("child-delivered");
+            yield* service.drain;
+
+            expect(yield* Ref.get(fixture.candidateReads)).toEqual([
+              undefined,
+              undefined,
+              undefined,
+            ]);
+            expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
+              child.id,
+            ]);
+            expect((yield* store.getThread(child.id)).settledOverride).toBe("settled");
+            expect(yield* Ref.get(fixture.branchCalls)).toEqual([]);
+            expect(yield* Ref.get(fixture.summaryCalls)).toEqual([]);
+          }).pipe(Effect.provide(fixture.layer));
+        }).pipe(Effect.provide(ProjectionStore.layerMemory)),
+      ),
+  );
+
   it.effect("skips the branch recheck when a terminal link would settle nothing", () =>
     Effect.scoped(
       Effect.gen(function* () {
@@ -1167,44 +1559,6 @@
 });
 
 describe("ThreadSettlementServiceV2 terminals", () => {
-  const appThread = (
-    id: string,
-    settledOverride: OrchestrationV2AppThread["settledOverride"],
-    worktreePath: string | null = null,
-  ): OrchestrationV2AppThread => {
-    const threadId = ThreadId.make(id);
-    return {
-      createdBy: "user",
-      creationSource: "web",
-      id: threadId,
-      projectId: PROJECT_ID,
-      title: id,
-      providerInstanceId: ProviderInstanceId.make("codex"),
-      modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
-      runtimeMode: "full-access",
-      interactionMode: "default",
-      branch: null,
-      worktreePath,
-      activeProviderThreadId: null,
-      lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
-      forkedFrom: null,
-      createdAt: DateTime.makeUnsafe(NOW),
-      updatedAt: DateTime.makeUnsafe(NOW),
-      archivedAt: null,
-      settledOverride,
-      settledAt: settledOverride === "settled" ? DateTime.makeUnsafe(NOW) : null,
-      lastVisitedAt: null,
-      deletedAt: null,
-    };
-  };
-  const settledEvent = (thread: OrchestrationV2AppThread): OrchestrationV2DomainEvent => ({
-    type: "thread.settled",
-    id: EventId.make(`event:settled:${thread.id}`),
-    threadId: thread.id,
-    occurredAt: DateTime.makeUnsafe(NOW),
-    payload: thread,
-  });
-
   it.effect("closes the thread's idle terminals when it settles", () =>
     Effect.scoped(
       Effect.gen(function* () {
@@ -1300,6 +1654,41 @@
     ),
   );
 
+  it.effect("skips the settle script for a subagent in its parent's worktree", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const parent = appThread("subagent-parent", "settled", "/worktrees/parent");
+        const subagent = (id: string, worktreePath: string) => ({
+          ...appThread(id, "settled", worktreePath),
+          lineage: {
+            parentThreadId: parent.id,
+            relationshipToParent: "subagent" as const,
+            rootThreadId: parent.id,
+          },
+        });
+        const shared = subagent("subagent-shared", "/worktrees/parent");
+        const own = subagent("subagent-own", "/worktrees/own");
+        const fixture = yield* makeHarness({
+          snapshot: makeSnapshot([]),
+          currentThreads: [parent, shared, own],
+          existingWorktreePaths: ["/worktrees/parent", "/worktrees/own"],
+        });
+
+        yield* Effect.gen(function* () {
+          const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
+          yield* startHarness(service, fixture.activation, fixture.snapshotReads);
+          // Events run in order, so the first run belongs to the second event
+          // only if the shared-worktree child was skipped. Both still close.
+          yield* fixture.publishEvent(settledEvent(shared));
+          yield* fixture.publishEvent(settledEvent(own));
+          assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: shared.id });
+          assert.deepStrictEqual(yield* Queue.take(fixture.closedIdle), { threadId: own.id });
+          assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, own.id);
+        }).pipe(Effect.provide(fixture.layer));
+      }),
+    ),
+  );
+
   it.effect("runs the settle script once when a settlement is re-emitted", () =>
     Effect.scoped(
       Effect.gen(function* () {
```

## apps/server/src/orchestration-v2/ThreadSettlementService.ts

Status: modified; +49; −10

```diff
@@ -320,9 +320,7 @@
     threadId?: ThreadId,
   ) {
     const settings = yield* settingsService.getSettings;
-    if (!autoSettlementConfigured(settings)) {
-      return;
-    }
+    const automaticSettlementEnabled = autoSettlementConfigured(settings);
     const threads = yield* projections.getSettlementCandidates(threadId);
     if (threads.length === 0) return;
     const projectShells = yield* projectStore.listShells();
@@ -332,10 +330,26 @@
     // the merged pull request: most threads carry no link and settle from
     // their branch lookup, which would otherwise wait for the next minute's
     // sweep on a possibly stale cached answer.
+    const candidates = threads.filter(
+      (thread) =>
+        (automaticSettlementEnabled || thread.lineage.relationshipToParent === "subagent") &&
+        isAutoSettlementCandidate(thread, nowMs),
-    const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs));
+    );
 
     const settleThread = Effect.fn("ThreadSettlementServiceV2.settleThread")(
       function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) {
+        if (thread.lineage.relationshipToParent === "subagent") {
+          // The store returns a subagent only once its parent is done, so it
+          // settles with the parent and never runs its own lookup.
+          const uuid = yield* crypto.randomUUIDv4();
+          yield* orchestrator.dispatch({
+            type: "thread.auto-settle",
+            commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
+            threadId: thread.id,
+            snapshotAt: thread.updatedAt,
+          });
+          return null;
+        }
         const currentSettings = resolveProjectSettings(
           yield* settingsService.getSettings,
           thread.projectId,
@@ -551,9 +565,18 @@
             }),
       ),
     );
+  let fullSweepPending = false;
+  const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) => {
+    // Clear at the start so events during this sweep can request one follow-up.
+    if (threadId === undefined) fullSweepPending = false;
+    return runSweep(null, threadId);
+  });
+  const enqueueFullSweep = () =>
+    Effect.suspend(() => {
-  const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) =>
-    runSweep(null, threadId),
-  );
+      if (fullSweepPending) return Effect.void;
+      fullSweepPending = true;
+      return worker.enqueue(undefined);
+    });
 
   // Then the project's settle script runs in the thread's own worktree; a
   // thread in the shared checkout skips it, because other threads may still be
@@ -571,6 +594,19 @@
       if (thread.settledOverride !== "settled") return;
       const settledAtMs = toMillis(thread.settledAt);
       if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return;
+      // The parent's worktree belongs to the parent; shared-worktree children
+      // skip duplicate cleanup there.
+      if (
+        thread.lineage.relationshipToParent === "subagent" &&
+        thread.lineage.parentThreadId !== null
+      ) {
+        const parent = yield* projections.getThread(thread.lineage.parentThreadId).pipe(
+          Effect.catchTags({
+            ProjectionStoreThreadNotFoundError: () => Effect.succeed(null),
+          }),
+        );
+        if (parent?.worktreePath === worktreePath) return;
+      }
       const run = yield* projectScripts.runForThread({
         threadId,
         projectId: thread.projectId,
@@ -599,7 +635,10 @@
   const processEvent = (event: OrchestrationV2DomainEvent) => {
     switch (event.type) {
       case "thread.settled":
+        return cleanUpSettledThread(event.threadId).pipe(Effect.andThen(enqueueFullSweep()));
-        return cleanUpSettledThread(event.threadId);
+      case "thread.archived":
+      case "thread.deleted":
+        return enqueueFullSweep();
       case "thread.pull-request-link-updated":
       case "thread.pull-request-synced":
       case "provider-session.detached":
@@ -629,7 +668,7 @@
     let lastSettlementSettings = autoSettlementSettingsKey(initialSettings);
     yield* forkParked(
       Effect.gen(function* () {
+        yield* enqueueFullSweep();
-        yield* worker.enqueue(undefined);
         yield* worker.drain;
       }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
     );
@@ -640,7 +679,7 @@
           return Effect.void;
         }
         lastSettlementSettings = key;
+        return enqueueFullSweep();
-        return worker.enqueue(undefined);
       }),
     );
     yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event)));
```
