import { EventId, MAX_SCRIPT_ID_LENGTH, SCRIPT_RUN_COMMAND_PATTERN, MessageId, ThreadLinkedPullRequest, UserInputRequestedPayload, isImportedAgentSessionMessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, type ThreadPullRequestKey, type ThreadPullRequestLink, type OrchestrationThreadActivity, } from "@t3tools/contracts"; import { legacyLinkedPullRequestOf, legacyThreadPullRequestKey, normalizeThreadPullRequestKey, threadPullRequestKeysEqual, } from "@t3tools/shared/threadPullRequests"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import type * as PlatformError from "effect/PlatformError"; import { OrchestrationCommandInvariantError, OrchestrationThreadSettleBlockedError, type OrchestrationCommandRejection, } from "./Errors.ts"; import { listThreadsByProjectId, requireActiveProjectWorkspaceRootAbsent, requireProject, requireProjectAbsent, requireThread, requireThreadArchived, requireThreadAbsent, requireThreadNotArchived, } from "./commandInvariants.ts"; import { projectEvent } from "./projector.ts"; import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); /** * Blocked-on-you work derived from the thread's retained activities: an * approval or user-input request with no later resolution for the same * requestId. The server-side twin of the shell's hasPendingApprovals / * hasPendingUserInput flags, which the decider read model does not carry. * The clearing rules MUST match ProjectionPipeline's pending accounting — * resolved activities always clear, respond.failed clears only when the * failure detail marks the request stale/unknown — or settle would be * rejected on threads whose shell flags read as clear. */ function isStaleRequestFailureDetail(payload: Record | null): boolean { const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; if (detail === null) return false; return ( detail.includes("stale pending approval request") || detail.includes("unknown pending approval request") || detail.includes("unknown pending permission request") || detail.includes("stale pending user-input request") || detail.includes("unknown pending user-input request") || detail.includes("unknown pending user input request") || detail.includes("unknown pending codex user input request") ); } // Scans the read model's activities, which the projector caps at the most // recent 500 plus pending async questions. Async questions remain actionable // while the agent works, so they must not expire with the activity window. function openRequests(thread: Pick) { const requests = new Map(); for (const activity of thread.activities) { const payload = typeof activity.payload === "object" && activity.payload !== null ? (activity.payload as Record) : null; const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; if (requestId === null) continue; if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { requests.set(requestId, activity); } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { requests.delete(requestId); } else if ( (activity.kind === "provider.approval.respond.failed" || activity.kind === "provider.user-input.respond.failed") && isStaleRequestFailureDetail(payload) ) { requests.delete(requestId); } } return requests; } /** Apply the shared shell-level rule to the detailed command read model. */ function hasQueuedTurnStartForThread( thread: Pick, now: string, ): boolean { let latestUserMessageAt: string | null = null; let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; for (const message of thread.messages) { if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; const messageAtMs = Date.parse(message.createdAt); latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); if (messageAtMs === latestUserMessageAtMs) { latestUserMessageAt = message.createdAt; } } return threadHasQueuedTurnStart( { latestUserMessageAt: Number.isFinite(latestUserMessageAtMs) ? latestUserMessageAt : null, latestTurn: thread.latestTurn, session: thread.session, }, now, ); } function findPullRequestLink( thread: Pick, key: ThreadPullRequestKey, ): ThreadPullRequestLink | undefined { return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key)); } function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; readonly aggregateId: OrchestrationEvent["aggregateId"]; readonly occurredAt: string; readonly metadata?: OrchestrationEvent["metadata"]; }, ): Effect.Effect< Omit, PlatformError.PlatformError, Crypto.Crypto > { return Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4.pipe( Effect.map((eventId) => ({ eventId: EventId.make(eventId), aggregateKind: input.aggregateKind, aggregateId: input.aggregateId, occurredAt: input.occurredAt, commandId: input.commandId, causationEventId: null, correlationId: input.commandId, metadata: input.metadata ?? {}, })), ), ), ); } type PlannedOrchestrationEvent = Omit; type DecideOrchestrationCommandResult = | PlannedOrchestrationEvent | ReadonlyArray; const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ commands, readModel, }: { readonly commands: ReadonlyArray; readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { let nextReadModel = readModel; let nextSequence = readModel.snapshotSequence; const plannedEvents: PlannedOrchestrationEvent[] = []; for (const nextCommand of commands) { const decided = yield* decideOrchestrationCommand({ command: nextCommand, readModel: nextReadModel, }); const nextEvents = Array.isArray(decided) ? decided : [decided]; for (const nextEvent of nextEvents) { plannedEvents.push(nextEvent); nextSequence += 1; nextReadModel = yield* projectEvent(nextReadModel, { ...nextEvent, sequence: nextSequence, }).pipe(Effect.orDie); } } return plannedEvents; }); export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({ command, readModel, userInputActivity, }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; readonly userInputActivity?: OrchestrationThreadActivity; }): Effect.fn.Return< DecideOrchestrationCommandResult, OrchestrationCommandRejection | PlatformError.PlatformError, Crypto.Crypto > { switch (command.type) { case "project.create": { yield* requireProjectAbsent({ readModel, command, projectId: command.projectId, }); yield* requireActiveProjectWorkspaceRootAbsent({ readModel, command, workspaceRoot: command.workspaceRoot, exceptProjectId: command.projectId, }); return { ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "project.created", payload: { projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, // Project creation has no user model choice. Older clients sent an // automatic seed here, but only a metadata update records an // explicit project default. defaultModelSelection: null, faviconPath: null, projectIcon: null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, }, }; } case "project.meta.update": { const project = yield* requireProject({ readModel, command, projectId: command.projectId, }); if (command.scripts !== undefined) { // Persisted IDs predate shortcut validation. Let users edit or remove them // without allowing another invalid ID to enter the project. const existingIds = new Set(project.scripts.map((script) => script.id)); for (const script of command.scripts) { if (!existingIds.has(script.id) && !isScriptRunCommand(`script.${script.id}.run`)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Script ID '${script.id}' must be 1-${MAX_SCRIPT_ID_LENGTH} lowercase letters, digits or hyphens, starting with a letter or digit.`, }); } } } if (command.workspaceRoot !== undefined) { yield* requireActiveProjectWorkspaceRootAbsent({ readModel, command, workspaceRoot: command.workspaceRoot, exceptProjectId: command.projectId, }); } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt, commandId: command.commandId, })), type: "project.meta-updated", payload: { projectId: command.projectId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), ...(command.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: command.defaultThreadEnvMode } : {}), ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}), ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(command.projectIcon !== undefined ? { projectIcon: command.projectIcon } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, }; } case "project.delete": { yield* requireProject({ readModel, command, projectId: command.projectId, }); const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter( (thread) => thread.deletedAt === null, ); if (activeThreads.length > 0 && command.force !== true) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`, }); } if (activeThreads.length > 0) { return yield* decideCommandSequence({ readModel, commands: [ ...activeThreads.map( (thread): Extract => ({ type: "thread.delete", commandId: command.commandId, threadId: thread.id, }), ), { type: "project.delete", commandId: command.commandId, projectId: command.projectId, }, ], }); } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt, commandId: command.commandId, })), type: "project.deleted" as const, payload: { projectId: command.projectId, deletedAt: occurredAt, }, }; } case "thread.create": { yield* requireProject({ readModel, command, projectId: command.projectId, }); yield* requireThreadAbsent({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), })), type: "thread.created", payload: { threadId: command.threadId, projectId: command.projectId, title: command.title, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, createdAt: command.createdAt, updatedAt: command.createdAt, }, }; } case "thread.delete": { yield* requireThread({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.deleted", payload: { threadId: command.threadId, deletedAt: occurredAt, }, }; } case "thread.archive": { yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.archived", payload: { threadId: command.threadId, archivedAt: occurredAt, updatedAt: occurredAt, }, }; } case "thread.unarchive": { yield* requireThreadArchived({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unarchived", payload: { threadId: command.threadId, updatedAt: occurredAt, }, }; } case "thread.settle": case "thread.auto-settle": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); if (command.type === "thread.auto-settle" && thread.settledOverride !== null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} changed before automatic settlement`, }), ); } // The server owns settle eligibility. A stale command must not settle // a thread whose session is coming alive or working. if (thread.session?.status === "starting" || thread.session?.status === "running") { return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const pendingRequests = openRequests(thread); // Manual settlement dismisses async questions without answering them. // Native callbacks and approvals still need a response or interruption. if ( Array.from(pendingRequests.values()).some( (activity) => command.type === "thread.auto-settle" || activity.kind !== "user-input.requested" || !Predicate.isObject(activity.payload) || activity.payload.responseMode !== "message", ) ) { return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } const occurredAt = yield* nowIso; // Settling inside the adoption window would hide just-requested work. if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* new OrchestrationThreadSettleBlockedError({ threadId: command.threadId }); } // Settling an already-settled thread re-emits with the original // settledAt: the engine rejects zero-event commands, and bulk-settle / // double-click must stay silent no-ops rather than surface errors. const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; const settledEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.settled" as const, payload: { threadId: command.threadId, settledAt: alreadySettled ? thread.settledAt : command.type === "thread.auto-settle" ? command.settledAt : occurredAt, // A re-emission is a projected no-op: keep the existing updatedAt // so duplicate settles neither rewind nor churn ordering. A fresh // settle stamps the command time. updatedAt: alreadySettled ? thread.updatedAt : occurredAt, }, }; // Settling is "I'm done with this": clear states that would keep the // row pinned or snoozed instead of showing the new settled state. const companionEvents: Array> = []; for (const [requestId, request] of pendingRequests) { companionEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.activity-appended", payload: { threadId: command.threadId, activity: { id: EventId.make(`settle:${command.commandId}:${requestId}`), kind: "user-input.resolved", summary: "User input dismissed", tone: "info", turnId: request.turnId, createdAt: occurredAt, payload: { requestId, responseMode: "message" }, }, }, }); } if (thread.pinnedAt != null) { companionEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unpinned" as const, payload: { threadId: command.threadId, updatedAt: occurredAt, }, }); } if (thread.snoozedUntil != null) { companionEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unsnoozed", payload: { threadId: command.threadId, reason: "user", updatedAt: occurredAt, }, }); } return companionEvents.length > 0 ? [settledEvent, ...companionEvents] : settledEvent; } case "thread.unsettle": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); // Idempotent by re-emission (see thread.settle): reducing the event a // second time lands on the same override state. A re-emission keeps // the existing updatedAt so duplicates do not churn ordering. const alreadyPinnedActive = thread.settledOverride === "active"; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unsettled", payload: { threadId: command.threadId, reason: command.reason, updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, }, }; } case "thread.snooze": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; // A wake time in the past would create a thread that is snoozed and // woken at once — the row would never leave the inbox but still carry // snooze state. Reject instead of silently normalizing. The negated // comparison also catches unparseable wake times (IsoDateTime is // structurally just a string): NaN fails every comparison, and an // unparseable snoozedUntil must never persist. if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, }), ); } // Blocked-on-you work must not be snoozed away: a pending approval or // user-input request is the agent waiting on the user, and hiding it // defeats the request. (A running session IS snoozable — snooze only // affects visibility, never the agent.) if (openRequests(thread).size > 0) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, }), ); } // A queued turn start — a user message no turn has adopted yet — is // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. if (hasQueuedTurnStartForThread(thread, occurredAt)) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, }), ); } // Re-snoozing an already-snoozed thread to the SAME wake time is a // duplicate (double-click, raced clients): re-emit with the original // timestamps so the projection is a no-op. A different wake time is a // real change and stamps fresh. const existingSnoozedAt = thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null ? thread.snoozedAt : null; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.snoozed", payload: { threadId: command.threadId, snoozedUntil: command.snoozedUntil, snoozedAt: existingSnoozedAt ?? occurredAt, updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, }, }; } case "thread.unsnooze": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); // Idempotent by re-emission (see thread.settle): waking a thread that // is not snoozed lands on the same null state without churning // updatedAt. const alreadyAwake = thread.snoozedUntil == null; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unsnoozed", payload: { threadId: command.threadId, reason: command.reason, updatedAt: alreadyAwake ? thread.updatedAt : occurredAt, }, }; } case "thread.pin": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; // Re-pinning an already-pinned thread is a duplicate (double-click, // raced clients): re-emit with the original timestamps so the // projection is a no-op. Pinning has no lifecycle invariants — a pin // only ever promotes visibility, so it can never hide pending work. const existingPinnedAt = thread.pinnedAt ?? null; const pinnedEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.pinned" as const, payload: { threadId: command.threadId, pinnedAt: existingPinnedAt ?? occurredAt, // A fresh pin takes the client's slot in the arranged order; on a // re-pin the existing key wins so raced duplicates cannot move a // thread the user already placed. ...(existingPinnedAt === null && command.orderKey !== undefined ? { pinOrderKey: command.orderKey } : {}), updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, }, }; // Pinning is a promotion: it clears the parked states rather than // silently outranking them. An explicit settle un-settles (reason // "user", same override the un-settle button stamps), and a snooze's // return ticket is spent — the thread is on top NOW, not on Tuesday. const promotionEvents: Array> = []; if (thread.settledOverride === "settled") { promotionEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unsettled", payload: { threadId: command.threadId, reason: "user", updatedAt: occurredAt, }, }); } if (thread.snoozedUntil != null) { promotionEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unsnoozed", payload: { threadId: command.threadId, reason: "user", updatedAt: occurredAt, }, }); } return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent; } case "thread.unpin": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); // Idempotent by re-emission (see thread.settle): unpinning a thread // that is not pinned lands on the same null state without churning // updatedAt. const alreadyUnpinned = thread.pinnedAt == null; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.unpinned", payload: { threadId: command.threadId, updatedAt: alreadyUnpinned ? thread.updatedAt : occurredAt, }, }; } case "thread.pin.reorder": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); // Only pinned threads have a slot in the arranged order. Rejecting // (rather than silently pinning) keeps a raced reorder-after-unpin // from resurrecting a pin the user just cleared. if (thread.pinnedAt == null) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} is not pinned and cannot be reordered`, }), ); } // Idempotent by re-emission (see thread.settle): a duplicate drop on // the same slot keeps the existing updatedAt so it projects as a no-op. const keyUnchanged = thread.pinOrderKey === command.orderKey; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.pin-reordered", payload: { threadId: command.threadId, orderKey: command.orderKey, updatedAt: keyUnchanged ? thread.updatedAt : occurredAt, }, }; } case "thread.active.reorder": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; // Snooze retains this slot. Changing it cannot wake the thread, and // accepting it handles races with snooze and retained wake timestamps. if ( thread.deletedAt !== null || thread.pinnedAt != null || thread.settledOverride === "settled" ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} is not active and cannot be reordered`, }); } return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.meta-updated", payload: { threadId: command.threadId, activeOrderKey: command.orderKey, // Arranging the list is not thread activity or a lifecycle transition. updatedAt: thread.updatedAt, }, }; } case "thread.meta.update": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); // Old clients only see the derived single link. Unlink that request through // the same command path as modern clients, including stack dismissal, while // retaining other links they cannot see. Historical metadata events still replay unchanged. const legacy = legacyLinkedPullRequestOf( thread.pullRequests, thread.projectId, readModel.projects.find((project) => project.id === thread.projectId)?.repositoryIdentity, ); const currentPullRequest = legacy === null ? null : (thread.pullRequests.find( (link) => link.url === legacy.url && link.number === legacy.number, ) ?? null); if (command.linkedPullRequest != null) { const { linkedPullRequest: linked, ...metadata } = command; const project = readModel.projects.find((project) => project.id === thread.projectId); let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"; try { host = new URL(linked.url).hostname; } catch { // Historical clients can send links without a parseable URL. } const hasMetadata = Object.entries(metadata).some( ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, ); return yield* decideCommandSequence({ readModel, commands: [ ...(hasMetadata ? [metadata] : []), ...(currentPullRequest?.source === "manual" ? [ { type: "thread.pull-request.unlink" as const, commandId: command.commandId, threadId: command.threadId, host: currentPullRequest.host, repository: currentPullRequest.repository, number: currentPullRequest.number, }, ] : []), { type: "thread.pull-request.link", commandId: command.commandId, threadId: command.threadId, ...legacyThreadPullRequestKey(linked, host), url: linked.url, source: "manual", }, ], }); } if (command.linkedPullRequest === null && currentPullRequest !== null) { const { linkedPullRequest: _linkedPullRequest, ...metadata } = command; const hasMetadata = Object.entries(metadata).some( ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, ); return yield* decideCommandSequence({ readModel, commands: [ ...(hasMetadata ? [metadata] : []), { type: "thread.pull-request.unlink", commandId: command.commandId, threadId: command.threadId, host: currentPullRequest.host, repository: currentPullRequest.repository, number: currentPullRequest.number, }, ], }); } const branch = command.branch !== undefined && command.expectedBranch !== undefined && thread.branch !== command.expectedBranch ? thread.branch : command.branch; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.meta-updated", payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), ...(command.regenerateTitle === true ? { regenerateTitle: true as const, previousTitle: thread.title, titleRegeneration: { requestId: command.commandId, startedAt: occurredAt, }, } : {}), ...(command.title !== undefined && thread.titleRegeneration != null ? { titleRegeneration: null } : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), ...(command.linkedPullRequest !== undefined ? { linkedPullRequest: command.linkedPullRequest } : {}), updatedAt: occurredAt, }, }; } case "thread.pull-request.link": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const key = normalizeThreadPullRequestKey(command); const existing = findPullRequestLink(thread, key); // An explicit link on a dismissed stack member un-dismisses it; any // other duplicate is a no-op the engine would reject as zero-event. const undismisses = existing?.source === "stack-dismissed" && (command.source === "manual" || command.source === "agent" || command.source === "created"); if (existing !== undefined && !undismisses) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`, }); } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.pull-request-linked", payload: { threadId: command.threadId, link: existing !== undefined ? { ...existing, url: command.url, source: command.source } : { ...key, url: command.url, source: command.source, linkedAt: occurredAt, snapshot: null, stack: null, }, updatedAt: occurredAt, }, }; } case "thread.pull-request.unlink": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const key = normalizeThreadPullRequestKey(command); const existing = findPullRequestLink(thread, key); if (existing === undefined) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, }); } const occurredAt = yield* nowIso; const eventBase = yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, }); // Any known native-stack member needs a tombstone, regardless of who linked it. // A sibling can rediscover it even before this link has its own stack snapshot. const belongsToStack = existing.source === "stack" || existing.stack !== null || thread.pullRequests.some( (link) => link.host.toLowerCase() === key.host && link.repository.toLowerCase() === key.repository && link.stack?.layers.some((layer) => layer.number === key.number), ); if (belongsToStack) { return { ...eventBase, type: "thread.pull-request-linked", payload: { threadId: command.threadId, link: { ...existing, source: "stack-dismissed" }, updatedAt: occurredAt, }, }; } return { ...eventBase, type: "thread.pull-request-unlinked", payload: { threadId: command.threadId, ...key, updatedAt: occurredAt, }, }; } case "thread.pull-request-link.sync": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const key = normalizeThreadPullRequestKey(command); if (findPullRequestLink(thread, key) === undefined) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, }); } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.pull-request-synced", payload: { threadId: command.threadId, ...key, snapshot: command.snapshot, stack: command.stack, updatedAt: occurredAt, }, }; } case "thread.pull-request.sync": { const thread = yield* requireThreadNotArchived({ readModel, command, threadId: command.threadId, }); if (thread.deletedAt !== null) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} was deleted before pull request discovery`, }); } if ( thread.projectId !== command.projectId || thread.branch !== command.expected.branch || thread.worktreePath !== command.expected.worktreePath || !threadPullRequestLinksEqual( thread.linkedPullRequest ?? null, command.expected.linkedPullRequest, ) || !threadPullRequestLinksEqual( thread.branchPullRequest ?? null, command.expected.branchPullRequest, ) ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} changed before pull request discovery`, }); } const project = yield* requireProject({ readModel, command, projectId: command.projectId }); if (project.deletedAt !== null || project.workspaceRoot !== command.expected.workspaceRoot) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `project ${command.projectId} changed before pull request discovery`, }); } const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.meta-updated", payload: { threadId: command.threadId, branchPullRequest: command.branchPullRequest, ...(command.linkedPullRequest !== undefined ? { linkedPullRequest: command.linkedPullRequest } : {}), updatedAt: thread.updatedAt, }, }; } case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.meta-updated", payload: { threadId: command.threadId, ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}), ...(requestIsCurrent ? { titleRegeneration: null } : {}), updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt, }, }; } case "thread.runtime-mode.set": { yield* requireThread({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.runtime-mode-set", payload: { threadId: command.threadId, runtimeMode: command.runtimeMode, updatedAt: occurredAt, }, }; } case "thread.interaction-mode.set": { yield* requireThread({ readModel, command, threadId: command.threadId, }); const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), type: "thread.interaction-mode-set", payload: { threadId: command.threadId, interactionMode: command.interactionMode, updatedAt: occurredAt, }, }; } case "thread.turn.start": { if (isImportedAgentSessionMessageId(command.message.messageId)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, }); } const targetThread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ readModel, command, threadId: sourceProposedPlan.threadId, }) : null; const sourcePlan = sourceProposedPlan && sourceThread ? sourceThread.proposedPlans.find((entry) => entry.id === sourceProposedPlan.planId) : null; if (sourceProposedPlan && !sourcePlan) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Proposed plan '${sourceProposedPlan.planId}' does not exist on thread '${sourceProposedPlan.threadId}'.`, }); } if (sourceThread && sourceThread.projectId !== targetThread.projectId) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`, }); } const userMessageEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.message-sent", payload: { threadId: command.threadId, messageId: command.message.messageId, role: "user", text: command.message.text, attachments: command.message.attachments, turnId: null, streaming: false, createdAt: command.createdAt, updatedAt: command.createdAt, }, }; const turnStartRequestedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), causationEventId: userMessageEvent.eventId, type: "thread.turn-start-requested", payload: { threadId: command.threadId, messageId: command.message.messageId, ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), createdAt: command.createdAt, }, }; // Real activity resets ANY override: it wakes an explicitly settled // thread, and it clears a keep-active pin back to neutral so the // thread can auto-settle again after this burst of work goes stale. // A snooze clears the same way — sending a message to a snoozed // thread is the user re-engaging, so the return ticket is spent. const lifecycleResetEvents: Array> = []; if (targetThread.settledOverride !== null) { lifecycleResetEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.unsettled", payload: { threadId: command.threadId, reason: "activity", updatedAt: command.createdAt, }, }); } if (targetThread.snoozedUntil != null) { lifecycleResetEvents.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.unsnoozed", payload: { threadId: command.threadId, reason: "activity", updatedAt: command.createdAt, }, }); } return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.turn-interrupt-requested", payload: { threadId: command.threadId, ...(command.turnId !== undefined ? { turnId: command.turnId } : {}), createdAt: command.createdAt, }, }; } case "thread.approval.respond": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, metadata: { requestId: command.requestId, }, })), type: "thread.approval-response-requested", payload: { threadId: command.threadId, requestId: command.requestId, decision: command.decision, createdAt: command.createdAt, }, }; } case "thread.user-input.respond": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const request = userInputActivity; const attachments = Object.values(command.attachmentsByQuestionId ?? {}).flat(); let questionTextById: Record = {}; if (attachments.length > 0) { const payload = request?.kind === "user-input.requested" ? decodeUserInputRequestedPayload(request.payload) : Option.none(); if (Option.isNone(payload)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: request?.kind === "user-input.resolved" ? "This question has already been answered." : "This question is no longer pending.", }); } questionTextById = Object.fromEntries( payload.value.questions.map((question) => [question.id, question.question]), ); for (const questionId of Object.keys(command.attachmentsByQuestionId ?? {})) { const question = payload.value.questions.find((question) => question.id === questionId); if (!question || question.allowCustomAnswer === false) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "This question does not accept file references.", }); } } } if ( request && Predicate.isObject(request.payload) && request.payload.responseMode === "message" ) { const payload = decodeUserInputRequestedPayload(request.payload); if (request.kind !== "user-input.requested" || Option.isNone(payload)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "This question has already been answered.", }); } const replies: string[] = []; for (const question of payload.value.questions) { const answer = command.answers[question.id]; if ( typeof answer !== "string" || (answer.trim().length === 0 && !command.attachmentsByQuestionId?.[question.id]?.length) ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "Answer each question before sending.", }); } const questionAttachments = command.attachmentsByQuestionId?.[question.id] ?? []; const attachmentLabels = questionAttachments .map((attachment) => `Attached file: ${attachment.name} (${attachment.id})`) .join("\n"); replies.push( [`${question.question}\n${answer.trim()}`, attachmentLabels].filter(Boolean).join("\n"), ); } // Commit the answer and its message together. The normal turn path // steers a running agent or resumes an idle session. return yield* decideCommandSequence({ readModel, commands: [ { type: "thread.activity.append", commandId: command.commandId, threadId: command.threadId, createdAt: command.createdAt, activity: { id: EventId.make(`async-answer:${command.requestId}`), kind: "user-input.resolved", summary: "User input submitted", tone: "info", turnId: request.turnId, createdAt: command.createdAt, payload: { requestId: command.requestId, responseMode: "message", answers: command.answers, ...(command.attachmentsByQuestionId ? { attachmentsByQuestionId: command.attachmentsByQuestionId } : {}), }, }, }, { type: "thread.turn.start", commandId: command.commandId, threadId: command.threadId, createdAt: command.createdAt, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, message: { messageId: MessageId.make(`async-answer:${command.requestId}`), role: "user", text: replies.join("\n\n"), attachments, }, }, ], }); } const responseEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, metadata: { requestId: command.requestId }, })), type: "thread.user-input-response-requested" as const, payload: { threadId: command.threadId, requestId: command.requestId, answers: command.answers, ...(command.attachmentsByQuestionId ? { attachmentsByQuestionId: command.attachmentsByQuestionId } : {}), createdAt: command.createdAt, }, }; if (attachments.length === 0) return responseEvent; const historyEvent = yield* decideOrchestrationCommand({ readModel, command: { type: "thread.activity.append", commandId: command.commandId, threadId: command.threadId, createdAt: command.createdAt, activity: { id: EventId.make(`question-answer:${command.commandId}`), kind: "user-input.answer-submitted", summary: "Question answer submitted", tone: "info", turnId: request?.turnId ?? null, createdAt: command.createdAt, payload: { requestId: command.requestId, answers: command.answers, questionTextById, attachmentsByQuestionId: command.attachmentsByQuestionId, detail: attachments.map((attachment) => attachment.name).join("\n"), }, }, }, }); return [...(Array.isArray(historyEvent) ? historyEvent : [historyEvent]), responseEvent]; } case "thread.user-input.dismiss": { yield* requireThread({ readModel, command, threadId: command.threadId, }); const request = userInputActivity; if (request === undefined || request.kind !== "user-input.requested") { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "This question has already been answered.", }); } // Only async questions can be dropped silently. A native callback // question leaves the provider blocked until it gets a reply, so it // still needs an answer or an interrupted turn. if (!Predicate.isObject(request.payload) || request.payload.responseMode !== "message") { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "This question needs an answer. Answer it or stop the turn.", }); } return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.activity-appended", payload: { threadId: command.threadId, activity: { id: EventId.make(`async-dismiss:${command.requestId}`), kind: "user-input.resolved", summary: "User input dismissed", tone: "info", turnId: request.turnId, createdAt: command.createdAt, payload: { requestId: command.requestId, responseMode: "message" }, }, }, }; } case "thread.checkpoint.revert": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.checkpoint-revert-requested", payload: { threadId: command.threadId, turnCount: command.turnCount, createdAt: command.createdAt, }, }; } case "thread.session.stop": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); // Settle-cleanup stops are conditional: between the settle landing and // this command, another client may have re-engaged the thread (a turn // start unsettles it and brings the session alive). Commands are // decided serially against this read model, so checking here — not in // the dispatcher's pre-settle snapshot — closes that race. if (command.onlyIfSettled === true) { const sessionComingAlive = thread.session?.status === "starting" || thread.session?.status === "running"; if ( thread.settledOverride !== "settled" || sessionComingAlive || hasQueuedTurnStartForThread(thread, command.createdAt) ) { return yield* Effect.fail( new OrchestrationCommandInvariantError({ commandType: command.type, detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, }), ); } } return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.session-stop-requested", payload: { threadId: command.threadId, createdAt: command.createdAt, }, }; } case "thread.session.set": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, metadata: {}, })), type: "thread.session-set", payload: { threadId: command.threadId, session: command.session, }, }; // Only a session coming alive is activity worth waking a settled thread // for — status writes like ready/stopped/error arrive after the fact and // must not fight a user's explicit settle. Snooze is deliberately NOT // cleared here: snooze never pauses the agent, so its session starting // or erroring is not the user re-engaging. Blocked/failed work still // surfaces immediately — effectiveSnoozed refuses to classify a thread // with a raised hand (approval / input / failure / fresh completion) // as snoozed, without spending the return ticket. const isSessionActivity = command.session.status === "starting" || command.session.status === "running"; // Real activity resets ANY override (settled wakes, active unpins). if (thread.settledOverride === null || !isSessionActivity) { return sessionSetEvent; } const unsettledEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.unsettled", payload: { threadId: command.threadId, reason: "activity", updatedAt: command.createdAt, }, }; return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { if (isImportedAgentSessionMessageId(command.messageId)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, }); } yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.message-sent", payload: { threadId: command.threadId, messageId: command.messageId, role: "assistant", text: command.delta, turnId: command.turnId ?? null, streaming: true, createdAt: command.createdAt, updatedAt: command.createdAt, }, }; } case "thread.message.assistant.complete": { if (isImportedAgentSessionMessageId(command.messageId)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, }); } yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.message-sent", payload: { threadId: command.threadId, messageId: command.messageId, role: "assistant", text: "", turnId: command.turnId ?? null, streaming: false, createdAt: command.createdAt, updatedAt: command.createdAt, }, }; } case "thread.history.import": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); if ( thread.deletedAt !== null || thread.archivedAt !== null || thread.messages.length > 0 || thread.latestTurn !== null || thread.session !== null || openRequests(thread).size > 0 ) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, }); } const firstMessage = command.messages[0]; if (firstMessage === undefined) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, detail: "Thread history imports require at least one message.", }); } const events: Array = []; for (const message of command.messages) { events.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: message.createdAt, commandId: command.commandId, metadata: { historyImport: true }, })), type: "thread.message-sent", payload: { threadId: command.threadId, messageId: message.messageId, role: message.role, text: message.text, turnId: null, streaming: false, createdAt: message.createdAt, updatedAt: message.createdAt, }, }); } const settledAt = command.messages.reduce( (latest, message) => compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, firstMessage.createdAt, ); events.push({ ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: settledAt, commandId: command.commandId, metadata: { historyImport: true }, })), type: "thread.settled", payload: { threadId: command.threadId, settledAt, updatedAt: settledAt, }, }); return events; } case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.proposed-plan-upserted", payload: { threadId: command.threadId, proposedPlan: command.proposedPlan, }, }; } case "thread.turn.diff.complete": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.turn-diff-completed", payload: { threadId: command.threadId, turnId: command.turnId, checkpointTurnCount: command.checkpointTurnCount, checkpointRef: command.checkpointRef, status: command.status, files: command.files, assistantMessageId: command.assistantMessageId ?? null, completedAt: command.completedAt, }, }; } case "thread.revert.complete": { yield* requireThread({ readModel, command, threadId: command.threadId, }); return { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.reverted", payload: { threadId: command.threadId, turnCount: command.turnCount, }, }; } case "thread.activity.append": { const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); const requestId = typeof command.activity.payload === "object" && command.activity.payload !== null && "requestId" in command.activity.payload && typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, ...(requestId !== undefined ? { metadata: { requestId } } : {}), })), type: "thread.activity-appended", payload: { threadId: command.threadId, activity: command.activity, }, }; // An approval or user-input request is blocked-on-you work — it must // never stay hidden inside a settled slim row. const wakesSettledThread = command.activity.kind === "approval.requested" || command.activity.kind === "user-input.requested"; // Real activity resets ANY override (settled wakes, active unpins). if (thread.settledOverride === null || !wakesSettledThread) { return activityAppendedEvent; } const unsettledEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, })), type: "thread.unsettled", payload: { threadId: command.threadId, reason: "activity", updatedAt: command.createdAt, }, }; return [unsettledEvent, activityAppendedEvent]; } default: { command satisfies never; const fallback = command as never as { type: string }; return yield* new OrchestrationCommandInvariantError({ commandType: fallback.type, detail: `Unknown command type: ${fallback.type}`, }); } } });