From 1bedc7b89a0ffc443e4e519147146914558f85b6 Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Tue, 3 Nov 2026 09:16:24 -0500 Subject: [PATCH 1/8] fix(server): a compaction cut by a restart runs again and sends what was queued behind it Restart recovery held the queue behind a cut /compact and recorded a continuation, but the continuation handler skipped maintenance commands, so nothing ever started. A cut /compact now runs again as its continuation, and when it completes it releases the queue it held. Fixes #30206 Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 21 ++++++--- .../RestartContinuation.test.ts | 46 ++++++++++++++++--- .../orchestration-v2/RestartContinuation.ts | 46 ++++++++++++++----- .../src/orchestration-v2/runtimeLayer.test.ts | 22 +++++++++ docs/internals/server-updates.md | 5 +- 5 files changed, 114 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 600764704e57f..d3a2840c1652e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2356,17 +2356,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A hold waits for a person to review what they queued. Nobody reviews messages from agents, // the server or scheduled tasks, so a later completed run releases those unless it was stopped. + // A restart that cut a compaction held what was queued behind it; redoing it releases them all. const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* ( threadId: ThreadId, - completedRunId: RunId, + completedRun: OrchestrationV2Run, ) { + const completedRunId = completedRun.id; const held = yield* projectionStore.getHeldQueuedRuns(threadId); if (held.length === 0) return; const projection = yield* projectionStore.getThreadRecords( threadId, ["runs", "messages", "turnItems"], { - messageIds: held.map((run) => run.userMessageId), + messageIds: [...held.map((run) => run.userMessageId), completedRun.userMessageId], turnItemTypes: ["run_interrupt_request"], turnItemRunIds: [completedRunId], }, @@ -2385,8 +2387,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined) .map((message) => message.id), ); + const resumedCompaction = + completedRun.restartContinuationOfRunId !== undefined && + messages.some( + (message) => + message.id === completedRun.userMessageId && isCompactionCommandMessage(message), + ); const now = yield* DateTime.now; - const released = held.filter((run) => unattended.has(run.userMessageId)); + const released = resumedCompaction + ? held + : held.filter((run) => unattended.has(run.userMessageId)); if (released.length === 0) return; yield* writeSystemEvents( released.map((run) => ({ @@ -13763,10 +13773,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* threadDispatch .withLock( threadId, - (run.status === "completed" - ? releaseUnattendedHolds(threadId, run.id) - : Effect.void - ).pipe( + (run.status === "completed" ? releaseUnattendedHolds(threadId, run) : Effect.void).pipe( Effect.andThen( startNextQueuedRun(threadId, { endedRunId: run.id, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index a318130500b3b..d2fb230e94bbe 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1075,15 +1075,47 @@ it.effect("does not continue a run the user asked to stop before the restart", ( }), ); -it.effect("does not continue a cut /compact or /logout turn", () => +it.effect("compacts again after a cut /compact turn, and does not continue a cut /logout", () => Effect.gen(function* () { - for (const text of ["/compact", " /LOGOUT "]) { - const texts = yield* continuationTexts({ + const message = (text: string) => ({ + id: MessageId.make("message:user"), + role: "user", + createdBy: "user", + text, + attachments: [], + idleCompaction: "onReturn", + }); + const dispatches = yield* continuationDispatches({ + ...cutMidTurn(), + messages: [message(" /Compact ")], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual( + dispatches.map( + ({ text, idleCompaction, createdBy, notification, restartContinuationOfRunId }) => ({ + text, + idleCompaction, + createdBy, + notification, + restartContinuationOfRunId, + }), + ), + [ + { + text: "/compact", + idleCompaction: "onReturn", + createdBy: "user", + notification: undefined, + restartContinuationOfRunId: runId, + }, + ], + ); + assert.deepEqual( + yield* continuationTexts({ ...cutMidTurn(), - messages: [{ id: MessageId.make("message:user"), text, attachments: [] }], - } as unknown as OrchestrationV2ThreadProjection); - assert.deepEqual(texts, [], text); - } + messages: [message(" /LOGOUT ")], + } as unknown as OrchestrationV2ThreadProjection), + [], + ); const texts = yield* continuationTexts({ ...cutMidTurn(), messages: [{ id: MessageId.make("message:user"), text: "/compact later", attachments: [] }], diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 20d110d335aa2..955ab3de9b264 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -2,6 +2,8 @@ import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, + IDLE_COMPACTION_TEXT, + isCompactionCommandMessage, isOrchestrationV2WorkActive, latestProviderTurnForAttempt, MessageId, @@ -290,7 +292,19 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta const sourceMessage = sourceRecords.messages.find( (message) => message.id === source.userMessageId, ); - if (sourceMessage !== undefined && isNativeMaintenanceCommand(sourceMessage)) return; + // Compaction changes nothing a repeat could double, so a cut one runs again. + const recompactMessage = + sourceMessage !== undefined && + isCompactionCommandMessage(sourceMessage) && + !isRestartNoteSource(source, projection.providerTurns) + ? sourceMessage + : undefined; + if ( + recompactMessage === undefined && + sourceMessage !== undefined && + isNativeMaintenanceCommand(sourceMessage) + ) + return; const note = restartContinuationNote( source, projection.runs, @@ -311,19 +325,29 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text, - notification: { - source: { kind: "system" }, - outcome: "updated", - summary: note.settled - ? "T3 Code restarted and stopped background work" - : "T3 Code restarted and resumed this turn", - detail: text, - }, + ...(recompactMessage === undefined + ? { + text, + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, + } + : { + text: IDLE_COMPACTION_TEXT, + ...(recompactMessage.idleCompaction === undefined + ? {} + : { idleCompaction: recompactMessage.idleCompaction }), + }), attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, - createdBy: "agent", + // A repeated /compact keeps its author, so a person's still reads as theirs, not idle. + createdBy: recompactMessage?.createdBy ?? "agent", creationSource: "server", restartContinuationOfRunId: input.sourceRunId, }) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b38fb737c91db..81e96ebf59551 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5534,6 +5534,28 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("a compaction resumed after a restart releases the user's held queue", () => + Effect.gen(function* () { + const threadId = ThreadId.make("runtime-layer-held-queue-resumed-compaction"); + const { send, runFor, end, startedRunIds, releasedAfter, awaitHeld } = + yield* queueTestThread(threadId); + yield* send("cut", "agent", true); + yield* send("user", "user", false); + yield* end("cut", "failed"); + yield* awaitHeld("user"); + const cut = yield* runFor("cut"); + yield* send(" /compact", "agent", true); + yield* end(" /compact", "completed"); + yield* send("/compact", "agent", true); + assert.equal((yield* runFor("/compact")).status, "starting"); + // A compaction that is not a continuation leaves the hold for the user. + assert.isTrue((yield* runFor("user")).queueHeld); + yield* end("/compact", "completed", "", { restartContinuationOfRunId: cut.id }); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("user")).id); + yield* releasedAfter("user", "/compact"); + }), + ); + it.effect("keeps held agent messages while the thread's worktree setup is unresolved", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 1536884578d4b..e23da78087a70 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -87,7 +87,10 @@ cannot be overwritten by a stale cancellation. The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts) rechecks the preference, provider selection, newer user work, a stop the user requested, -and maintenance turns such as `/compact` before dispatching. Intent and delivery both +and maintenance turns before dispatching. A cut `/logout` is not repeated. A cut `/compact` +runs again instead of receiving the continuation prompt, and its completion releases the +queue held behind it, since that queue was waiting on the compaction rather than on a person. +Intent and delivery both refuse a thread that is archived, settled, or still snoozed, a cut turn whose provider turn already ended, and a run waiting on the user: an approval, question, or secret that was pending, or that recovery closed on the cut turn. Message-capable questions stay answerable, so From 0c517b23809838cbec212c81e94319f10743207d Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Tue, 3 Nov 2026 09:32:12 -0500 Subject: [PATCH 2/8] fix(server): a resumed compaction releases only the holds its restart added A user message held by an earlier Stop or failure can sit behind an idle compaction. Releasing every hold when the resumed compaction finished would send it without the user resuming it. Recovery now records on the cut run which queued runs it held, and the resumed compaction releases only those, across a chain of restarts. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 25 +++++++++++----- .../orchestration-v2/ProjectionStore.test.ts | 11 +++++++ .../src/orchestration-v2/ProjectionStore.ts | 10 +++++-- .../ProviderRuntimeRecoveryService.test.ts | 11 +++++++ .../ProviderRuntimeRecoveryService.ts | 13 ++++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 29 ++++++++++++------- docs/internals/server-updates.md | 5 ++-- packages/contracts/src/orchestrationV2.ts | 2 ++ 8 files changed, 83 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index d3a2840c1652e..6da09a1d1ed8d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2356,7 +2356,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A hold waits for a person to review what they queued. Nobody reviews messages from agents, // the server or scheduled tasks, so a later completed run releases those unless it was stopped. - // A restart that cut a compaction held what was queued behind it; redoing it releases them all. + // A restart that cut a compaction held what was queued behind it; redoing it releases those. const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* ( threadId: ThreadId, completedRun: OrchestrationV2Run, @@ -2387,16 +2387,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined) .map((message) => message.id), ); - const resumedCompaction = - completedRun.restartContinuationOfRunId !== undefined && + const restartHeld = new Set(); + if ( messages.some( (message) => message.id === completedRun.userMessageId && isCompactionCommandMessage(message), - ); + ) + ) { + // Each restart in a continuation chain records only the holds it added. + const visited = new Set(); + let sourceId = completedRun.restartContinuationOfRunId; + while (sourceId !== undefined && !visited.has(sourceId)) { + visited.add(sourceId); + const source = projection.runs.find((run) => run.id === sourceId); + for (const id of source?.restartHeldRunIds ?? []) restartHeld.add(id); + sourceId = source?.restartContinuationOfRunId; + } + } const now = yield* DateTime.now; - const released = resumedCompaction - ? held - : held.filter((run) => unattended.has(run.userMessageId)); + const released = held.filter( + (run) => unattended.has(run.userMessageId) || restartHeld.has(run.id), + ); if (released.length === 0) return; yield* writeSystemEvents( released.map((run) => ({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 5b24a65a99567..cdd2daa6b7d2e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1022,6 +1022,16 @@ const restartCancelledWorkSurvivesStaleRunUpdate = Effect.gen(function* () { occurredAt: now, payload: { runId: run.id, restartCancelledBackgroundWork: work }, }); + const restartHeldRunIds = [RunId.make("run:restart-cancelled-work-stale-update:held")]; + yield* store.apply({ + id: EventId.make("event:restart-cancelled-work-stale-update:cancelled"), + type: "run.updated", + threadId, + runId: run.id, + providerInstanceId, + occurredAt: now, + payload: { ...run, status: "cancelled", completedAt: now, restartHeldRunIds }, + }); yield* store.apply({ id: EventId.make("event:restart-cancelled-work-stale-update:completed"), type: "run.updated", @@ -1034,6 +1044,7 @@ const restartCancelledWorkSurvivesStaleRunUpdate = Effect.gen(function* () { const updated = (yield* store.getThreadProjection(threadId)).runs[0]; assert.equal(updated?.status, "completed"); assert.deepEqual(updated?.restartCancelledBackgroundWork, work); + assert.deepEqual(updated?.restartHeldRunIds, restartHeldRunIds); }); const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 3cffce15b70d6..f56577cf11d38 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -734,6 +734,9 @@ function preserveRunRecordedFields( current.restartCancelledBackgroundWork !== undefined ? { restartCancelledBackgroundWork: current.restartCancelledBackgroundWork } : {}), + ...(next.restartHeldRunIds === undefined && current.restartHeldRunIds !== undefined + ? { restartHeldRunIds: current.restartHeldRunIds } + : {}), }; } @@ -2034,8 +2037,11 @@ export const layer: Layer.Layer = requested_at = excluded.requested_at, completed_at = excluded.completed_at, payload_json = ${keepRecordedRunField( - keepRecordedRunField(sql`excluded.payload_json`, "$.delegatedCompletion"), - "$.restartCancelledBackgroundWork", + keepRecordedRunField( + keepRecordedRunField(sql`excluded.payload_json`, "$.delegatedCompletion"), + "$.restartCancelledBackgroundWork", + ), + "$.restartHeldRunIds", )} `; break; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 2fd9b13a35c33..5ed2f7e0422be 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -655,6 +655,8 @@ it.effect( providerThreadId, providerInstanceId: ProviderInstanceId.make("codex"), }, + { id: RunId.make("run_recovery_queued"), status: "queued", ordinal: 2 }, + { id: RunId.make("run_recovery_held"), status: "queued", ordinal: 3, queueHeld: true }, ], attempts: [ { @@ -683,6 +685,7 @@ it.effect( Layer.mock(ProjectionStore.ProjectionStoreV2)({ getRecoveryThreadIds: () => Effect.succeed([threadId]), getRuntimeRecoveryProjection: () => Effect.succeed(projection), + getThreadRecords: () => Effect.succeed(projection), }), Layer.mock(EventSink.EventSinkV2)({ commitCommand: (input) => { @@ -721,6 +724,7 @@ it.effect( "status" in event.payload ? event.payload.status : null, ]), [ + ["run.updated", "queued"], ["run.updated", "cancelled"], ["run-attempt.updated", "cancelled"], ["node.updated", "cancelled"], @@ -731,6 +735,13 @@ it.effect( ["provider-session.updated", "stopped"], ], ); + // Only the hold this restart added is recorded on the run it cut. + const cutRun = events.find( + (event) => event.type === "run.updated" && event.payload.status === "cancelled", + ); + assert.deepEqual(cutRun?.type === "run.updated" ? cutRun.payload.restartHeldRunIds : null, [ + RunId.make("run_recovery_queued"), + ]); const messageEvent = events.find((event) => event.type === "message.updated"); assert.isFalse(messageEvent?.type === "message.updated" && messageEvent.payload.streaming); }).pipe(Effect.provide(layer)); diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index c344357972268..aa97f8e8dd66f 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -5,6 +5,7 @@ import { isProviderNativeSubagentThread, type OrchestrationV2DomainEvent, type ProviderThreadId, + type RunId, type OrchestrationV2RestartCancelledBackgroundWork, type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, @@ -340,12 +341,14 @@ export const make = Effect.gen(function* () { const bufferedMessageIds = new Set( queuedMessages.filter(isBufferedProviderNotification).map((message) => message.id), ); + const restartHeldRunIds: Array = []; for (const run of queuedRuns) { if (bufferedMessageIds.has(run.userMessageId)) { runs.push(run); continue; } if (run.queueHeld === true) continue; + restartHeldRunIds.push(run.id); events.push({ id: yield* allocateEventId(), type: "run.updated", @@ -392,7 +395,15 @@ export const make = Effect.gen(function* () { runId: run.id, providerInstanceId: run.providerInstanceId, occurredAt: now, - payload: { ...run, status: "cancelled", queuePosition: null, completedAt: now }, + payload: { + ...run, + status: "cancelled", + queuePosition: null, + completedAt: now, + ...(run.status === "queued" || restartHeldRunIds.length === 0 + ? {} + : { restartHeldRunIds }), + }, }); for (const attempt of projection.attempts.filter( (candidate) => diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 81e96ebf59551..976c578f8db12 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5384,7 +5384,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); const end = ( key: string, - status: "completed" | "failed", + status: "completed" | "failed" | "cancelled", revision = "", patch: Partial = {}, ) => @@ -5534,24 +5534,31 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); - it.effect("a compaction resumed after a restart releases the user's held queue", () => + it.effect("a compaction resumed after a restart releases only what that restart held", () => Effect.gen(function* () { const threadId = ThreadId.make("runtime-layer-held-queue-resumed-compaction"); const { send, runFor, end, startedRunIds, releasedAfter, awaitHeld } = yield* queueTestThread(threadId); - yield* send("cut", "agent", true); + yield* send("failed", "agent", true); yield* send("user", "user", false); - yield* end("cut", "failed"); + yield* end("failed", "failed"); yield* awaitHeld("user"); - const cut = yield* runFor("cut"); + const user = yield* runFor("user"); + // An idle compaction ran past a failure's hold; the restart that cut it added no hold. + yield* send("idle", "agent", true); + yield* end("idle", "cancelled"); yield* send(" /compact", "agent", true); - yield* end(" /compact", "completed"); - yield* send("/compact", "agent", true); - assert.equal((yield* runFor("/compact")).status, "starting"); - // A compaction that is not a continuation leaves the hold for the user. + yield* end(" /compact", "completed", "", { + restartContinuationOfRunId: (yield* runFor("idle")).id, + }); assert.isTrue((yield* runFor("user")).queueHeld); - yield* end("/compact", "completed", "", { restartContinuationOfRunId: cut.id }); - assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("user")).id); + yield* send("cut", "agent", true); + yield* end("cut", "cancelled", "", { restartHeldRunIds: [user.id] }); + yield* send("/compact", "agent", true); + yield* end("/compact", "completed", "", { + restartContinuationOfRunId: (yield* runFor("cut")).id, + }); + assert.equal(yield* Queue.take(startedRunIds), user.id); yield* releasedAfter("user", "/compact"); }), ); diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index e23da78087a70..25f097a5c2d9d 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -88,8 +88,9 @@ cannot be overwritten by a stale cancellation. The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts) rechecks the preference, provider selection, newer user work, a stop the user requested, and maintenance turns before dispatching. A cut `/logout` is not repeated. A cut `/compact` -runs again instead of receiving the continuation prompt, and its completion releases the -queue held behind it, since that queue was waiting on the compaction rather than on a person. +runs again instead of receiving the continuation prompt. Its completion releases the runs +that restart held, which the cut run records, since they waited on the compaction rather than +on a person. Holds already in place, such as after a Stop or a failure, stay for the user. Intent and delivery both refuse a thread that is archived, settled, or still snoozed, a cut turn whose provider turn already ended, and a run waiting on the user: an approval, question, or secret that was pending, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 785f41e3aa0fb..12a125f63ed1a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -697,6 +697,8 @@ export const OrchestrationV2Run = Schema.Struct({ restartCancelledBackgroundWork: Schema.optional( Schema.Array(OrchestrationV2RestartCancelledBackgroundWork), ), + /** Queued runs a restart held when it cut this run, as opposed to holds already in place. */ + restartHeldRunIds: Schema.optional(Schema.Array(RunId)), sourcePlanRef: Schema.optional( Schema.Struct({ threadId: ThreadId, From eae4d6b28c67840162802dc04d4b4d254c970bb9 Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Tue, 3 Nov 2026 09:42:43 -0500 Subject: [PATCH 3/8] test(server): wait for the completion listener before asserting a hold stays Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 976c578f8db12..babc57c57ab5e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5548,10 +5548,15 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* send("idle", "agent", true); yield* end("idle", "cancelled"); yield* send(" /compact", "agent", true); + yield* send("witness", "agent", false); + assert.isTrue((yield* runFor("witness")).queueHeld); yield* end(" /compact", "completed", "", { restartContinuationOfRunId: (yield* runFor("idle")).id, }); + // The listener releases the agent's message past the user's, which stays held. + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("witness")).id); assert.isTrue((yield* runFor("user")).queueHeld); + yield* end("witness", "completed"); yield* send("cut", "agent", true); yield* end("cut", "cancelled", "", { restartHeldRunIds: [user.id] }); yield* send("/compact", "agent", true); From 792ef3ed534dc75294ad5b9f34e7ee342e19ad33 Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Tue, 3 Nov 2026 10:03:54 -0500 Subject: [PATCH 4/8] refactor(server): trim the compaction restart fix Release the holds recorded by the continuation's direct source only, drop the projection-store preservation (the field is written in the same event that cancels the run), and shorten the dispatch and tests. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 27 ++++------- .../orchestration-v2/ProjectionStore.test.ts | 11 ----- .../src/orchestration-v2/ProjectionStore.ts | 10 +--- .../ProviderRuntimeRecoveryService.ts | 4 +- .../RestartContinuation.test.ts | 47 ++++--------------- .../orchestration-v2/RestartContinuation.ts | 44 +++++++---------- .../src/orchestration-v2/runtimeLayer.test.ts | 28 +++-------- docs/internals/server-updates.md | 6 +-- 8 files changed, 45 insertions(+), 132 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6da09a1d1ed8d..2761b8ec1c55c 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2356,7 +2356,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // A hold waits for a person to review what they queued. Nobody reviews messages from agents, // the server or scheduled tasks, so a later completed run releases those unless it was stopped. - // A restart that cut a compaction held what was queued behind it; redoing it releases those. const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* ( threadId: ThreadId, completedRun: OrchestrationV2Run, @@ -2387,26 +2386,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined) .map((message) => message.id), ); - const restartHeld = new Set(); - if ( - messages.some( - (message) => - message.id === completedRun.userMessageId && isCompactionCommandMessage(message), - ) - ) { - // Each restart in a continuation chain records only the holds it added. - const visited = new Set(); - let sourceId = completedRun.restartContinuationOfRunId; - while (sourceId !== undefined && !visited.has(sourceId)) { - visited.add(sourceId); - const source = projection.runs.find((run) => run.id === sourceId); - for (const id of source?.restartHeldRunIds ?? []) restartHeld.add(id); - sourceId = source?.restartContinuationOfRunId; - } - } + // A restart that cut a compaction records the holds it added; redoing it releases those. + const restartHeld = messages.some( + (message) => message.id === completedRun.userMessageId && isCompactionCommandMessage(message), + ) + ? projection.runs.find((run) => run.id === completedRun.restartContinuationOfRunId) + ?.restartHeldRunIds + : undefined; const now = yield* DateTime.now; const released = held.filter( - (run) => unattended.has(run.userMessageId) || restartHeld.has(run.id), + (run) => unattended.has(run.userMessageId) || restartHeld?.includes(run.id) === true, ); if (released.length === 0) return; yield* writeSystemEvents( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index cdd2daa6b7d2e..5b24a65a99567 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1022,16 +1022,6 @@ const restartCancelledWorkSurvivesStaleRunUpdate = Effect.gen(function* () { occurredAt: now, payload: { runId: run.id, restartCancelledBackgroundWork: work }, }); - const restartHeldRunIds = [RunId.make("run:restart-cancelled-work-stale-update:held")]; - yield* store.apply({ - id: EventId.make("event:restart-cancelled-work-stale-update:cancelled"), - type: "run.updated", - threadId, - runId: run.id, - providerInstanceId, - occurredAt: now, - payload: { ...run, status: "cancelled", completedAt: now, restartHeldRunIds }, - }); yield* store.apply({ id: EventId.make("event:restart-cancelled-work-stale-update:completed"), type: "run.updated", @@ -1044,7 +1034,6 @@ const restartCancelledWorkSurvivesStaleRunUpdate = Effect.gen(function* () { const updated = (yield* store.getThreadProjection(threadId)).runs[0]; assert.equal(updated?.status, "completed"); assert.deepEqual(updated?.restartCancelledBackgroundWork, work); - assert.deepEqual(updated?.restartHeldRunIds, restartHeldRunIds); }); const threadWorktreePathsIncludeArchivedOnRequest = Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index f56577cf11d38..3cffce15b70d6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -734,9 +734,6 @@ function preserveRunRecordedFields( current.restartCancelledBackgroundWork !== undefined ? { restartCancelledBackgroundWork: current.restartCancelledBackgroundWork } : {}), - ...(next.restartHeldRunIds === undefined && current.restartHeldRunIds !== undefined - ? { restartHeldRunIds: current.restartHeldRunIds } - : {}), }; } @@ -2037,11 +2034,8 @@ export const layer: Layer.Layer = requested_at = excluded.requested_at, completed_at = excluded.completed_at, payload_json = ${keepRecordedRunField( - keepRecordedRunField( - keepRecordedRunField(sql`excluded.payload_json`, "$.delegatedCompletion"), - "$.restartCancelledBackgroundWork", - ), - "$.restartHeldRunIds", + keepRecordedRunField(sql`excluded.payload_json`, "$.delegatedCompletion"), + "$.restartCancelledBackgroundWork", )} `; break; diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index aa97f8e8dd66f..f392004905113 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -400,9 +400,7 @@ export const make = Effect.gen(function* () { status: "cancelled", queuePosition: null, completedAt: now, - ...(run.status === "queued" || restartHeldRunIds.length === 0 - ? {} - : { restartHeldRunIds }), + ...(restartHeldRunIds.length > 0 ? { restartHeldRunIds } : {}), }, }); for (const attempt of projection.attempts.filter( diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index d2fb230e94bbe..d7507bcdc7848 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1077,45 +1077,16 @@ it.effect("does not continue a run the user asked to stop before the restart", ( it.effect("compacts again after a cut /compact turn, and does not continue a cut /logout", () => Effect.gen(function* () { - const message = (text: string) => ({ - id: MessageId.make("message:user"), - role: "user", - createdBy: "user", - text, - attachments: [], - idleCompaction: "onReturn", - }); - const dispatches = yield* continuationDispatches({ - ...cutMidTurn(), - messages: [message(" /Compact ")], - } as unknown as OrchestrationV2ThreadProjection); - assert.deepEqual( - dispatches.map( - ({ text, idleCompaction, createdBy, notification, restartContinuationOfRunId }) => ({ - text, - idleCompaction, - createdBy, - notification, - restartContinuationOfRunId, - }), - ), - [ - { - text: "/compact", - idleCompaction: "onReturn", - createdBy: "user", - notification: undefined, - restartContinuationOfRunId: runId, - }, - ], - ); - assert.deepEqual( - yield* continuationTexts({ + for (const [text, expected] of [ + [" /Compact ", ["/compact"]], + [" /LOGOUT ", []], + ] as const) { + const texts = yield* continuationTexts({ ...cutMidTurn(), - messages: [message(" /LOGOUT ")], - } as unknown as OrchestrationV2ThreadProjection), - [], - ); + messages: [{ id: MessageId.make("message:user"), role: "user", text, attachments: [] }], + } as unknown as OrchestrationV2ThreadProjection); + assert.deepEqual(texts, [...expected], text); + } const texts = yield* continuationTexts({ ...cutMidTurn(), messages: [{ id: MessageId.make("message:user"), text: "/compact later", attachments: [] }], diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 955ab3de9b264..365ec55c0b4be 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -293,17 +293,11 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta (message) => message.id === source.userMessageId, ); // Compaction changes nothing a repeat could double, so a cut one runs again. - const recompactMessage = + const recompact = sourceMessage !== undefined && isCompactionCommandMessage(sourceMessage) && - !isRestartNoteSource(source, projection.providerTurns) - ? sourceMessage - : undefined; - if ( - recompactMessage === undefined && - sourceMessage !== undefined && - isNativeMaintenanceCommand(sourceMessage) - ) + !isRestartNoteSource(source, projection.providerTurns); + if (!recompact && sourceMessage !== undefined && isNativeMaintenanceCommand(sourceMessage)) return; const note = restartContinuationNote( source, @@ -325,29 +319,23 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - ...(recompactMessage === undefined - ? { - text, - notification: { - source: { kind: "system" }, - outcome: "updated", - summary: note.settled - ? "T3 Code restarted and stopped background work" - : "T3 Code restarted and resumed this turn", - detail: text, - }, - } + text: recompact ? IDLE_COMPACTION_TEXT : text, + idleCompaction: sourceMessage?.idleCompaction, + notification: recompact + ? undefined : { - text: IDLE_COMPACTION_TEXT, - ...(recompactMessage.idleCompaction === undefined - ? {} - : { idleCompaction: recompactMessage.idleCompaction }), - }), + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, - // A repeated /compact keeps its author, so a person's still reads as theirs, not idle. - createdBy: recompactMessage?.createdBy ?? "agent", + // A person's repeated /compact stays theirs, so it does not read as automatic. + createdBy: recompact ? sourceMessage.createdBy : "agent", creationSource: "server", restartContinuationOfRunId: input.sourceRunId, }) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index babc57c57ab5e..88d7f7fa2fc73 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5537,34 +5537,20 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { it.effect("a compaction resumed after a restart releases only what that restart held", () => Effect.gen(function* () { const threadId = ThreadId.make("runtime-layer-held-queue-resumed-compaction"); - const { send, runFor, end, startedRunIds, releasedAfter, awaitHeld } = - yield* queueTestThread(threadId); + const { send, runFor, end, startedRunIds, awaitHeld } = yield* queueTestThread(threadId); yield* send("failed", "agent", true); - yield* send("user", "user", false); + yield* send("earlier", "user", false); + yield* send("later", "user", false); yield* end("failed", "failed"); - yield* awaitHeld("user"); - const user = yield* runFor("user"); - // An idle compaction ran past a failure's hold; the restart that cut it added no hold. - yield* send("idle", "agent", true); - yield* end("idle", "cancelled"); - yield* send(" /compact", "agent", true); - yield* send("witness", "agent", false); - assert.isTrue((yield* runFor("witness")).queueHeld); - yield* end(" /compact", "completed", "", { - restartContinuationOfRunId: (yield* runFor("idle")).id, - }); - // The listener releases the agent's message past the user's, which stays held. - assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("witness")).id); - assert.isTrue((yield* runFor("user")).queueHeld); - yield* end("witness", "completed"); + yield* awaitHeld("later"); yield* send("cut", "agent", true); - yield* end("cut", "cancelled", "", { restartHeldRunIds: [user.id] }); + yield* end("cut", "cancelled", "", { restartHeldRunIds: [(yield* runFor("later")).id] }); yield* send("/compact", "agent", true); yield* end("/compact", "completed", "", { restartContinuationOfRunId: (yield* runFor("cut")).id, }); - assert.equal(yield* Queue.take(startedRunIds), user.id); - yield* releasedAfter("user", "/compact"); + assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("later")).id); + assert.isTrue((yield* runFor("earlier")).queueHeld); }), ); diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 25f097a5c2d9d..dc17096b29834 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -87,10 +87,8 @@ cannot be overwritten by a stale cancellation. The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinuation.ts) rechecks the preference, provider selection, newer user work, a stop the user requested, -and maintenance turns before dispatching. A cut `/logout` is not repeated. A cut `/compact` -runs again instead of receiving the continuation prompt. Its completion releases the runs -that restart held, which the cut run records, since they waited on the compaction rather than -on a person. Holds already in place, such as after a Stop or a failure, stay for the user. +and maintenance turns such as `/logout` before dispatching. A cut `/compact` runs again, and +its completion releases the holds that restart added, since they waited on it, not a person. Intent and delivery both refuse a thread that is archived, settled, or still snoozed, a cut turn whose provider turn already ended, and a run waiting on the user: an approval, question, or secret that was pending, From ef424dc58c2cd78549d723c7af5e57d6755a18ce Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Tue, 3 Nov 2026 10:15:07 -0500 Subject: [PATCH 5/8] fix(server): release restart holds across retried and re-cut compaction continuations Co-Authored-By: Claude Opus 5.5 --- .../server/src/orchestration-v2/Orchestrator.ts | 17 +++++++++++------ .../src/orchestration-v2/runtimeLayer.test.ts | 7 ++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 2761b8ec1c55c..d843d992847a9 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2387,15 +2387,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .map((message) => message.id), ); // A restart that cut a compaction records the holds it added; redoing it releases those. - const restartHeld = messages.some( + // Retried or re-cut continuations chain back to the run that recorded them. + const restartHeld = new Set(); + const isCompaction = messages.some( (message) => message.id === completedRun.userMessageId && isCompactionCommandMessage(message), - ) - ? projection.runs.find((run) => run.id === completedRun.restartContinuationOfRunId) - ?.restartHeldRunIds - : undefined; + ); + let id = isCompaction ? completedRun.restartContinuationOfRunId : undefined; + for (let hops = 0; id !== undefined && hops < projection.runs.length; hops++) { + const source = projection.runs.find((run) => run.id === id); + for (const heldId of source?.restartHeldRunIds ?? []) restartHeld.add(heldId); + id = source?.restartContinuationOfRunId; + } const now = yield* DateTime.now; const released = held.filter( - (run) => unattended.has(run.userMessageId) || restartHeld?.includes(run.id) === true, + (run) => unattended.has(run.userMessageId) || restartHeld.has(run.id), ); if (released.length === 0) return; yield* writeSystemEvents( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 88d7f7fa2fc73..e7b3f171b6476 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5545,9 +5545,14 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* awaitHeld("later"); yield* send("cut", "agent", true); yield* end("cut", "cancelled", "", { restartHeldRunIds: [(yield* runFor("later")).id] }); + // A second restart cuts the first continuation, which adds no holds of its own. + yield* send(" /compact", "agent", true); + yield* end(" /compact", "cancelled", "", { + restartContinuationOfRunId: (yield* runFor("cut")).id, + }); yield* send("/compact", "agent", true); yield* end("/compact", "completed", "", { - restartContinuationOfRunId: (yield* runFor("cut")).id, + restartContinuationOfRunId: (yield* runFor(" /compact")).id, }); assert.equal(yield* Queue.take(startedRunIds), (yield* runFor("later")).id); assert.isTrue((yield* runFor("earlier")).queueHeld); From c9ceca1f9b40bd3d187fb753d05e2852870b3bbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:22:11 +0000 Subject: [PATCH 6/8] refactor(server): pass idleCompaction only on a repeated compaction AI-assisted; verified with the tests and tools listed above. --- apps/server/src/orchestration-v2/RestartContinuation.ts | 2 +- docs/internals/server-updates.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 365ec55c0b4be..416c5f7f726d9 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -320,7 +320,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta threadId: input.threadId, messageId, text: recompact ? IDLE_COMPACTION_TEXT : text, - idleCompaction: sourceMessage?.idleCompaction, + idleCompaction: recompact ? sourceMessage.idleCompaction : undefined, notification: recompact ? undefined : { diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index dc17096b29834..6ec9ee5daca5d 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -89,8 +89,7 @@ The [continuation handler](../../apps/server/src/orchestration-v2/RestartContinu rechecks the preference, provider selection, newer user work, a stop the user requested, and maintenance turns such as `/logout` before dispatching. A cut `/compact` runs again, and its completion releases the holds that restart added, since they waited on it, not a person. -Intent and delivery both -refuse a thread that is archived, settled, or still snoozed, a cut turn whose provider +Intent and delivery both refuse a thread that is archived, settled, or still snoozed, a cut turn whose provider turn already ended, and a run waiting on the user: an approval, question, or secret that was pending, or that recovery closed on the cut turn. Message-capable questions stay answerable, so they do not block. Delivery also stops after three consecutive continuations in one From 7b081e1d192a12af2415a63f4f8a8b4a0c77dc94 Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Thu, 12 Nov 2026 22:54:13 -0500 Subject: [PATCH 7/8] fix(server): a resumed agent-sent /compact no longer reads as an idle compaction Co-Authored-By: Claude Opus 5.5 --- .../RestartContinuation.test.ts | 21 ++++++++++++++----- .../orchestration-v2/RestartContinuation.ts | 8 +++++-- docs/user/composer.md | 1 + 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 6bffb2d2decec..08b8105307d87 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1079,14 +1079,25 @@ it.effect("does not continue a run the user asked to stop before the restart", ( it.effect("compacts again after a cut /compact turn, and does not continue a cut /logout", () => Effect.gen(function* () { - for (const [text, expected] of [ - [" /Compact ", ["/compact"]], - [" /LOGOUT ", []], + for (const [text, idleCompaction, expected] of [ + [" /Compact ", undefined, ["user:/compact"]], + ["/compact", "beforeCacheExpires", ["agent:/compact"]], + [" /LOGOUT ", undefined, []], ] as const) { - const texts = yield* continuationTexts({ + const sent = yield* continuationDispatches({ ...cutMidTurn(), - messages: [{ id: MessageId.make("message:user"), role: "user", text, attachments: [] }], + messages: [ + { + id: MessageId.make("message:user"), + role: "user", + createdBy: "agent", + text, + attachments: [], + idleCompaction, + }, + ], } as unknown as OrchestrationV2ThreadProjection); + const texts = sent.map((command) => `${command.createdBy}:${command.text}`); assert.deepEqual(texts, [...expected], text); } const texts = yield* continuationTexts({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 416c5f7f726d9..f0c38632776b2 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -334,8 +334,12 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, - // A person's repeated /compact stays theirs, so it does not read as automatic. - createdBy: recompact ? sourceMessage.createdBy : "agent", + // Agent + server with no idleCompaction reads as an automatic idle compaction. + createdBy: !recompact + ? "agent" + : sourceMessage.idleCompaction === undefined + ? "user" + : sourceMessage.createdBy, creationSource: "server", restartContinuationOfRunId: input.sourceRunId, }) diff --git a/docs/user/composer.md b/docs/user/composer.md index df880e49c6ca5..95c35b2e4abc5 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -261,6 +261,7 @@ If you stop the agent or the server restarts, queued messages keep their order a Press **Resume** in an empty composer to continue. **Resume** also continues a stopped run or one that hit a usage limit, even with nothing queued. Sending a new message also resumes the queue, unless it steers a turn that is already running. You can edit or remove held messages without starting them. +A `/compact` cut by a restart runs again, and messages queued behind it send when it finishes. The pencil on a queued message opens it in the composer for editing. The original message stays in the queue until you save, and it is highlighted while you edit. The message's From 1531602ae11b3d60ec34ff1eeeea9ec9a69dfbb5 Mon Sep 17 00:00:00 2001 From: Nicholas Wasmiller Date: Thu, 12 Nov 2026 23:00:20 -0500 Subject: [PATCH 8/8] fix(server): a resumed legacy idle compaction keeps T3 Code authorship Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/RestartContinuation.test.ts | 11 ++++++----- .../src/orchestration-v2/RestartContinuation.ts | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 08b8105307d87..6df1b633a3845 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -1079,10 +1079,11 @@ it.effect("does not continue a run the user asked to stop before the restart", ( it.effect("compacts again after a cut /compact turn, and does not continue a cut /logout", () => Effect.gen(function* () { - for (const [text, idleCompaction, expected] of [ - [" /Compact ", undefined, ["user:/compact"]], - ["/compact", "beforeCacheExpires", ["agent:/compact"]], - [" /LOGOUT ", undefined, []], + for (const [text, creationSource, expected] of [ + [" /Compact ", "mcp", ["user:/compact"]], + // Servers before `idleCompaction` marked their own compaction this way. + ["/compact", "server", ["agent:/compact"]], + [" /LOGOUT ", "mcp", []], ] as const) { const sent = yield* continuationDispatches({ ...cutMidTurn(), @@ -1091,9 +1092,9 @@ it.effect("compacts again after a cut /compact turn, and does not continue a cut id: MessageId.make("message:user"), role: "user", createdBy: "agent", + creationSource, text, attachments: [], - idleCompaction, }, ], } as unknown as OrchestrationV2ThreadProjection); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index f0c38632776b2..330927f96fdad 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -3,6 +3,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { CommandId, IDLE_COMPACTION_TEXT, + idleCompactionOf, isCompactionCommandMessage, isOrchestrationV2WorkActive, latestProviderTurnForAttempt, @@ -337,7 +338,7 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta // Agent + server with no idleCompaction reads as an automatic idle compaction. createdBy: !recompact ? "agent" - : sourceMessage.idleCompaction === undefined + : idleCompactionOf(sourceMessage) === undefined ? "user" : sourceMessage.createdBy, creationSource: "server",