diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 27cce9c5795ba..f67ed618cb01f 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"; @@ -843,7 +844,7 @@ function ThreadDetailBody( lifecycleSnoozed && snoozedUntil !== null ? `Snoozed, wakes in ${snoozeWakeLabel(snoozedUntil, { now: lifecycleNow })}` : settled - ? "Settled" + ? settledThreadLabel(props.selectedThread, null) : "Woke from snooze"; const actionLabel = lifecycleSnoozed ? "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 35598d74bc36f..cd0b0089036d5 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 bcec0a13cf20a..9feac68aa4f1f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3704,6 +3704,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, @@ -13399,6 +13402,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 825bc5d7ce53c..c50a651714f33 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -24,7 +24,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import { isAutoSettlementCandidate, isSnoozed, - resolveAutoSettlementAt, + resolveAutoSettlement, } from "./ThreadSettlementService.ts"; const layerSql = ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)); @@ -344,8 +344,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 dea68ee7723d1..d72c057c716c9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1533,6 +1533,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 { @@ -1687,6 +1696,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, @@ -1953,6 +1963,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 742616f27bd3e..d0000d6ada8b6 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -588,7 +588,7 @@ it.effect.each([ status: "running" as OrchestrationV2Run["status"], userMessageId: MessageId.make("follow-up"), }; - const dispatched: Array = []; + const dispatched: Array = []; // The notification starts after the completed run was observed, before settlement commits. let raced = false; const layerTest = ThreadManagementService.layer.pipe( @@ -661,7 +661,7 @@ it.effect.each([ detail: "Thread is still running", }); } - dispatched.push(command.type); + dispatched.push(command); return { sequence: 1, storedEvents: [] } as never; }), }), @@ -699,7 +699,11 @@ it.effect.each([ } as unknown as OrchestrationV2StoredEvent); } yield* Fiber.join(fiber); - 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 46ea454310554..4b235ad1f29a1 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1089,6 +1089,7 @@ const make = Effect.gen(function* () { commandId: CommandId.make(`server:settle-after-run:${input.runId}:${sequence}`), threadId: input.threadId, expectedLatestRunId: latest.id, + reason: { type: "agent" }, }).pipe( Effect.as(true), // A notification can start between the projection read and the guarded dispatch. @@ -1130,6 +1131,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 f3c83eca70646..bc35e306edb5c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -200,7 +200,20 @@ describe("isAutoSettlementCandidate", () => { }); }); -describe("resolveAutoSettlementAt", () => { +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, + 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 = { thread: shell({ @@ -218,19 +231,24 @@ 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)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + 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", () => { @@ -245,18 +263,25 @@ 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)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + state: "merged", + mergedAt: DateTime.formatIso(at(-1)), + }, autoSettleAfterDays: null, }), ).toBeNull(); @@ -264,7 +289,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))], @@ -292,12 +317,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(); }); @@ -309,15 +336,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) }, }), @@ -338,10 +367,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", () => { @@ -352,6 +381,8 @@ describe("resolveAutoSettlementAt", () => { const input = { thread, pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), }, @@ -359,24 +390,36 @@ 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)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + 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)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + 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", () => { @@ -388,14 +431,21 @@ describe("resolveAutoSettlementAt", () => { }); const input = { thread: woken, - pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) }, + pullRequest: { + number: 7, + url: PULL_REQUEST_7_URL, + state: "merged" as const, + mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)), + }, nowMs: NOW_MS, 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) }, }), @@ -434,16 +484,23 @@ 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), + 2, + "https://example.test/owner/repository/pull/2", + ), + ); }); }); @@ -828,9 +885,16 @@ 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", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }); }).pipe(Effect.provide(fixture.layer)); }), ), @@ -854,9 +918,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)); }), ), @@ -1631,9 +1695,14 @@ 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)) }, + 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 8c852426da323..f31e8b3872b49 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"; @@ -38,6 +39,8 @@ import * as Orchestrator from "./Orchestrator.ts"; 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; @@ -193,14 +196,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 ?? []); @@ -218,6 +226,8 @@ export function resolveAutoSettlementAt(input: { latest.snapshot === null ? null : { + number: latest.number, + url: latest.url, state: latest.snapshot.state, mergedAt: latest.snapshot.mergedAt ?? null, closedAt: latest.snapshot.closedAt ?? null, @@ -232,7 +242,15 @@ 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", + number: pullRequest.number, + ...(pullRequest.url.trim() === "" ? {} : { url: pullRequest.url }), + }, + }; } const hasPullRequest = hasOpenLink || @@ -247,7 +265,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; } @@ -340,7 +361,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), @@ -348,14 +369,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; }, @@ -377,7 +399,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, @@ -385,7 +407,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 }, )) @@ -456,7 +478,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), @@ -485,6 +507,8 @@ export const make = Effect.gen(function* () { } const summary = matchesMerge ? ({ + number: mergedPullRequest.number, + url: reference.url, state: "merged", closedAt: null, mergedAt: mergedPullRequest.mergedAt, @@ -498,6 +522,8 @@ export const make = Effect.gen(function* () { { recoverTransientFailure: false }, ); 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 b90f491dfa94e..b1c7c917b5e3e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2662,10 +2662,22 @@ 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, + url: "https://github.com/acme/widgets/pull/128", + }, }); 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", + number: 128, + url: "https://github.com/acme/widgets/pull/128", + }); yield* orchestrator.dispatch({ type: "thread.unsettle", commandId: CommandId.make("runtime-layer-lifecycle-auto-unsettle"), @@ -2691,6 +2703,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", @@ -2706,6 +2728,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { (thread) => thread.id === threadId, ); assert.deepEqual(activeShell?.unsettledAt, activeProjection.thread.unsettledAt); + assert.isUndefined(activeShell?.settleReason); yield* orchestrator.dispatch({ type: "thread.settle", diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx index 50ed46ae671f9..4c5b9fc52038f 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"; @@ -623,10 +624,13 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) { 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 25095b70f1c3d..f8e47a1d85c7d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -194,6 +194,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 098b73141e3c9..e553856449a1d 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, @@ -175,6 +176,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; @@ -330,6 +332,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 7a8d20830adec..36aeb79aaa4c7 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 c247f9d80489f..8ea9de5cd2607 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 { @@ -359,6 +361,76 @@ 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 linkTerm = + (link && sourceControlClients.findByChangeRequestUrl(link.url)?.changeRequest.shortLabel) ?? + "PR"; + const state = link?.snapshot?.state; + const ended = + link !== null && (state === "merged" || state === "closed") + ? { + term: linkTerm, + host: link.host, + 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 = + settledBy === null + ? null + : { + term: settledByUrl?.definition.changeRequest.shortLabel ?? linkTerm, + host: settledByUrl?.link.host ?? null, + 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, and their hosts when + // the repositories match too, to tell them apart. + const qualify = !sameChangeRequest && cause !== null && cause.number === ended?.number; + 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"}` + : [ + "Settled", + reason?.type === "user" ? " by you" : reason?.type === "agent" ? " by an agent" : "", + when === null ? "" : ` ${when}`, + cause === null ? "" : `: ${describe(cause)}`, + ].join(""); + return ended === null || sameChangeRequest ? head : `${head} · ${describe(ended)}`; +} + 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 5da3be096509a..9b47bb776925d 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -2,6 +2,7 @@ import * as DateTime from "effect/DateTime"; import { RuntimeRequestId } from "@t3tools/contracts"; 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"; @@ -12,6 +13,7 @@ import { hasQueuedTurnStart, pickSnoozePreset, resolveSnoozePresets, + settledThreadLabel, threadRaisedHandWhileSnoozed, threadWokeAt, isThreadWokeUnseen, @@ -503,3 +505,130 @@ 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: new URL(url).pathname.split("/").slice(1, 3).join("/"), + 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", + number: 12, + url: "https://github.com/owner/repo/pull/12", + } 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 #12 merged", + ], + [ + "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 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", + ], + [ + "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, + [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 461ec5e4b3f3e..e63709e1c4c5d 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -441,6 +441,25 @@ 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"]), + 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 }), +]); +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 +499,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)), @@ -2138,6 +2158,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)), @@ -2977,6 +2998,8 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, expectedLatestRunId: Schema.optional(RunId), 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({ @@ -2985,6 +3008,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"),