diff --git a/apps/server/src/internal/session-owner-side-effects.ts b/apps/server/src/internal/session-owner-side-effects.ts index 4dccf48374..e885e78f7d 100644 --- a/apps/server/src/internal/session-owner-side-effects.ts +++ b/apps/server/src/internal/session-owner-side-effects.ts @@ -231,6 +231,7 @@ function completeDaemonActiveWorkDisconnectGrace( interruptActiveThreadsForHost(deps, { hostId: args.hostId, reason: "host-daemon-restarted", + cause: "host-connection-lost", }); } diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts index 2e7028bc77..f446556292 100644 --- a/apps/server/src/services/threads/thread-lifecycle.ts +++ b/apps/server/src/services/threads/thread-lifecycle.ts @@ -244,11 +244,13 @@ interface InterruptActiveThreadArgs { } interface InterruptActiveThreadsArgs { + cause?: "host-connection-lost"; reason: SystemThreadInterruptedReason; threads: readonly InterruptActiveThreadArgs[]; } interface InterruptActiveThreadsForHostArgs { + cause?: "host-connection-lost"; hostId: string; reason: SystemThreadInterruptedReason; } @@ -312,13 +314,18 @@ interface SettleThreadPlanCancelCommandResultArgs { report: ThreadPlanCancelCommandResultReport; } +type RuntimeThreadInterruptionReason = + | SystemThreadInterruptedReason + | "host-connection-lost"; + function lifecycleEventForInterruptedThread( - reason: SystemThreadInterruptedReason, + reason: RuntimeThreadInterruptionReason, ): ThreadLifecycleEvent { switch (reason) { case "manual-stop": return { type: "stop.settled" }; case "host-daemon-restarted": + case "host-connection-lost": return { type: "run.failed" }; case "provider-turn-idle": return { type: "run.failed" }; @@ -328,13 +335,15 @@ function lifecycleEventForInterruptedThread( } function pendingInteractionStopReason( - reason: SystemThreadInterruptedReason, + reason: RuntimeThreadInterruptionReason, ): string { switch (reason) { case "manual-stop": return "Thread stopped by user request"; case "host-daemon-restarted": return "Host daemon restarted while awaiting user interaction"; + case "host-connection-lost": + return "Connection to host was lost while awaiting user interaction"; case "provider-turn-idle": return "Thread stopped after the provider stopped sending progress"; default: @@ -343,13 +352,15 @@ function pendingInteractionStopReason( } function threadCommandFailureMessageForInterruption( - reason: SystemThreadInterruptedReason, + reason: RuntimeThreadInterruptionReason, ): string | null { switch (reason) { case "manual-stop": return null; case "host-daemon-restarted": return "Thread interrupted because the host daemon disconnected"; + case "host-connection-lost": + return "Thread interrupted because the connection to the host was lost"; case "provider-turn-idle": return "Live runtime work failed because the provider stopped sending progress"; default: @@ -358,12 +369,13 @@ function threadCommandFailureMessageForInterruption( } function threadCommandFailureDetailForInterruption( - reason: SystemThreadInterruptedReason, + reason: RuntimeThreadInterruptionReason, ): string { switch (reason) { case "manual-stop": return "Thread stopped by user request"; case "host-daemon-restarted": + case "host-connection-lost": return "Please retry the thread to continue."; case "provider-turn-idle": return "Provider stopped sending progress while the thread was running"; @@ -1483,7 +1495,8 @@ function interruptActiveThreads( const results: InterruptedActiveThreadResult[] = []; const threadIds = args.threads.map((thread) => thread.threadId); - const lifecycleEvent = lifecycleEventForInterruptedThread(args.reason); + const effectiveReason = args.cause ?? args.reason; + const lifecycleEvent = lifecycleEventForInterruptedThread(effectiveReason); deps.db.transaction( (tx) => { @@ -1498,9 +1511,8 @@ function interruptActiveThreads( const state = stateByThreadId.get(thread.threadId); const activeTurnId = state?.activeTurnId ?? null; const providerThreadId = state?.latestProviderThreadId ?? null; - const failureMessage = threadCommandFailureMessageForInterruption( - args.reason, - ); + const failureMessage = + threadCommandFailureMessageForInterruption(effectiveReason); if (activeTurnId !== null) { eventArgs.push({ @@ -1527,7 +1539,8 @@ function interruptActiveThreads( data: buildSystemErrorEventData({ code: "thread_command_failed", message: failureMessage, - detail: threadCommandFailureDetailForInterruption(args.reason), + detail: + threadCommandFailureDetailForInterruption(effectiveReason), }), }); } @@ -1538,6 +1551,7 @@ function interruptActiveThreads( scope: threadScope(), data: { reason: args.reason, + ...(args.cause ? { cause: args.cause } : {}), }, }); results.push({ @@ -1560,7 +1574,7 @@ function interruptActiveThreads( deps.pendingInteractions.interruptPendingInteractionsForThreadIds({ threadIds: results.map((result) => result.threadId), - reason: pendingInteractionStopReason(args.reason), + reason: pendingInteractionStopReason(effectiveReason), }); for (const result of results) { @@ -1611,6 +1625,7 @@ export function interruptActiveThreadsForHost( return interruptActiveThreads(deps, { threads: activeThreads, reason: args.reason, + ...(args.cause ? { cause: args.cause } : {}), }); } diff --git a/apps/server/test/internal/background-task-reconciliation.test.ts b/apps/server/test/internal/background-task-reconciliation.test.ts index 77c900195c..5632866e8c 100644 --- a/apps/server/test/internal/background-task-reconciliation.test.ts +++ b/apps/server/test/internal/background-task-reconciliation.test.ts @@ -492,7 +492,7 @@ describe("active thread disconnect reconciliation triggers", () => { }); }); - it("interrupts active turns when a different daemon instance registers", async () => { + it("records a confirmed daemon restart when a different daemon instance registers", async () => { await withTestHarness(async (harness) => { const { host, session, thread } = seedActiveTurnThread(harness); @@ -542,7 +542,7 @@ describe("active thread disconnect reconciliation triggers", () => { }); }); - it("interrupts active turns after the live event window elapses without a reconnect", async () => { + it("records a lost host connection after the live event window elapses without a reconnect", async () => { await withTestHarness(async (harness) => { const { session, thread } = seedActiveTurnThread(harness); @@ -575,13 +575,17 @@ describe("active thread disconnect reconciliation triggers", () => { expect.objectContaining({ data: expect.objectContaining({ code: "thread_command_failed", - message: "Thread interrupted because the host daemon disconnected", + message: + "Thread interrupted because the connection to the host was lost", detail: "Please retry the thread to continue.", }), type: "system/error", }), expect.objectContaining({ - data: { reason: "host-daemon-restarted" }, + data: { + reason: "host-daemon-restarted", + cause: "host-connection-lost", + }, type: "system/thread/interrupted", }), ]); diff --git a/packages/domain/src/thread-events.ts b/packages/domain/src/thread-events.ts index e8551a0fc0..794ee9d345 100644 --- a/packages/domain/src/thread-events.ts +++ b/packages/domain/src/thread-events.ts @@ -222,6 +222,7 @@ export type SystemThreadInterruptedReason = z.infer< export const systemThreadInterruptedEventDataSchema = z.object({ reason: systemThreadInterruptedReasonSchema, + cause: z.literal("host-connection-lost").optional(), }); export const provisioningTranscriptEntrySchema = z.object({ diff --git a/packages/domain/test/stored-thread-event.test.ts b/packages/domain/test/stored-thread-event.test.ts index 4aa10696ec..dcad6267e5 100644 --- a/packages/domain/test/stored-thread-event.test.ts +++ b/packages/domain/test/stored-thread-event.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { parseStoredThreadEvent, parseThreadEventRow, } from "../src/stored-thread-event.js"; import { threadScope, turnScope } from "../src/thread-event-scope.js"; +import { systemThreadInterruptedEventDataSchema } from "../src/thread-events.js"; describe("parseStoredThreadEvent", () => { it("rejects assistant deltas without an itemId", () => { @@ -117,4 +119,24 @@ describe("parseStoredThreadEvent", () => { }); expect(event).not.toHaveProperty("turnId"); }); + + it("keeps host connection loss compatible with legacy interruption readers", () => { + const data = { + reason: "host-daemon-restarted", + cause: "host-connection-lost", + } as const; + + expect(systemThreadInterruptedEventDataSchema.parse(data)).toEqual(data); + + const legacySchema = z.object({ + reason: z.enum([ + "manual-stop", + "host-daemon-restarted", + "provider-turn-idle", + ]), + }); + expect(legacySchema.parse(data)).toEqual({ + reason: "host-daemon-restarted", + }); + }); }); diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json index 098cbc0ed2..7244f556c9 100644 --- a/packages/plugin-api-map/sdk-public-api.json +++ b/packages/plugin-api-map/sdk-public-api.json @@ -3,7 +3,7 @@ "entries": { ".": { "types": "bundled-types/bb-plugin-sdk.d.ts", - "sha256": "70077a510684588d29816ecb221ca99476640c7972c31730025064904265535e" + "sha256": "76c415f99e2336c4a2a17b4a7760e60004a38e279dcfa28025ae0c0fda763545" }, "./ai-services": { "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts", @@ -27,7 +27,7 @@ }, "./provider-bridge/testing": { "types": "bundled-types/bb-plugin-sdk-provider-bridge-testing.d.ts", - "sha256": "0eec301dc51c73ae3201abfe4bf6d1afe1fab7a79214d37311d20c861cc4533f" + "sha256": "511c1586fac2ec1ca648b1ddfc5882821750ff33d3c19dd7749ac2328d4d2dd1" }, "./testing": { "types": "bundled-types/bb-plugin-sdk-testing.d.ts", diff --git a/packages/thread-view/src/parse-operation-message.ts b/packages/thread-view/src/parse-operation-message.ts index 155bb75232..2a296f7adc 100644 --- a/packages/thread-view/src/parse-operation-message.ts +++ b/packages/thread-view/src/parse-operation-message.ts @@ -109,7 +109,13 @@ function createThreadOperationMetadata( }; } -function threadInterruptedTitle(reason: SystemThreadInterruptedReason): string { +function threadInterruptedTitle( + reason: SystemThreadInterruptedReason, + cause?: "host-connection-lost", +): string { + if (cause === "host-connection-lost") { + return "Stopped — connection to host was lost"; + } switch (reason) { case "manual-stop": return "Stopped manually"; @@ -490,7 +496,7 @@ export function parseOperationMessage( if (decoded.type === "system/thread/interrupted") { return op(decoded, meta, "thread-interrupted", { opType: "thread-interrupted", - title: threadInterruptedTitle(decoded.reason), + title: threadInterruptedTitle(decoded.reason, decoded.cause), status: "interrupted", }); } diff --git a/packages/thread-view/test/parse-operation-message.test.ts b/packages/thread-view/test/parse-operation-message.test.ts index 1d46aab71c..635d9d0b2d 100644 --- a/packages/thread-view/test/parse-operation-message.test.ts +++ b/packages/thread-view/test/parse-operation-message.test.ts @@ -41,8 +41,9 @@ function provisioningTitle( function interruptedTitle( reason: SystemThreadInterruptedReason, threadName: string, + cause?: "host-connection-lost", ): string { - const row = factory().systemThreadInterrupted({ reason }); + const row = factory().systemThreadInterrupted({ reason, cause }); return operationTitleFor(row, threadName); } @@ -137,6 +138,13 @@ describe("parseOperationMessage operation titles", () => { expect(interruptedTitle("host-daemon-restarted", THREAD_NAME)).toBe( "Stopped — host daemon restarted", ); + expect( + interruptedTitle( + "host-daemon-restarted", + THREAD_NAME, + "host-connection-lost", + ), + ).toBe("Stopped — connection to host was lost"); }); }); diff --git a/packages/thread-view/test/timeline-test-harness.ts b/packages/thread-view/test/timeline-test-harness.ts index 19d03e1116..d0968a923c 100644 --- a/packages/thread-view/test/timeline-test-harness.ts +++ b/packages/thread-view/test/timeline-test-harness.ts @@ -283,6 +283,7 @@ interface SystemOperationArgs extends EventFactoryRowOptions { } interface SystemThreadInterruptedArgs extends EventFactoryRowOptions { + cause?: "host-connection-lost"; reason?: SystemThreadInterruptedReason; } @@ -924,6 +925,7 @@ export function createTimelineEventFactory( type: "system/thread/interrupted", data: { reason: args.reason ?? "manual-stop", + ...(args.cause ? { cause: args.cause } : {}), }, }; },