From c72e4f6694bc92681b110d705d0c90cd9f071479 Mon Sep 17 00:00:00 2001 From: Dominic R Date: Mon, 2 Nov 2026 21:29:29 -0500 Subject: [PATCH 1/4] server, web, mobile: say why a settled thread settled and how its PR ended When a thread auto-settled because its pull request merged, the status line said only "Settled 49m ago". The timeline above it often ends on the PR watch's last update before the merge ("checks passed, the merge queue merges it next"), so the thread looked settled while its PR was still queued. Settles now record a reason on the thread: the user, an agent, the pull request merging or closing, or inactivity. The status line names it and the current PR's terminal state, e.g. "Settled 49m ago: PR #128 merged". Threads settled before this keep loading and show only how their PR ended. An idle settle shows no time, because its settledAt is the last activity rather than the moment it settled. --- .../features/threads/ThreadDetailScreen.tsx | 3 +- apps/mobile/src/state/use-thread-selection.ts | 3 + .../src/orchestration-v2/Orchestrator.ts | 4 + .../ProjectionSettlement.test.ts | 6 +- .../src/orchestration-v2/ProjectionStore.ts | 11 +++ .../SubagentProjection.test.ts | 9 +- .../ThreadManagementService.test.ts | 10 +- .../ThreadManagementService.ts | 2 + .../ThreadSettlementService.test.ts | 91 +++++++++++-------- .../ThreadSettlementService.ts | 36 ++++++-- .../src/orchestration-v2/runtimeLayer.test.ts | 16 ++++ .../components/chat/useThreadLifecycle.tsx | 8 +- docs/user/thread-sidebar.md | 2 + packages/client-runtime/src/state/models.ts | 3 + .../src/state/threadCommands.ts | 4 + .../client-runtime/src/state/threadSettled.ts | 40 ++++++++ .../src/state/threadSnoozed.test.ts | 91 +++++++++++++++++++ .../contracts/src/orchestrationV2.test.ts | 8 +- packages/contracts/src/orchestrationV2.ts | 21 +++++ 19 files changed, 308 insertions(+), 60 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0b08ce405df2..5592a89e469c7 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -20,6 +20,7 @@ import { effectiveSnoozed, threadWokeAt, isThreadWokeUnseen, + settledThreadLabel, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; import type { WorktreeSetupCardProps } from "./worktree-setup-card"; @@ -815,7 +816,7 @@ function ThreadDetailBody( snoozed && snoozedUntil !== null ? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}` : settled - ? "Settled" + ? settledThreadLabel(props.selectedThread, null) : "Woke from snooze"; const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss"; return { diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 17a4a26a1862b..1fa165ad08b5a 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -101,6 +101,9 @@ function threadDetailToShell( archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + ...(thread.settledOverride === "settled" && thread.settleReason + ? { settleReason: thread.settleReason } + : {}), unsettledAt: thread.unsettledAt, activeOrderKey: thread.activeOrderKey, autoSettleDisabledAt: thread.autoSettleDisabledAt, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 035bd9c73db66..4c87fbd512ae3 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3490,6 +3490,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), + settleReason: alreadySettled + ? (thread.settleReason ?? null) + : (command.reason ?? { type: "user" }), pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), unsettledAt: null, pinnedAt: null, @@ -13028,6 +13031,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandId: command.commandId, threadId: command.threadId, ...(command.settledAt === undefined ? {} : { settledAt: command.settledAt }), + ...(command.reason === undefined ? {} : { reason: command.reason }), }, events, effects, diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 1352e17585bfd..9dd21f4191f94 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -20,7 +20,7 @@ import * as Layer from "effect/Layer"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { isAutoSettlementCandidate, resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; +import { isAutoSettlementCandidate, resolveAutoSettlement } from "./ThreadSettlementService.ts"; const layerSql = ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)); const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); @@ -270,8 +270,8 @@ it.effect( autoSettleOnMerge: false, }; assert.deepEqual( - resolveAutoSettlementAt({ ...settings, thread: candidate }), - resolveAutoSettlementAt({ + resolveAutoSettlement({ ...settings, thread: candidate }), + resolveAutoSettlement({ ...settings, thread: { ...expected, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3cffce15b70d6..bdd10bfa8b94a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1456,6 +1456,15 @@ const linkCardStillAsks = ( latestUserAuthoredMessageAt === null || !DateTime.isGreaterThan(latestUserAuthoredMessageAt, item.startedAt ?? item.updatedAt); +// Unsettling leaves the reason on the thread record; shells send it only while it applies. +function settleReasonForShell( + thread: Pick, +) { + return thread.settledOverride === "settled" && thread.settleReason + ? { settleReason: thread.settleReason } + : {}; +} + export function threadShellFromProjection( projection: OrchestrationV2ThreadProjection, ): OrchestrationV2ThreadShell { @@ -1602,6 +1611,7 @@ export function threadShellFromProjection( archivedAt: projection.thread.archivedAt, settledOverride: projection.thread.settledOverride, settledAt: projection.thread.settledAt, + ...settleReasonForShell(projection.thread), unsettledAt: projection.thread.unsettledAt ?? null, snoozedUntil: projection.thread.snoozedUntil ?? null, snoozedAt: projection.thread.snoozedAt ?? null, @@ -1868,6 +1878,7 @@ function shellFromState(input: { archivedAt: input.state.thread.archivedAt, settledOverride: input.state.thread.settledOverride, settledAt: input.state.thread.settledAt, + ...settleReasonForShell(input.state.thread), unsettledAt: input.state.thread.unsettledAt ?? null, snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index ae8f7e09d2068..47c36b45dc3ab 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -30,7 +30,7 @@ import { import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { resolveAutoSettlementAt } from "./ThreadSettlementService.ts"; +import { resolveAutoSettlement } from "./ThreadSettlementService.ts"; const parentThreadId = ThreadId.make("thread:subagent-snoozed-parent"); const childThreadId = ThreadId.make("thread:subagent-awake-child"); @@ -173,14 +173,17 @@ it.effect.each(["provider", "mcp"] as const)( candidates.map((candidate) => candidate.id), [childThreadId], ); - const settledAt = resolveAutoSettlementAt({ + const settlement = resolveAutoSettlement({ thread: candidates[0]!, pullRequest: null, nowMs: DateTime.toEpochMillis(DateTime.add(childCreatedAt, { days: 4 })), autoSettleAfterDays: 3, autoSettleOnMerge: false, }); - assert.equal(settledAt && DateTime.formatIso(settledAt), DateTime.formatIso(childCreatedAt)); + assert.equal( + settlement && DateTime.formatIso(settlement.settledAt), + DateTime.formatIso(childCreatedAt), + ); }).pipe( Effect.provide(ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory))), ), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 9285f72d46f56..d98c745ea0ff6 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -567,7 +567,7 @@ it.effect.each([ const projectId = ProjectId.make("project:thread-management:settle-after-run"); const threadId = ThreadId.make("thread:thread-management:settle-after-run"); const runId = RunId.make("run:thread-management:settle-after-run"); - const dispatched: Array = []; + const dispatched: Array = []; const layerTest = ThreadManagementService.layer.pipe( Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( @@ -581,7 +581,7 @@ it.effect.each([ } as unknown as OrchestrationV2ThreadProjection), dispatch: (command) => Effect.sync(() => { - dispatched.push(command.type); + dispatched.push(command); return { sequence: 1, storedEvents: [] } as never; }), }), @@ -595,7 +595,11 @@ it.effect.each([ yield* service.settleAfterRun({ projectId, threadId, runId }); - expect(dispatched).toEqual(settles ? ["thread.settle"] : []); + expect(dispatched).toEqual( + settles + ? [expect.objectContaining({ type: "thread.settle", reason: { type: "agent" } })] + : [], + ); }), ); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f0346b2bec4ec..a9de80d5d470e 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1038,6 +1038,7 @@ const make = Effect.gen(function* () { type: "thread.settle", commandId: CommandId.make(`server:settle-after-run:${input.runId}`), threadId: input.threadId, + reason: { type: "agent" }, }); }); @@ -1057,6 +1058,7 @@ const make = Effect.gen(function* () { type: "thread.settle", commandId: input.commandId, threadId: input.threadId, + reason: { type: "agent" }, }); return { sequence: result.sequence }; }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index f118cd13a62dc..49a2e9219f0aa 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -197,7 +197,16 @@ describe("isAutoSettlementCandidate", () => { }); }); -describe("resolveAutoSettlementAt", () => { +describe("resolveAutoSettlement", () => { + const idleFor = (days: number, settledAt: DateTime.Utc) => ({ + settledAt, + reason: { type: "inactive", days }, + }); + const pullRequestWas = (state: "merged" | "closed", settledAt: DateTime.Utc) => ({ + settledAt, + reason: { type: "pull-request", state }, + }); + it("keeps threads with a PR from inactivity settlement only when asked", () => { const input = { thread: shell({ @@ -215,19 +224,19 @@ describe("resolveAutoSettlementAt", () => { autoSettleOnMerge: true, autoSettleKeepPullRequests: true, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, autoSettleKeepPullRequests: false, }), - ).toEqual(at(-4 * DAY_MS)); + ).toEqual(idleFor(3, at(-4 * DAY_MS))); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, }), - ).toEqual(at(-4 * DAY_MS)); + ).toEqual(pullRequestWas("merged", at(-4 * DAY_MS))); }); it("settles an idle thread with an open persisted link only while retention is off", () => { @@ -242,16 +251,18 @@ describe("resolveAutoSettlementAt", () => { autoSettleOnMerge: true, autoSettleKeepPullRequests: false, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-4 * DAY_MS)); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toEqual( + idleFor(3, at(-4 * DAY_MS)), + ); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, autoSettleKeepPullRequests: true, }), ).toBeNull(); // The open link outranks a merged lookup, so only inactivity can settle it. expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, autoSettleAfterDays: null, @@ -261,7 +272,7 @@ describe("resolveAutoSettlementAt", () => { it("keeps an idle thread whose persisted link was never synced", () => { expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ thread: shell({ latestUserMessageAt: at(-4 * DAY_MS), pullRequests: [persistedLink(null, at(-5 * DAY_MS))], @@ -289,12 +300,14 @@ describe("resolveAutoSettlementAt", () => { autoSettleAfterDays: 2, autoSettleOnMerge: true, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-3 * DAY_MS)); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toEqual( + idleFor(2, at(-3 * DAY_MS)), + ); expect( - ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: 5 }), + ThreadSettlementService.resolveAutoSettlement({ ...input, autoSettleAfterDays: 5 }), ).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null }), + ThreadSettlementService.resolveAutoSettlement({ ...input, autoSettleAfterDays: null }), ).toBeNull(); }); @@ -306,15 +319,17 @@ describe("resolveAutoSettlementAt", () => { autoSettleAfterDays: 2, autoSettleOnMerge: false, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-3 * DAY_MS)); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toEqual( + idleFor(2, at(-3 * DAY_MS)), + ); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, thread: shell({ createdAt: at(-DAY_MS) }), }), ).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, thread: { ...input.thread, lastSnoozeWakeAt: at(-DAY_MS) }, }), @@ -335,10 +350,10 @@ describe("resolveAutoSettlementAt", () => { autoSettleAfterDays: 1, autoSettleOnMerge: false, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: NOW_MS + DAY_MS }), - ).toEqual(at(-1_000)); + ThreadSettlementService.resolveAutoSettlement({ ...input, nowMs: NOW_MS + DAY_MS }), + ).toEqual(idleFor(1, at(-1_000))); }); it("settles on merge only after the user's last action and preserves the activity time", () => { @@ -356,24 +371,26 @@ describe("resolveAutoSettlementAt", () => { autoSettleAfterDays: null, autoSettleOnMerge: true, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-90 * 60 * 1_000)); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toEqual( + pullRequestWas("merged", at(-90 * 60 * 1_000)), + ); expect( - ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleOnMerge: false }), + ThreadSettlementService.resolveAutoSettlement({ ...input, autoSettleOnMerge: false }), ).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)) }, }), ).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, thread: shell(), pullRequest: { state: "closed", closedAt: DateTime.formatIso(at(-1)) }, autoSettleOnMerge: false, }), - ).toEqual(shell().createdAt); + ).toEqual(pullRequestWas("closed", shell().createdAt)); }); it("settles on merge after agent-started runs, but not after the user writes again", () => { @@ -390,9 +407,11 @@ describe("resolveAutoSettlementAt", () => { autoSettleAfterDays: null, autoSettleOnMerge: true, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-29 * 60 * 1_000)); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toEqual( + pullRequestWas("merged", at(-29 * 60 * 1_000)), + ); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, thread: { ...woken, latestUserAuthoredMessageAt: at(-30 * 60 * 1_000) }, }), @@ -431,16 +450,16 @@ describe("resolveAutoSettlementAt", () => { autoSettleAfterDays: null, autoSettleOnMerge: true, }; - expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect(ThreadSettlementService.resolveAutoSettlement(input)).toBeNull(); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ ...input, thread: { ...input.thread, pullRequests: [recorded, link(2, at(-90 * 60 * 1_000), at(-40 * 60 * 1_000))], }, }), - ).toEqual(at(-45 * 60 * 1_000)); + ).toEqual(pullRequestWas("merged", at(-45 * 60 * 1_000))); }); }); @@ -813,9 +832,9 @@ describe("ThreadSettlementServiceV2 worker", () => { yield* Effect.gen(function* () { const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; yield* startHarness(service, fixture.activation, fixture.snapshotReads); - expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ - thread.id, - ]); + const commands = yield* Ref.get(fixture.commands); + expect(commands.map((command) => command.threadId)).toEqual([thread.id]); + expect(commands[0]).toMatchObject({ reason: { type: "pull-request", state: "merged" } }); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -839,9 +858,9 @@ describe("ThreadSettlementServiceV2 worker", () => { yield* Effect.gen(function* () { const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; yield* startHarness(service, fixture.activation, fixture.snapshotReads); - expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ - thread.id, - ]); + const commands = yield* Ref.get(fixture.commands); + expect(commands.map((command) => command.threadId)).toEqual([thread.id]); + expect(commands[0]).toMatchObject({ reason: { type: "inactive", days: 3 } }); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -1474,7 +1493,7 @@ describe("isSnoozed", () => { expect(ThreadSettlementService.isSnoozed(thread, NOW_MS)).toBe(true); expect(ThreadSettlementService.isAutoSettlementCandidate(thread, NOW_MS)).toBe(false); expect( - ThreadSettlementService.resolveAutoSettlementAt({ + ThreadSettlementService.resolveAutoSettlement({ thread, pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, nowMs: NOW_MS, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 99fcb077aa718..2f84e8470e2f9 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -9,6 +9,7 @@ import { isOrchestrationV2WorkActive, type ThreadId, type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadSettleReason, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -184,14 +185,19 @@ export function isSnoozed( return !wokeOnError; } -export function resolveAutoSettlementAt(input: { +export interface AutoSettlement { + readonly settledAt: DateTime.Utc; + readonly reason: OrchestrationV2ThreadSettleReason; +} + +export function resolveAutoSettlement(input: { readonly thread: ProjectionStore.ProjectionSettlementCandidate; readonly pullRequest: SettlementPullRequest | null; readonly nowMs: number; readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; readonly autoSettleKeepPullRequests?: boolean; -}): DateTime.Utc | null { +}): AutoSettlement | null { const { thread } = input; let pullRequest = input.pullRequest; const links = settlementPullRequestLinks(thread.pullRequests ?? []); @@ -223,7 +229,13 @@ export function resolveAutoSettlementAt(input: { toMillis(thread.latestRunCompletedAt), ]) ?? DateTime.toEpochMillis(thread.createdAt); if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { - return DateTime.makeUnsafe(activityAtMs); + return { + settledAt: DateTime.makeUnsafe(activityAtMs), + reason: { + type: "pull-request", + state: pullRequest.state === "merged" ? "merged" : "closed", + }, + }; } const hasPullRequest = hasOpenLink || @@ -238,7 +250,10 @@ export function resolveAutoSettlementAt(input: { const inactivityAtMs = latestMillis([activityAtMs, toMillis(thread.lastSnoozeWakeAt), timerWakeAtMs]) ?? activityAtMs; return inactivityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS - ? DateTime.makeUnsafe(inactivityAtMs) + ? { + settledAt: DateTime.makeUnsafe(inactivityAtMs), + reason: { type: "inactive", days: input.autoSettleAfterDays }, + } : null; } @@ -330,7 +345,7 @@ export const make = Effect.gen(function* () { thread.projectId, ).settings; const decisionNow = yield* DateTime.now; - const settledAt = resolveAutoSettlementAt({ + const settlement = resolveAutoSettlement({ thread, pullRequest, nowMs: DateTime.toEpochMillis(decisionNow), @@ -338,14 +353,15 @@ export const make = Effect.gen(function* () { autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests, }); - if (settledAt === null) return thread; + if (settlement === null) return thread; const uuid = yield* random.uuidV4(); yield* orchestrator.dispatch({ type: "thread.auto-settle", commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), threadId: thread.id, snapshotAt: thread.updatedAt, - settledAt, + settledAt: settlement.settledAt, + reason: settlement.reason, }); return null; }, @@ -367,7 +383,7 @@ export const make = Effect.gen(function* () { (thread) => { const projectSettings = resolveProjectSettings(settings, thread.projectId).settings; // Skip no-op decisions; actual settlement still reads fresh settings before dispatch. - const settledAt = resolveAutoSettlementAt({ + const settlement = resolveAutoSettlement({ thread, pullRequest: null, nowMs, @@ -375,7 +391,7 @@ export const make = Effect.gen(function* () { autoSettleOnMerge: projectSettings.sidebarAutoSettleOnMerge, autoSettleKeepPullRequests: projectSettings.sidebarAutoSettleKeepPullRequests, }); - return settledAt === null ? Effect.succeed(thread) : settleThread(thread, null); + return settlement === null ? Effect.succeed(thread) : settleThread(thread, null); }, { concurrency: 8 }, )) @@ -444,7 +460,7 @@ export const make = Effect.gen(function* () { return group.some((thread) => { const { settings } = resolveProjectSettings(currentSettings, thread.projectId); return ( - resolveAutoSettlementAt({ + resolveAutoSettlement({ thread, pullRequest, nowMs: DateTime.toEpochMillis(decisionNow), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b38fb737c91db..df4453db8c8f8 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2599,10 +2599,15 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { commandId: CommandId.make("runtime-layer-lifecycle-auto-settle"), threadId, snapshotAt: preAutoProjection.thread.updatedAt, + reason: { type: "pull-request", state: "merged" }, }); const autoSettledProjection = yield* orchestrator.getThreadProjection(threadId); assert.equal(autoSettledProjection.thread.settledOverride, "settled"); assert.isNull(autoSettledProjection.thread.activeOrderKey); + assert.deepEqual(shellOf(yield* orchestrator.getShellSnapshot()).settleReason, { + type: "pull-request", + state: "merged", + }); yield* orchestrator.dispatch({ type: "thread.unsettle", commandId: CommandId.make("runtime-layer-lifecycle-auto-unsettle"), @@ -2628,6 +2633,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const settledProjection = yield* orchestrator.getThreadProjection(threadId); assert.equal(settledProjection.thread.settledOverride, "settled"); assert.isNotNull(settledProjection.thread.settledAt); + assert.deepEqual(settledProjection.thread.settleReason, { type: "user" }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-lifecycle-resettle-by-agent"), + threadId, + reason: { type: "agent" }, + }); + assert.deepEqual((yield* orchestrator.getThreadProjection(threadId)).thread.settleReason, { + type: "user", + }); yield* orchestrator.dispatch({ type: "thread.unsettle", @@ -2643,6 +2658,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { (thread) => thread.id === threadId, ); assert.deepEqual(activeShell?.unsettledAt, activeProjection.thread.unsettledAt); + assert.isUndefined(activeShell?.settleReason); const archive = yield* orchestrator.dispatch({ type: "thread.archive", diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index b9f8a42d289c3..efff54faaea17 100644 --- a/apps/web/src/components/chat/useThreadLifecycle.tsx +++ b/apps/web/src/components/chat/useThreadLifecycle.tsx @@ -25,6 +25,7 @@ import { } from "@t3tools/contracts"; import { effectiveSnoozed, + settledThreadLabel, snoozeWakeLabel, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; @@ -618,10 +619,13 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { actionDisabled={!canOperateThread || isUnsnoozing} onAction={() => void handleUnsnoozeActiveThreadStable()} /> - ) : activeThreadSettled ? ( + ) : activeThreadSettled && activeThreadShell ? ( } - label={settledAt ? `Settled ${formatRelativeTimeLabel(settledAt)}` : "Settled"} + label={settledThreadLabel( + activeThreadShell, + settledAt ? formatRelativeTimeLabel(settledAt) : null, + )} clockMinute={nowMinute} actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"} actionDisabled={!canOperateThread || isUnsettling} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bed4ed73a40c5..fdd49d561b474 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -187,6 +187,8 @@ settlement unless you turn on **Keep threads with a PR** in **Settings → Threa 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. +At the end of a settled thread, the status line says why it settled (its pull request +merged or closed, it sat idle, or you or an agent settled it) and how its pull request ended. 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 diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 196b5ff39e171..80143aabba121 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -9,6 +9,7 @@ import type { OrchestrationV2RunStatus, OrchestrationV2ProviderFailureClass, OrchestrationV2ThreadProjection, + OrchestrationV2ThreadSettleReason, OrchestrationV2ThreadShell, PlanId, ProjectId, @@ -174,6 +175,7 @@ export interface EnvironmentThreadShell { readonly archivedAt: string | null; readonly settledOverride: "settled" | "active" | null; readonly settledAt: string | null; + readonly settleReason?: OrchestrationV2ThreadSettleReason | null; readonly unsettledAt: string | null; readonly snoozedUntil: string | null; readonly snoozedAt: string | null; @@ -325,6 +327,7 @@ export function presentThreadShell( archivedAt: nullableIso(thread.archivedAt), settledOverride: thread.settledOverride, settledAt: nullableIso(thread.settledAt), + settleReason: thread.settleReason ?? null, unsettledAt: nullableIso(thread.unsettledAt ?? null), snoozedUntil: nullableIso(thread.snoozedUntil ?? null), snoozedAt: nullableIso(thread.snoozedAt ?? null), diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 88a5759c5ed50..87c9ae270db5e 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -388,6 +388,10 @@ export function createThreadEnvironmentAtoms( pendingRuntimeRequest: null, settledOverride: "settled", settledAt: thread.settledOverride === "settled" ? (thread.settledAt ?? now) : now, + settleReason: + thread.settledOverride === "settled" + ? (thread.settleReason ?? null) + : { type: "user" }, unsettledAt: null, activeOrderKey: null, pinnedAt: null, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 6ad788e0a6623..5fe81ad79a278 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -2,6 +2,8 @@ import * as DateTime from "effect/DateTime"; import { DEFAULT_SNOOZE_PRESETS, type SnoozePresetSetting } from "@t3tools/contracts/settings"; +import { sourceControlClients } from "../sourceControlClients.ts"; +import { resolveThreadCurrentPullRequestLink } from "../threadPullRequests.ts"; import type { EnvironmentThreadShell, PendingSubagentRequest } from "./models.ts"; interface SettlementRunLike { @@ -334,6 +336,44 @@ export function snoozeWakeLabel(snoozedUntil: string, options: { readonly now: s return `${Math.ceil(remainingMs / DAY_MS)}d`; } +/** + * A settled thread's status line, e.g. "Settled 49m ago: PR #12 merged". The timeline above can + * end on the watch's last update before the merge, so this says what ended it. `when` is the + * relative settledAt, or null where the line shows none. An idle settle omits it: its settledAt is + * the last activity, days before the settle. + */ +export function settledThreadLabel( + thread: Pick, + when: string | null, +): string { + const reason = thread.settleReason ?? null; + const link = resolveThreadCurrentPullRequestLink(thread.pullRequests); + const term = + (link && sourceControlClients.findByChangeRequestUrl(link.url)?.changeRequest.shortLabel) ?? + "PR"; + const state = link?.snapshot?.state; + const ended = + link !== null && (state === "merged" || state === "closed") + ? { state, text: `${term} #${link.number} ${state}` } + : null; + const cause = + reason?.type === "pull-request" + ? ended?.state === reason.state + ? ended.text + : `${term} ${reason.state}` + : null; + const head = + reason?.type === "inactive" + ? `Settled after ${reason.days} idle ${reason.days === 1 ? "day" : "days"}` + : [ + "Settled", + reason?.type === "user" ? " by you" : reason?.type === "agent" ? " by an agent" : "", + when === null ? "" : ` ${when}`, + cause === null ? "" : `: ${cause}`, + ].join(""); + return ended === null || cause === ended.text ? head : `${head} · ${ended.text}`; +} + export type CustomSnoozeInput = | { readonly mode: "date"; readonly date: string; readonly time: string } | { diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index adf9227bf010c..7eebda0876928 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalDate:off -- Tests exercise local calendar snooze boundaries. import { ThreadId } from "@t3tools/contracts"; +import type { ThreadPullRequestLink } from "@t3tools/contracts"; import { TurnId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; @@ -10,6 +11,7 @@ import { hasQueuedTurnStart, pickSnoozePreset, resolveSnoozePresets, + settledThreadLabel, threadRaisedHandWhileSnoozed, threadWokeAt, isThreadWokeUnseen, @@ -390,3 +392,92 @@ it("dismisses a wake at its timestamp without hiding a later wake", () => { expect(isThreadWokeUnseen({ ...state, lastVisitedAt: firstWake })).toBe(false); expect(isThreadWokeUnseen({ ...state, lastVisitedAt: firstWake, wokeAt: NOW })).toBe(true); }); + +describe("settledThreadLabel", () => { + const link = ( + state: "open" | "merged" | "closed", + url = "https://github.com/owner/repo/pull/12", + ): ThreadPullRequestLink => ({ + host: new URL(url).host, + repository: "owner/repo", + number: Number(url.split("/").at(-1)), + url, + source: "agent", + linkedAt: SNOOZED_AT, + snapshot: { + state, + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: state === "merged" ? NOW : null, + closedAt: state === "open" ? null : NOW, + }, + stack: null, + }); + const mergedByReason = { type: "pull-request", state: "merged" } as const; + + it.each([ + [ + "an old settle without a reason, after the PR merged", + null, + [link("merged")], + "49m ago", + "Settled 49m ago · PR #12 merged", + ], + [ + "the merge it settled on", + mergedByReason, + [link("merged")], + "49m ago", + "Settled 49m ago: PR #12 merged", + ], + [ + "a merge found by branch, with no link", + mergedByReason, + [], + "49m ago", + "Settled 49m ago: PR merged", + ], + [ + "a GitLab merge request", + mergedByReason, + [link("merged", "https://gitlab.com/owner/repo/-/merge_requests/7")], + null, + "Settled: MR #7 merged", + ], + ["an old settle without a reason or a PR", null, [], "49m ago", "Settled 49m ago"], + [ + "inactivity", + { type: "inactive", days: 1 } as const, + [], + "2d ago", + "Settled after 1 idle day", + ], + [ + "inactivity after the PR closed", + { type: "inactive", days: 3 } as const, + [link("closed")], + "4d ago", + "Settled after 3 idle days · PR #12 closed", + ], + [ + "the user, with the PR still open", + { type: "user" } as const, + [link("open")], + "5m ago", + "Settled by you 5m ago", + ], + [ + "an agent, after the PR closed", + { type: "agent" } as const, + [link("closed")], + null, + "Settled by an agent · PR #12 closed", + ], + ])("names %s", (_name, settleReason, pullRequests, when, expected) => { + expect(settledThreadLabel({ settleReason, pullRequests }, when)).toBe(expected); + }); +}); diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 3782307171e87..6634928bb1af6 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -447,8 +447,8 @@ describe("orchestration V2 contracts", () => { }); }); -describe("thread open stats", () => { - it("reads a thread whose open stats this build cannot read, without them", () => { +describe("thread open stats and settle reason", () => { + it("reads a thread whose open stats or settle reason this build cannot read, without them", () => { const shell = { id: "thread-test", projectId: "project-test", @@ -484,6 +484,10 @@ describe("thread open stats", () => { const opens = { openedAt: 1, score: 1, picks: [{ query: "upl", at: 1 }] }; expect(decode({ ...shell, opens }).opens).toEqual(opens); expect(decode({ ...shell, opens: { openedAt: "2026-10-01", score: 1 } }).opens).toBeNull(); + expect(decode(shell).settleReason).toBeUndefined(); + const settleReason = { type: "inactive", days: 3 }; + expect(decode({ ...shell, settleReason }).settleReason).toEqual(settleReason); + expect(decode({ ...shell, settleReason: { type: "later-reason" } }).settleReason).toBeNull(); }); }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 785f41e3aa0fb..ac5752d56ac00 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -441,6 +441,22 @@ const LenientThreadOpens = Schema.NullOr(OrchestrationV2ThreadOpens).pipe( Schema.catchDecoding(() => Effect.succeedSome(null)), ); +export const OrchestrationV2ThreadSettleReason = Schema.Union([ + Schema.Struct({ type: Schema.Literals(["user", "agent"]) }), + Schema.Struct({ + type: Schema.Literal("pull-request"), + state: Schema.Literals(["merged", "closed"]), + }), + Schema.Struct({ type: Schema.Literal("inactive"), days: Schema.Number }), +]); +export type OrchestrationV2ThreadSettleReason = typeof OrchestrationV2ThreadSettleReason.Type; + +// Every settle writes it and unsettling leaves it on the thread; shells carry it only while settled. +// Absent on threads settled before reasons existed; a reason this build cannot read becomes null. +const LenientThreadSettleReason = Schema.NullOr(OrchestrationV2ThreadSettleReason).pipe( + Schema.catchDecoding(() => Effect.succeedSome(null)), +); + export const OrchestrationV2AppThread = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -480,6 +496,7 @@ export const OrchestrationV2AppThread = Schema.Struct({ settledAt: Schema.NullOr(Schema.DateTimeUtc).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + settleReason: Schema.optional(LenientThreadSettleReason), unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -2127,6 +2144,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ archivedAt: Schema.NullOr(Schema.DateTimeUtc), settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), settledAt: Schema.NullOr(Schema.DateTimeUtc), + settleReason: Schema.optional(LenientThreadSettleReason), unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -2964,6 +2982,8 @@ export const OrchestrationV2Command = Schema.Union([ commandId: CommandId, threadId: ThreadId, settledAt: Schema.optional(Schema.DateTimeUtc), + /** Defaults to user. */ + reason: Schema.optional(OrchestrationV2ThreadSettleReason), }), /** Rejects changes since snapshotAt or any explicit settled override to preserve user actions. */ Schema.Struct({ @@ -2972,6 +2992,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, snapshotAt: Schema.DateTimeUtc, settledAt: Schema.optional(Schema.DateTimeUtc), + reason: Schema.optional(OrchestrationV2ThreadSettleReason), }), Schema.Struct({ type: Schema.Literal("thread.unsettle"), From c0bb197a7f4652c8218e79c9dfa4bdee1b7d95bd Mon Sep 17 00:00:00 2001 From: Dominic R Date: Mon, 2 Nov 2026 21:59:41 -0500 Subject: [PATCH 2/4] server, web, mobile: name the pull request a settle was recorded for The status line matched the settle reason to the thread's current pull request link by state alone. With several merged links, the current link can be a different pull request from the one whose merge settled the thread, so the line credited the wrong number. The reason now carries the number of the pull request the settlement service decided on, and the line names it, adding the current link only when it is a different one. --- .../ThreadSettlementService.test.ts | 31 +++++++++++++------ .../ThreadSettlementService.ts | 5 +++ .../src/orchestration-v2/runtimeLayer.test.ts | 3 +- .../client-runtime/src/state/threadSettled.ts | 10 ++---- .../src/state/threadSnoozed.test.ts | 13 ++++++-- packages/contracts/src/orchestrationV2.ts | 1 + 6 files changed, 42 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 49a2e9219f0aa..80989abe8485e 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -202,9 +202,9 @@ describe("resolveAutoSettlement", () => { settledAt, reason: { type: "inactive", days }, }); - const pullRequestWas = (state: "merged" | "closed", settledAt: DateTime.Utc) => ({ + const pullRequestWas = (state: "merged" | "closed", settledAt: DateTime.Utc, number = 7) => ({ settledAt, - reason: { type: "pull-request", state }, + reason: { type: "pull-request", state, number }, }); it("keeps threads with a PR from inactivity settlement only when asked", () => { @@ -234,7 +234,7 @@ describe("resolveAutoSettlement", () => { expect( ThreadSettlementService.resolveAutoSettlement({ ...input, - pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { number: 7, state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, }), ).toEqual(pullRequestWas("merged", at(-4 * DAY_MS))); }); @@ -264,7 +264,7 @@ describe("resolveAutoSettlement", () => { expect( ThreadSettlementService.resolveAutoSettlement({ ...input, - pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { number: 7, state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, autoSettleAfterDays: null, }), ).toBeNull(); @@ -364,6 +364,7 @@ describe("resolveAutoSettlement", () => { const input = { thread, pullRequest: { + number: 7, state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), }, @@ -380,14 +381,18 @@ describe("resolveAutoSettlement", () => { expect( ThreadSettlementService.resolveAutoSettlement({ ...input, - pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)) }, + pullRequest: { + number: 7, + state: "merged", + mergedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)), + }, }), ).toBeNull(); expect( ThreadSettlementService.resolveAutoSettlement({ ...input, thread: shell(), - pullRequest: { state: "closed", closedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { number: 7, state: "closed", closedAt: DateTime.formatIso(at(-1)) }, autoSettleOnMerge: false, }), ).toEqual(pullRequestWas("closed", shell().createdAt)); @@ -402,7 +407,11 @@ describe("resolveAutoSettlement", () => { }); const input = { thread: woken, - pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) }, + pullRequest: { + number: 7, + state: "merged" as const, + mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), + }, nowMs: NOW_MS, autoSettleAfterDays: null, autoSettleOnMerge: true, @@ -459,7 +468,7 @@ describe("resolveAutoSettlement", () => { pullRequests: [recorded, link(2, at(-90 * 60 * 1_000), at(-40 * 60 * 1_000))], }, }), - ).toEqual(pullRequestWas("merged", at(-45 * 60 * 1_000))); + ).toEqual(pullRequestWas("merged", at(-45 * 60 * 1_000), 2)); }); }); @@ -834,7 +843,9 @@ describe("ThreadSettlementServiceV2 worker", () => { yield* startHarness(service, fixture.activation, fixture.snapshotReads); const commands = yield* Ref.get(fixture.commands); expect(commands.map((command) => command.threadId)).toEqual([thread.id]); - expect(commands[0]).toMatchObject({ reason: { type: "pull-request", state: "merged" } }); + expect(commands[0]).toMatchObject({ + reason: { type: "pull-request", state: "merged", number: 42 }, + }); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -1495,7 +1506,7 @@ describe("isSnoozed", () => { expect( ThreadSettlementService.resolveAutoSettlement({ thread, - pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { number: 7, state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, nowMs: NOW_MS, autoSettleAfterDays: null, autoSettleOnMerge: true, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 2f84e8470e2f9..4b6daf24b7ba7 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -37,6 +37,7 @@ import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; export interface SettlementPullRequest { + readonly number: number; readonly state: "open" | "closed" | "merged"; readonly closedAt?: string | null; readonly mergedAt?: string | null; @@ -215,6 +216,7 @@ export function resolveAutoSettlement(input: { latest.snapshot === null ? null : { + number: latest.number, state: latest.snapshot.state, mergedAt: latest.snapshot.mergedAt ?? null, closedAt: latest.snapshot.closedAt ?? null, @@ -234,6 +236,7 @@ export function resolveAutoSettlement(input: { reason: { type: "pull-request", state: pullRequest.state === "merged" ? "merged" : "closed", + number: pullRequest.number, }, }; } @@ -489,6 +492,7 @@ export const make = Effect.gen(function* () { } const summary = matchesMerge ? ({ + number: mergedPullRequest.number, state: "merged", closedAt: null, mergedAt: mergedPullRequest.mergedAt, @@ -502,6 +506,7 @@ export const make = Effect.gen(function* () { { recoverTransientFailure: false }, ); const terminal = { + number: reference.number, state: summary.state, closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index df4453db8c8f8..840defe63c4c7 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2599,7 +2599,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { commandId: CommandId.make("runtime-layer-lifecycle-auto-settle"), threadId, snapshotAt: preAutoProjection.thread.updatedAt, - reason: { type: "pull-request", state: "merged" }, + reason: { type: "pull-request", state: "merged", number: 128 }, }); const autoSettledProjection = yield* orchestrator.getThreadProjection(threadId); assert.equal(autoSettledProjection.thread.settledOverride, "settled"); @@ -2607,6 +2607,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.deepEqual(shellOf(yield* orchestrator.getShellSnapshot()).settleReason, { type: "pull-request", state: "merged", + number: 128, }); yield* orchestrator.dispatch({ type: "thread.unsettle", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 5fe81ad79a278..98eccf118acb9 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -354,14 +354,10 @@ export function settledThreadLabel( const state = link?.snapshot?.state; const ended = link !== null && (state === "merged" || state === "closed") - ? { state, text: `${term} #${link.number} ${state}` } + ? `${term} #${link.number} ${state}` : null; const cause = - reason?.type === "pull-request" - ? ended?.state === reason.state - ? ended.text - : `${term} ${reason.state}` - : null; + reason?.type === "pull-request" ? `${term} #${reason.number} ${reason.state}` : null; const head = reason?.type === "inactive" ? `Settled after ${reason.days} idle ${reason.days === 1 ? "day" : "days"}` @@ -371,7 +367,7 @@ export function settledThreadLabel( when === null ? "" : ` ${when}`, cause === null ? "" : `: ${cause}`, ].join(""); - return ended === null || cause === ended.text ? head : `${head} · ${ended.text}`; + return ended === null || cause === ended ? head : `${head} · ${ended}`; } export type CustomSnoozeInput = diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 7eebda0876928..d17dd0713e92a 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -417,7 +417,7 @@ describe("settledThreadLabel", () => { }, stack: null, }); - const mergedByReason = { type: "pull-request", state: "merged" } as const; + const mergedByReason = { type: "pull-request", state: "merged", number: 12 } as const; it.each([ [ @@ -439,11 +439,18 @@ describe("settledThreadLabel", () => { mergedByReason, [], "49m ago", - "Settled 49m ago: PR merged", + "Settled 49m ago: PR #12 merged", ], [ - "a GitLab merge request", + "the PR it settled on when a later merged PR is the current one", mergedByReason, + [link("merged", "https://github.com/owner/repo/pull/13"), link("merged")], + "49m ago", + "Settled 49m ago: PR #12 merged · PR #13 merged", + ], + [ + "a GitLab merge request", + { ...mergedByReason, number: 7 }, [link("merged", "https://gitlab.com/owner/repo/-/merge_requests/7")], null, "Settled: MR #7 merged", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ac5752d56ac00..84f58f35b5e09 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -446,6 +446,7 @@ export const OrchestrationV2ThreadSettleReason = Schema.Union([ Schema.Struct({ type: Schema.Literal("pull-request"), state: Schema.Literals(["merged", "closed"]), + number: PositiveInt, }), Schema.Struct({ type: Schema.Literal("inactive"), days: Schema.Number }), ]); From 0b4f3302543951ef0a400f04135babddcf36e12e Mon Sep 17 00:00:00 2001 From: Dominic R Date: Mon, 2 Nov 2026 23:26:08 -0500 Subject: [PATCH 3/4] server, web, mobile: name a settle's pull request by its host and repository A thread can link pull requests on more than one host or repository. The settle reason carried only a number, so the status line took the "PR"/"MR" term from the current link and could not tell two pull requests with the same number apart. The reason now also records the settling pull request's URL. The line takes the term from it, decides whether the cause and the current link are the same pull request by URL, and names both repositories when two different pull requests share a number. Reasons recorded without a URL fall back to the current link. --- .../ThreadSettlementService.test.ts | 59 +++++++++++++++---- .../ThreadSettlementService.ts | 5 ++ .../src/orchestration-v2/runtimeLayer.test.ts | 8 ++- .../client-runtime/src/state/threadSettled.ts | 30 ++++++++-- .../src/state/threadSnoozed.test.ts | 32 ++++++++-- packages/contracts/src/orchestrationV2.ts | 2 + 6 files changed, 116 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 80989abe8485e..d6bd64d6ca628 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -197,15 +197,19 @@ describe("isAutoSettlementCandidate", () => { }); }); +const PULL_REQUEST_7_URL = "https://example.test/owner/repo/pull/7"; + describe("resolveAutoSettlement", () => { const idleFor = (days: number, settledAt: DateTime.Utc) => ({ settledAt, reason: { type: "inactive", days }, }); - const pullRequestWas = (state: "merged" | "closed", settledAt: DateTime.Utc, number = 7) => ({ - settledAt, - reason: { type: "pull-request", state, number }, - }); + const pullRequestWas = ( + state: "merged" | "closed", + settledAt: DateTime.Utc, + number = 7, + url = PULL_REQUEST_7_URL, + ) => ({ settledAt, reason: { type: "pull-request", state, number, url } }); it("keeps threads with a PR from inactivity settlement only when asked", () => { const input = { @@ -234,7 +238,12 @@ describe("resolveAutoSettlement", () => { expect( ThreadSettlementService.resolveAutoSettlement({ ...input, - pullRequest: { number: 7, state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + state: "merged", + mergedAt: DateTime.formatIso(at(-1)), + }, }), ).toEqual(pullRequestWas("merged", at(-4 * DAY_MS))); }); @@ -264,7 +273,12 @@ describe("resolveAutoSettlement", () => { expect( ThreadSettlementService.resolveAutoSettlement({ ...input, - pullRequest: { number: 7, state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + state: "merged", + mergedAt: DateTime.formatIso(at(-1)), + }, autoSettleAfterDays: null, }), ).toBeNull(); @@ -365,6 +379,7 @@ describe("resolveAutoSettlement", () => { thread, pullRequest: { number: 7, + url: PULL_REQUEST_7_URL, state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), }, @@ -383,6 +398,7 @@ describe("resolveAutoSettlement", () => { ...input, pullRequest: { number: 7, + url: PULL_REQUEST_7_URL, state: "merged", mergedAt: DateTime.formatIso(at(-3 * 60 * 60 * 1_000)), }, @@ -392,7 +408,12 @@ describe("resolveAutoSettlement", () => { ThreadSettlementService.resolveAutoSettlement({ ...input, thread: shell(), - pullRequest: { number: 7, state: "closed", closedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + state: "closed", + closedAt: DateTime.formatIso(at(-1)), + }, autoSettleOnMerge: false, }), ).toEqual(pullRequestWas("closed", shell().createdAt)); @@ -409,6 +430,7 @@ describe("resolveAutoSettlement", () => { thread: woken, pullRequest: { number: 7, + url: PULL_REQUEST_7_URL, state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), }, @@ -468,7 +490,14 @@ describe("resolveAutoSettlement", () => { pullRequests: [recorded, link(2, at(-90 * 60 * 1_000), at(-40 * 60 * 1_000))], }, }), - ).toEqual(pullRequestWas("merged", at(-45 * 60 * 1_000), 2)); + ).toEqual( + pullRequestWas( + "merged", + at(-45 * 60 * 1_000), + 2, + "https://example.test/owner/repository/pull/2", + ), + ); }); }); @@ -844,7 +873,12 @@ describe("ThreadSettlementServiceV2 worker", () => { const commands = yield* Ref.get(fixture.commands); expect(commands.map((command) => command.threadId)).toEqual([thread.id]); expect(commands[0]).toMatchObject({ - reason: { type: "pull-request", state: "merged", number: 42 }, + reason: { + type: "pull-request", + state: "merged", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, }); }).pipe(Effect.provide(fixture.layer)); }), @@ -1506,7 +1540,12 @@ describe("isSnoozed", () => { expect( ThreadSettlementService.resolveAutoSettlement({ thread, - pullRequest: { number: 7, state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + state: "merged", + mergedAt: DateTime.formatIso(at(-1)), + }, nowMs: NOW_MS, autoSettleAfterDays: null, autoSettleOnMerge: true, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 4b6daf24b7ba7..6a8d81ebb30df 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -38,6 +38,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; export interface SettlementPullRequest { readonly number: number; + readonly url: string; readonly state: "open" | "closed" | "merged"; readonly closedAt?: string | null; readonly mergedAt?: string | null; @@ -217,6 +218,7 @@ export function resolveAutoSettlement(input: { ? null : { number: latest.number, + url: latest.url, state: latest.snapshot.state, mergedAt: latest.snapshot.mergedAt ?? null, closedAt: latest.snapshot.closedAt ?? null, @@ -237,6 +239,7 @@ export function resolveAutoSettlement(input: { type: "pull-request", state: pullRequest.state === "merged" ? "merged" : "closed", number: pullRequest.number, + ...(pullRequest.url.trim() === "" ? {} : { url: pullRequest.url }), }, }; } @@ -493,6 +496,7 @@ export const make = Effect.gen(function* () { const summary = matchesMerge ? ({ number: mergedPullRequest.number, + url: reference.url, state: "merged", closedAt: null, mergedAt: mergedPullRequest.mergedAt, @@ -507,6 +511,7 @@ export const make = Effect.gen(function* () { ); const terminal = { number: reference.number, + url: reference.url, state: summary.state, closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 840defe63c4c7..1de5fa24c73c7 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2599,7 +2599,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { commandId: CommandId.make("runtime-layer-lifecycle-auto-settle"), threadId, snapshotAt: preAutoProjection.thread.updatedAt, - reason: { type: "pull-request", state: "merged", number: 128 }, + reason: { + type: "pull-request", + state: "merged", + number: 128, + url: "https://github.com/acme/widgets/pull/128", + }, }); const autoSettledProjection = yield* orchestrator.getThreadProjection(threadId); assert.equal(autoSettledProjection.thread.settledOverride, "settled"); @@ -2608,6 +2613,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { type: "pull-request", state: "merged", number: 128, + url: "https://github.com/acme/widgets/pull/128", }); yield* orchestrator.dispatch({ type: "thread.unsettle", diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 98eccf118acb9..7b419cd4ea12f 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -348,16 +348,36 @@ export function settledThreadLabel( ): string { const reason = thread.settleReason ?? null; const link = resolveThreadCurrentPullRequestLink(thread.pullRequests); - const term = + const linkTerm = (link && sourceControlClients.findByChangeRequestUrl(link.url)?.changeRequest.shortLabel) ?? "PR"; const state = link?.snapshot?.state; const ended = link !== null && (state === "merged" || state === "closed") - ? `${term} #${link.number} ${state}` + ? { term: linkTerm, repository: link.repository, number: link.number, state } : null; + const settledBy = reason?.type === "pull-request" ? reason : null; + const settledByUrl = + settledBy?.url === undefined ? null : sourceControlClients.parseChangeRequestUrl(settledBy.url); const cause = - reason?.type === "pull-request" ? `${term} #${reason.number} ${reason.state}` : null; + settledBy === null + ? null + : { + term: settledByUrl?.definition.changeRequest.shortLabel ?? linkTerm, + repository: settledByUrl?.link.repository ?? null, + number: settledBy.number, + state: settledBy.state, + }; + const sameChangeRequest = + cause !== null && + ended !== null && + (settledBy?.url === undefined || link === null + ? cause.number === ended.number && cause.state === ended.state + : sourceControlClients.isSameChangeRequest(settledBy.url, link.url)); + // Two different pull requests with one number need their repositories to tell them apart. + const qualify = !sameChangeRequest && cause !== null && cause.number === ended?.number; + const describe = (pr: NonNullable) => + `${pr.term} ${qualify && pr.repository !== null ? pr.repository : ""}#${pr.number} ${pr.state}`; const head = reason?.type === "inactive" ? `Settled after ${reason.days} idle ${reason.days === 1 ? "day" : "days"}` @@ -365,9 +385,9 @@ export function settledThreadLabel( "Settled", reason?.type === "user" ? " by you" : reason?.type === "agent" ? " by an agent" : "", when === null ? "" : ` ${when}`, - cause === null ? "" : `: ${cause}`, + cause === null ? "" : `: ${describe(cause)}`, ].join(""); - return ended === null || cause === ended ? head : `${head} · ${ended}`; + return ended === null || sameChangeRequest ? head : `${head} · ${describe(ended)}`; } export type CustomSnoozeInput = diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index d17dd0713e92a..a3aa3cc5086f0 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -399,7 +399,7 @@ describe("settledThreadLabel", () => { url = "https://github.com/owner/repo/pull/12", ): ThreadPullRequestLink => ({ host: new URL(url).host, - repository: "owner/repo", + repository: new URL(url).pathname.split("/").slice(1, 3).join("/"), number: Number(url.split("/").at(-1)), url, source: "agent", @@ -417,7 +417,12 @@ describe("settledThreadLabel", () => { }, stack: null, }); - const mergedByReason = { type: "pull-request", state: "merged", number: 12 } as const; + const mergedByReason = { + type: "pull-request", + state: "merged", + number: 12, + url: "https://github.com/owner/repo/pull/12", + } as const; it.each([ [ @@ -449,8 +454,27 @@ describe("settledThreadLabel", () => { "Settled 49m ago: PR #12 merged · PR #13 merged", ], [ - "a GitLab merge request", - { ...mergedByReason, number: 7 }, + "a GitLab cause while the current link is on GitHub", + { + type: "pull-request", + state: "merged", + number: 7, + url: "https://gitlab.com/owner/repo/-/merge_requests/7", + } as const, + [link("merged")], + "49m ago", + "Settled 49m ago: MR #7 merged · PR #12 merged", + ], + [ + "two repositories that share a number", + { ...mergedByReason, url: "https://github.com/acme/api/pull/12" }, + [link("merged", "https://github.com/acme/web/pull/12")], + "49m ago", + "Settled 49m ago: PR acme/api#12 merged · PR acme/web#12 merged", + ], + [ + "a GitLab merge request, from a reason recorded without its URL", + { type: "pull-request", state: "merged", number: 7 } as const, [link("merged", "https://gitlab.com/owner/repo/-/merge_requests/7")], null, "Settled: MR #7 merged", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 84f58f35b5e09..4dc9f49b3417f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -447,6 +447,8 @@ export const OrchestrationV2ThreadSettleReason = Schema.Union([ type: Schema.Literal("pull-request"), state: Schema.Literals(["merged", "closed"]), number: PositiveInt, + /** Names the host and repository; reasons recorded without it fall back to the current link. */ + url: Schema.optional(TrimmedNonEmptyString), }), Schema.Struct({ type: Schema.Literal("inactive"), days: Schema.Number }), ]); From afd135d846e63df5e0bf298a01c1ca34684bc92c Mon Sep 17 00:00:00 2001 From: Dominic R Date: Mon, 2 Nov 2026 23:42:58 -0500 Subject: [PATCH 4/4] web, mobile: tell apart same-named pull requests on two hosts When the pull request that settled a thread and the thread's current link share both repository and number but live on different hosts, the status line printed them identically. It now prefixes each with its host in that case. --- .../client-runtime/src/state/threadSettled.ts | 24 +++++++++++++++---- .../src/state/threadSnoozed.test.ts | 7 ++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 7b419cd4ea12f..b97ac96d6c387 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -354,7 +354,13 @@ export function settledThreadLabel( const state = link?.snapshot?.state; const ended = link !== null && (state === "merged" || state === "closed") - ? { term: linkTerm, repository: link.repository, number: link.number, state } + ? { + term: linkTerm, + host: link.host, + repository: link.repository, + number: link.number, + state, + } : null; const settledBy = reason?.type === "pull-request" ? reason : null; const settledByUrl = @@ -364,6 +370,7 @@ export function settledThreadLabel( ? null : { term: settledByUrl?.definition.changeRequest.shortLabel ?? linkTerm, + host: settledByUrl?.link.host ?? null, repository: settledByUrl?.link.repository ?? null, number: settledBy.number, state: settledBy.state, @@ -374,10 +381,19 @@ export function settledThreadLabel( (settledBy?.url === undefined || link === null ? cause.number === ended.number && cause.state === ended.state : sourceControlClients.isSameChangeRequest(settledBy.url, link.url)); - // Two different pull requests with one number need their repositories to tell them apart. + // Two different pull requests with one number need their repositories, and their hosts when + // the repositories match too, to tell them apart. const qualify = !sameChangeRequest && cause !== null && cause.number === ended?.number; - const describe = (pr: NonNullable) => - `${pr.term} ${qualify && pr.repository !== null ? pr.repository : ""}#${pr.number} ${pr.state}`; + const withHost = qualify && cause?.repository === ended?.repository; + const describe = (pr: NonNullable) => { + const repository = + !qualify || pr.repository === null + ? "" + : withHost && pr.host !== null + ? `${pr.host}/${pr.repository}` + : pr.repository; + return `${pr.term} ${repository}#${pr.number} ${pr.state}`; + }; const head = reason?.type === "inactive" ? `Settled after ${reason.days} idle ${reason.days === 1 ? "day" : "days"}` diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index a3aa3cc5086f0..bbf0de8a54982 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -472,6 +472,13 @@ describe("settledThreadLabel", () => { "49m ago", "Settled 49m ago: PR acme/api#12 merged · PR acme/web#12 merged", ], + [ + "one repository and number on two hosts", + { ...mergedByReason, url: "https://github.com/acme/api/pull/12" }, + [link("merged", "https://git.example.com/acme/api/pull/12")], + "49m ago", + "Settled 49m ago: PR github.com/acme/api#12 merged · PR git.example.com/acme/api#12 merged", + ], [ "a GitLab merge request, from a reason recorded without its URL", { type: "pull-request", state: "merged", number: 7 } as const,