From fa8538f229914c6462af4eb3ffc22466ad1a36b9 Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:43:13 +0900 Subject: [PATCH] feat(server): agents can settle their own thread after the run ends A scheduled task that posts into a bound thread unsettles it on every delivery, and the agent could not put it back: settling its own thread fails while its run is active, and its MCP credential dies with the run. t3_thread_organize gains settle_after_run. It records the calling run on the thread (settleAfterRunId), and when that run ends the orchestrator applies it through the normal thread.settle mutation, so projections and events match a manual settle. Any message dispatched to the thread (including scheduled deliveries and steers), an unsettle, or a pin clears the request, and the apply also requires the run to have completed as the thread's latest run. --- ...OrchestratorMcpToolkit.integration.test.ts | 32 ++++ .../src/mcp/toolkits/thread/handlers.ts | 15 +- apps/server/src/mcp/toolkits/thread/tools.ts | 3 +- .../src/orchestration-v2/Orchestrator.ts | 141 +++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 167 ++++++++++++++++++ .../provider/T3OrchestrationInstructions.ts | 1 + docs/user/thread-sidebar.md | 5 + packages/contracts/src/orchestrationV2.ts | 26 +++ 8 files changed, 388 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 4c3456aec830..e12256806c01 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -781,10 +781,42 @@ describe("orchestrator MCP toolkit", () => { "running", ); + const otherThreadId = ThreadId.make("thread:mcp-other"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:mcp-other:create"), + threadId: otherThreadId, + projectId, + title: "MCP other", + modelSelection: codexSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const otherThreadSettle = yield* invoke("t3_thread_organize", { + action: "settle_after_run", + threadId: otherThreadId, + }); + expect(declaredFailure(otherThreadSettle)).toMatchObject({ code: "invalid_request" }); + const deferredSettle = yield* invoke("t3_thread_organize", { + action: "settle_after_run", + }); + expect(deferredSettle.isError).toBe(false); + const afterDeferredSettle = yield* orchestrator.getThreadProjection(parentThreadId); + expect(afterDeferredSettle.thread.settleAfterRunId).toBe(parentRun?.id); + expect(afterDeferredSettle.thread.settledOverride).not.toBe("settled"); + + // Pinning withdraws the request, so the run's end leaves the thread active. const pinned = yield* invoke("t3_thread_organize", { action: "pin" }); expect(pinned.isError).toBe(false); expect(pinned.structuredContent).toHaveProperty("sequence"); expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).not.toBeNull(); + expect( + (yield* orchestrator.getThreadProjection(parentThreadId)).thread.settleAfterRunId, + ).toBeNull(); yield* invoke("t3_thread_organize", { action: "unpin" }); expect((yield* orchestrator.getThreadShell(parentThreadId))?.pinnedAt).toBeNull(); diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 2a06c58c1b43..d5204ff1cdf7 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -292,8 +292,21 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { ), t3_thread_organize: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readThread(input.threadId); + const { threads, projection, caller } = yield* readThread(input.threadId); const common = { commandId: yield* newCommandId(), threadId: projection.thread.id }; + if (input.action === "settle_after_run") { + // Only the agent running a thread knows whether its run was worth the user's attention. + if (caller === undefined || caller.id !== projection.thread.id || !caller.activeRunId) { + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: "settle_after_run applies only to the calling thread's own active run.", + }); + } + const result = yield* threads + .dispatch({ ...common, type: "thread.settle-after-run", runId: caller.activeRunId }) + .pipe(Effect.mapError(dispatchFailure)); + return { sequence: result.sequence }; + } let command: OrchestrationV2Command; switch (input.action) { case "snooze": diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 0958e6f3add9..51fb6608402b 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -30,7 +30,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply; this does not schedule a future action.", + "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. settle fails on a thread with an active run, including this one; to settle this thread when your current run finishes, use settle_after_run with threadId omitted. It settles only if the run completes and no newer message, scheduled delivery, unsettle, or pin reaches the thread first; otherwise the thread stays as it is.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), action: Schema.Literals([ @@ -39,6 +39,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { "snooze", "unsnooze", "settle", + "settle_after_run", "unsettle", "archive", "unarchive", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f..39e9f73037ba 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -449,6 +449,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "checkpoint.rollback.fail": case "thread.background-work.settle": case "thread.stop": + case "thread.settle-after-run": + case "thread.settle-after-run.apply": case "provider.switch": return command.threadId; case "delegated_task.request": @@ -2770,6 +2772,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), + settleAfterRunId: null, pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), unsettledAt: null, pinnedAt: null, @@ -2784,6 +2787,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "active", settledAt: null, + settleAfterRunId: null, unsettledAt: alreadyPinnedActive ? (thread.unsettledAt ?? null) : now, updatedAt: alreadyPinnedActive ? thread.updatedAt : now, }; @@ -2837,6 +2841,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio settledOverride: thread.settledOverride === "settled" ? "active" : thread.settledOverride, settledAt: thread.settledOverride === "settled" ? null : thread.settledAt, + settleAfterRunId: null, snoozedUntil: null, snoozedAt: null, updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now, @@ -4536,6 +4541,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } + // A message outranks its thread agent's request to settle after the run. + if (projection.thread.settleAfterRunId != null) { + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: projection.thread.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { ...projection.thread, settleAfterRunId: null }, + }); + projection = yield* getProjectionWithPendingEvents(command.threadId, events); + } if (projection.thread.settledOverride !== null) { const now = yield* DateTime.now; const thread: OrchestrationV2AppThread = { @@ -10097,6 +10116,100 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ); + const dispatchSettleAfterRunRequest = ( + command: Extract, + events: Ref.Ref>, + ) => + Effect.gen(function* () { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe( + Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), + ); + const reject = (cause: string) => + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause, + }); + const thread = projection.thread; + if (thread.archivedAt !== null || thread.deletedAt !== null) { + return yield* reject(`Thread ${command.threadId} is archived or deleted.`); + } + const run = projection.runs.find((candidate) => candidate.id === command.runId); + if (run === undefined || !["starting", "running", "waiting"].includes(run.status)) { + return yield* reject(`Run ${command.runId} is not active on thread ${command.threadId}.`); + } + if (projection.runs.some((candidate) => candidate.ordinal > run.ordinal)) { + return yield* reject( + `Thread ${command.threadId} already has newer work queued, so it stays unsettled.`, + ); + } + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: thread.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { ...thread, settleAfterRunId: run.id }, + }); + }); + + const dispatchSettleAfterRunApply = ( + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.settle-after-run.apply" } + >, + events: Ref.Ref>, + effects: Ref.Ref>, + ) => + Effect.gen(function* () { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs"]) + .pipe( + Effect.mapError(() => new OrchestratorProjectionError({ threadId: command.threadId })), + ); + const requested = projection.thread.settleAfterRunId === command.runId; + const run = projection.runs.find((candidate) => candidate.id === command.runId); + // A message dispatched after the request already cleared it; a later + // run that skipped message dispatch still wins here. + const settles = + requested && + run?.status === "completed" && + !projection.runs.some((candidate) => candidate.ordinal > run.ordinal); + if (settles) { + const eventCountBeforeSettle = (yield* Ref.get(events)).length; + const settled = yield* dispatchThreadMutation( + { type: "thread.settle", commandId: command.commandId, threadId: command.threadId }, + events, + effects, + ).pipe( + Effect.as(true), + // Blocked work, such as a pending approval, keeps the thread active. + Effect.catchTags({ + OrchestratorDispatchError: () => + Ref.update(events, (existing) => existing.slice(0, eventCountBeforeSettle)).pipe( + Effect.as(false), + ), + }), + ); + if (settled) return; + } + // The request is spent either way; record the no-op so the receipt is accepted. + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + providerInstanceId: projection.thread.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: requested ? { ...projection.thread, settleAfterRunId: null } : projection.thread, + }); + }); + const dispatchOnce = Effect.fn("orchestrationV2.dispatch.once")(function* ( command: OrchestrationV2ServerCommand, ): Effect.fn.Return< @@ -10363,6 +10476,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.background-work.settle": yield* dispatchBackgroundWorkSettle(command, events, effects); break; + case "thread.settle-after-run": + yield* dispatchSettleAfterRunRequest(command, events); + break; + case "thread.settle-after-run.apply": + yield* dispatchSettleAfterRunApply(command, events, effects); + break; case "thread.fork": yield* dispatchThreadFork(command, events); break; @@ -10622,6 +10741,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId, finalizeDelegatedCompletionDelivery(threadId, stored.event.payload.id), ); + // Before queue promotion, so a queued run is still visible to the request's guard. + // A failure leaves the thread unsettled and must not hold up the queue. + const runId = stored.event.payload.id; + yield* projectionStore.getThread(threadId).pipe( + Effect.flatMap((thread) => + thread.settleAfterRunId === runId + ? dispatchWithReceipt({ + type: "thread.settle-after-run.apply", + commandId: CommandId.make(`command:system:settle-after-run:${runId}`), + threadId, + runId, + }) + : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to settle a thread after its run", { + threadId, + runId, + cause, + }), + ), + ); } yield* threadDispatch.withLock( threadId, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 131f05034051..c2ebccc77463 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3498,6 +3498,173 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + { + // Starts a run on a thread that a scheduled delivery just unsettled, and + // returns helpers to end that run and await the orchestrator's reaction. + const startRun = (name: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`settle-after-run-${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`settle-after-run-${name}-create`), + threadId, + projectId: ProjectId.make(`settle-after-run-${name}-project`), + title: "Settle after run", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: `/tmp/settle-after-run-${name}`, + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`settle-after-run-${name}-settled`), + threadId, + }); + const sendMessage = ( + id: string, + dispatchMode: { type: "queue_after_active" | "start_immediately" }, + ) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "server", + commandId: CommandId.make(`settle-after-run-${name}-${id}`), + threadId, + messageId: MessageId.make(`settle-after-run-${name}-${id}`), + text: id, + attachments: [], + modelSelection, + dispatchMode, + }); + yield* sendMessage("delivery", { type: "start_immediately" }); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + const request = (attempt = "request") => + orchestrator.dispatch({ + type: "thread.settle-after-run", + commandId: CommandId.make(`settle-after-run-${name}-${attempt}`), + threadId, + runId: run.id, + }); + // Ends the run; with a request still pending, waits for its reaction. + const endRun = (status: "completed" | "failed") => + Effect.gen(function* () { + const pending = + (yield* orchestrator.getThreadProjection(threadId)).thread.settleAfterRunId === + run.id; + const afterSequence = yield* eventSink.latestSequence(); + const applied = yield* eventSink.stream({ afterSequence }).pipe( + Stream.filter( + (stored) => stored.commandId === `command:system:settle-after-run:${run.id}`, + ), + Stream.runHead, + Effect.forkChild, + ); + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: CommandId.make(`settle-after-run-${name}-${status}`), + events: [ + { + id: EventId.make(`settle-after-run-${name}-${status}`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status, startedAt: now, completedAt: now }, + }, + ], + }); + if (pending) yield* Fiber.join(applied); + else yield* Fiber.interrupt(applied); + return yield* orchestrator.getThreadProjection(threadId); + }); + return { orchestrator, threadId, run, request, endRun, sendMessage }; + }); + + it.effect("settle after run: settles the thread once the requesting run completes", () => + Effect.gen(function* () { + const { orchestrator, threadId, run, request, endRun } = yield* startRun("completes"); + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.settledAt); + + yield* request(); + const requested = yield* orchestrator.getThreadProjection(threadId); + assert.equal(requested.thread.settleAfterRunId, run.id); + assert.isNull(requested.thread.settledAt); + + const ended = yield* endRun("completed"); + assert.equal(ended.thread.settledOverride, "settled"); + assert.isNotNull(ended.thread.settledAt); + assert.isNull(ended.thread.settleAfterRunId ?? null); + assert.equal((yield* orchestrator.getThreadShell(threadId))?.settledOverride, "settled"); + + const late = yield* request("late-request").pipe(Effect.flip); + assert.equal(late._tag, "OrchestratorDispatchError"); + }), + ); + + it.effect( + "settle after run: keeps the thread unsettled when a delivery queues after the request", + () => + Effect.gen(function* () { + const { orchestrator, threadId, request, endRun, sendMessage } = + yield* startRun("queued"); + yield* request(); + yield* sendMessage("next-delivery", { type: "queue_after_active" }); + assert.isNull( + (yield* orchestrator.getThreadProjection(threadId)).thread.settleAfterRunId ?? null, + ); + + const ended = yield* endRun("completed"); + assert.isNull(ended.thread.settledOverride); + assert.isNull(ended.thread.settledAt); + }), + ); + + it.effect("settle after run: drops the request when the run fails", () => + Effect.gen(function* () { + const { request, endRun } = yield* startRun("failed"); + yield* request(); + + const ended = yield* endRun("failed"); + assert.isNull(ended.thread.settledAt); + assert.isNull(ended.thread.settleAfterRunId ?? null); + }), + ); + + it.effect("settle after run: lets the user's unsettle win over the request", () => + Effect.gen(function* () { + const { orchestrator, threadId, request, endRun } = yield* startRun("unsettled"); + yield* request(); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("settle-after-run-unsettled-user"), + threadId, + reason: "user", + }); + assert.isNull( + (yield* orchestrator.getThreadProjection(threadId)).thread.settleAfterRunId ?? null, + ); + + const ended = yield* endRun("completed"); + assert.equal(ended.thread.settledOverride, "active"); + assert.isNull(ended.thread.settledAt); + }), + ); + + it.effect("settle after run: rejects a request once a later run is queued", () => + Effect.gen(function* () { + const { request, sendMessage } = yield* startRun("already-queued"); + yield* sendMessage("earlier-delivery", { type: "queue_after_active" }); + const error = yield* request().pipe(Effect.flip); + assert.equal(error._tag, "OrchestratorDispatchError"); + }), + ); + } + it.effect("cancels queued work when a thread is archived", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/provider/T3OrchestrationInstructions.ts b/apps/server/src/provider/T3OrchestrationInstructions.ts index fe700c0e2084..e0753998c1c0 100644 --- a/apps/server/src/provider/T3OrchestrationInstructions.ts +++ b/apps/server/src/provider/T3OrchestrationInstructions.ts @@ -10,6 +10,7 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work. - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review. - \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. +- To leave this thread settled once your run ends, for example when a scheduled check found nothing new, call \`t3_thread_organize\` with \`{"action":"settle_after_run"}\` and no threadId. It applies only if this run completes and no newer message, scheduled delivery, unsettle, or pin reaches the thread first. Plain \`settle\` fails while your own run is active. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. ### Choose the workspace before starting a new thread diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index aed09c042794..7e0049cc6ce8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,6 +134,11 @@ sending an answer or restarting the agent. Settling also closes the thread's terminals that wait at an idle prompt, and keeps their output. A terminal that runs a command, such as a dev server, stays open. +An agent can also settle its own thread when its turn ends. This suits a recurring +scheduled task: tell the agent in the task's prompt to settle the thread when it found nothing +new, and quiet runs stay out of your active list. A message you send, another scheduled run, or +un-settling or pinning the thread before the turn ends keeps it active. + On web and desktop, press a thread's **Settle** button and drag up or down to settle every thread in that section between it and the one you release on. The **Un-settle** and **Wake** buttons work the same way in their sections. diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..cecf2c56d090 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -405,6 +405,11 @@ export const OrchestrationV2AppThread = Schema.Struct({ snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), + /** + * The run whose agent asked to settle this thread once it completes. Any + * message dispatched to the thread, an unsettle, or a pin clears it. + */ + settleAfterRunId: Schema.optional(Schema.NullOr(RunId)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), // Fractional-index slot in the user-arranged pinned order. Optional so @@ -3078,6 +3083,27 @@ const OrchestrationV2InternalCommand = Schema.Union([ }), ), }), + /** + * An agent's request to settle its own thread once its active `runId` + * completes. Rejected when the run is not active or a later run exists. + */ + Schema.Struct({ + type: Schema.Literal("thread.settle-after-run"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + }), + /** + * Follows the end of a run that requested `thread.settle-after-run`. Settles + * only when that run completed and is still the thread's latest work; + * otherwise it clears the request. + */ + Schema.Struct({ + type: Schema.Literal("thread.settle-after-run.apply"), + commandId: CommandId, + threadId: ThreadId, + runId: RunId, + }), /** Records that the provider rollback `requestId` failed for good. */ Schema.Struct({ type: Schema.Literal("checkpoint.rollback.fail"),