From ab35920c2d636ba6b331a3d796b04bab9161a695 Mon Sep 17 00:00:00 2001 From: Dominic R Date: Fri, 9 Oct 2026 18:57:51 -0400 Subject: [PATCH] server, docs: hold auto-settle while a background command runs Auto-settlement reused the completion-alert rule, which ignores pending background commands. A Claude background Bash the agent is waiting on wakes the agent when it exits, so the thread settled while it was still working. Any live pending background task now holds auto-settle. Alerts and sidebar status keep their rule. --- .../ThreadSettlementService.test.ts | 45 +++++++++++++++++-- .../ThreadSettlementService.ts | 8 ++-- docs/user/thread-sidebar.md | 10 +++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 00daff5a8c87b..193339151b822 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -151,17 +151,18 @@ describe("isAutoSettlementCandidate", () => { ).toBe(false); }); - it("settles a thread whose only background work is a command left running", () => { + it("holds a thread whose only background work is a live command", () => { + // The agent may be waiting on the command, which wakes it when it exits. expect( ThreadSettlementService.isAutoSettlementCandidate( shell({ pendingBackgroundTasks: [ - { taskId: "dev", kind: "command", description: "vp run dev --share" }, + { taskId: "ci", kind: "command", description: "gh run watch --exit-status" }, ], }), NOW_MS, ), - ).toBe(true); + ).toBe(false); }); it("keeps snoozed threads parked until they wake early on error or completion", () => { @@ -357,6 +358,44 @@ describe("resolveAutoSettlementAt", () => { ).toBeNull(); }); + it("does not settle on merge or inactivity while a background command runs", () => { + const idle = shell({ + latestUserMessageAt: at(-4 * DAY_MS), + latestRunCompletedAt: at(-3 * DAY_MS), + }); + const waiting = { + ...idle, + pendingBackgroundTasks: [{ taskId: "ci", kind: "command" as const }], + }; + const merged = { + state: "merged" as const, + mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), + }; + const onMerge = { + thread: waiting, + pullRequest: merged, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }; + const onInactivity = { + thread: waiting, + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 2, + autoSettleOnMerge: false, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(onMerge)).toBeNull(); + expect(ThreadSettlementService.resolveAutoSettlementAt(onInactivity)).toBeNull(); + // Once the command exits, both rules settle the same thread. + expect(ThreadSettlementService.resolveAutoSettlementAt({ ...onMerge, thread: idle })).toEqual( + at(-3 * DAY_MS), + ); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ ...onInactivity, thread: idle }), + ).toEqual(at(-3 * DAY_MS)); + }); + it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 8b55e25cdc393..666bb5402782c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -1,4 +1,3 @@ -import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; import { @@ -144,10 +143,11 @@ export function isAutoSettlementCandidate( if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false; // Blocked-on-you work must never park behind a settled override. if (thread.pendingRuntimeRequest !== null) return false; - // A live run, or background work that will wake the agent, is not - // staleness. A dev server left running is: the agent is done. + // A live run or live background work is not staleness. Unlike completion + // alerts, this counts commands too: a background command can wake the agent + // when it exits, and auto-settle is silent and hides the thread, so it waits. if (thread.activityRunStatus != null) return false; - if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; + if ((thread.pendingBackgroundTasks ?? []).length > 0) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b676..53308159a97aa 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -149,10 +149,12 @@ Press `Escape` while dragging to cancel. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work -prevent automatic settlement. An open pull request does not prevent inactivity -settlement, but an old closed or merged pull request does not settle work you -resumed after it closed. Only your own messages count as resuming. A turn that -finished background work or a pull request watch starts on its own does not. +prevent automatic settlement. A command the agent left running in the background, +such as a dev server, holds the thread until the command exits or you settle the +thread. An open pull request does not prevent inactivity settlement, but an old +closed or merged pull request does not settle work you resumed after it closed. Only +your own messages count as resuming. A turn that finished background work or a pull +request watch starts on its own does not. To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick