Files
PaperClipAI/server/src/services/execution-projection.ts
T
DottaandClaude Opus 5.5 edd00526e7 fix(native): park a response_wake with no admitted response instead of self-waking (#20935)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A native agent run ends with a structured result. The status arbiter
turns that result into an issue status and next actions.
> - A result can report `yielded` with `continuation.kind:
"response_wake"`. This means "wake me when somebody responds".
> - Only a direct Board comment gave this kind a passive wait. Every
other `response_wake` became an immediate `issue_status_changed` wake to
the same agent.
> - The agent woke, saw nothing new, reported `response_wake` again, and
woke again. Nothing bounded this loop. It spent provider budget and its
back-to-back runs caused other failures on warm sandboxes.
> - This pull request parks such a result as a visible wait, and puts
declared continuation wakes under the rewake throttle as a backstop.
> - The benefit is that an agent that waits for input stops and costs
nothing until a real response arrives.

## Linked Issues or Issue Description

**What happened?**

Restart-chaos waves on master `683fd1e9a5` (no faults injected) showed
one task run 30 to 66 times, about 12 s apart. Example: wave
`w-20261111T190352-s5101`, task t02 (W2C), 65 runs in 13 minutes. Each
run reported `yielded` with `continuation.kind: "response_wake"` and the
summary "No changes made. This status-change wake does not trigger phase
2". Each run was woken by `issue_status_changed`, and each finalization
committed `live_continuation_registered` with an `enqueue_continuation`
effect of kind `response_wake`.

Root cause: `arbitrateNativeStatus()`
(`server/src/services/native-runtime/status-arbiter.ts`) only treats
`response_wake` as passive when `boardResponseWaitAuthorized` (from
`readNativeBoardResponseWaitSource`), external chat, or plan wait is
true. Otherwise it falls to the generic yielded branch, and
`status-decision-committer.ts` (`enqueue_continuation`) enqueues an
`issue_status_changed` wake to the same agent. `issue_status_changed` is
not in `THROTTLED_ISSUE_REWAKE_REASONS`, and the native committer writes
an `issue.updated` activity row with the run's id for every decision, so
the rewake throttle saw every native run as progress.

**Expected behavior**

A `response_wake` with no admitted response source parks the task. A
comment, an interaction answer or a status change by somebody else wakes
the agent. No path can loop without a bound.

**Steps to reproduce**

Run `tests/restart-chaos/main.ts --env local --adapters codex
--workloads W2,W2Q,W2C --concurrency 10 --tasks 10 --seed 5101
--lifecycle warm --triggers time`. W2/W2C tasks loop after phase 1.

## What Changed

- `status-arbiter.ts`: a `yielded` / `response_wake` result with no
admitted response source and no blocking remaining work now commits
`response_wake_waiting_for_input`, keeps the task `in_progress`, and has
no effects. Policy version is `phase6-v14`. Board, external chat, plan,
governance, dependency, pause and unfinished-work branches are unchanged
and still come first.
- `native-runtime/native-response-wake-wait.ts` (new) and
`recovery/service.ts`: recovery treats that applied decision as a
durable wait while it is the issue's latest decision, the issue is
`in_progress` with the same agent assignee, and no user comment arrived
after the run started. This applies to the stranded-work sweep and to
the paused-agent passive-wait check.
- `execution-projection.ts`: a committed run with this reason shows the
phase `waiting_for_answer`, label "Waiting for a reply", and a next
action that tells the user to comment or change the status. This shows
only while that decision is still the issue's latest decision and the
issue is `in_progress`; an older run shows "Completed".
- Backstop in `issue-rewake-throttle.ts` and `heartbeat/queue.ts`: an
`issue_status_changed` wake that carries a declared `continuationKind`
(the `enqueue_continuation` effect) is now a throttle candidate. The
native status committer's own `issue.updated` / status-decision activity
no longer counts as run progress or as new input. After two consecutive
successful runs with no issue-visible progress, such a wake waits for
the existing escalating cooldown (2 min, doubling, max 30 min). The
native status dispatcher (`dispatchNativeStatusWakeBatch`) does not drop
a throttled intent: it keeps the intent `queued`, reports the cooldown
deadline to the deferred-receipt worker, and dispatches it again when
the cooldown ends. Status wakes without a declared continuation (blocker
owner, notify owner, retry) are unchanged.
- Restart chaos: new enforced universal check U5 (`universal.ts`). A
task with more than 10 runs fails as `U5:run_budget_exceeded`
(`RESTART_CHAOS_U5_MAX_RUNS`). The README records the calibration.
- `doc/architecture/native-status-arbitration.md`: the decision table
and the wait rules.

## Verification

- RED on master (same reason as the waves):
`heartbeat-process-recovery.test.ts` "parks a native response_wake that
no response admitted…" fails with decision `reasonCode:
"live_continuation_registered"` and effect `{ kind:
"enqueue_continuation", continuationKind: "response_wake" }`.
`heartbeat-issue-rewake-throttle.test.ts` "throttles a
same_agent/response_wake/retry continuation…" fails 3/3 because the wake
is admitted.
- GREEN: the integration test finalizes the run, dispatches native
status wakes and runs the stranded-work sweep three times. It asserts
one run total, one wake request, no recovery action, the task
`in_progress`, and the projection "Waiting for a reply".
- `heartbeat-process-recovery.test.ts` "delays a no-progress native
continuation until the rewake cooldown…" finalizes a real `same_agent`
continuation after a no-progress run, dispatches twice (one throttle
receipt, intent stays `queued`, deadline equals `nextAllowedAt`), then
dispatches after the cooldown and gets the run. It fails when the
dispatcher drops the throttled intent.
- Focused suites pass: `heartbeat-process-recovery`,
`heartbeat-issue-rewake-throttle`, `status-arbiter`,
`native-status-arbiter-corpus`, `runtime-mode`, `execution-projection`,
`chat-run-milestone-work`, `heartbeat-dependency-scheduling`,
`heartbeat-run-summary` (904 tests), and all restart-chaos unit tests
(584 tests). `pnpm --filter @paperclipai/server typecheck` passes.
- Universal audit over the 17 saved waves of 2026-11-11: U5 flags 12
tasks, all looping tasks (12 to 66 runs), and 0 tasks on all-pass waves.
- Chaos rerun of local warm seed 5101 (W2, W2Q, W2C): result is added in
a comment on this PR.

## Risks

- Behavioral shift: a model that reports `response_wake` to mean
"continue now" no longer gets an immediate run. The task waits visibly
for a reply. The runner prompt already describes `response_wake` as a
real response wait, and `same_agent` stays available for server-owned
continuations.
- The throttle now applies to native runs. A chain of declared
continuations with no issue-visible output (no comment, document, work
product or other activity) waits for the cooldown after the second such
run. `same_agent` continuations are server-owned (repository
preparation), so this affects only repeated no-output chains.
- Old saved state: decisions committed under `phase6-v13` keep their
recorded effects. A run that finalizes after deploy uses the new policy.
No migration.
- Budget hard-stop behavior is unchanged.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, test runs).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:40:44 -06:00

549 lines
26 KiB
TypeScript

import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import {
agents,
heartbeatRuns,
issues,
dotAgentBindings,
dotRunnerAssignments,
museAgentBindings,
museRunnerAssignments,
issueRecoveryActions,
issueThreadInteractions,
nativeRunFinalizations,
type Db,
} from "@paperclipai/db";
import type { ExecutionProjection } from "@paperclipai/shared";
import { EXECUTION_CONTROL_DEADLINE_MS } from "./execution-control-deadline.js";
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
import { workspaceRecoveryDisplay } from "./workspace-recovery-receipt.js";
import { readTaskDrain } from "./task-admission.js";
import { getStartupRecoveryState } from "../startup-recovery-state.js";
import { hasPendingRunCleanup } from "./execution-blocker.js";
import { isAgentAwaitingSetup } from "../modules/agent-lifecycle/index.js";
import { NATIVE_RESPONSE_WAKE_WAITING_REASON } from "./native-runtime/status-arbiter.js";
const MAX_PREDECESSOR_CLEANUP_CHECKS = 16;
const text = (v: unknown) => (typeof v === "string" ? v : null);
const executionRunColumns = {
id: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
driverKind: heartbeatRuns.driverKind,
errorCode: heartbeatRuns.errorCode,
executionControlDeadlineAt: heartbeatRuns.executionControlDeadlineAt,
finishedAt: heartbeatRuns.finishedAt,
lastOutputAt: heartbeatRuns.lastOutputAt,
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
nativeIssueId: heartbeatRuns.nativeIssueId,
nextAction: heartbeatRuns.nextAction,
processPid: heartbeatRuns.processPid,
retryOfRunId: heartbeatRuns.retryOfRunId,
runtimeMode: heartbeatRuns.runtimeMode,
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
startedAt: heartbeatRuns.startedAt,
status: heartbeatRuns.status,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object(
'issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
'wakeReason', ${heartbeatRuns.contextSnapshot}->'wakeReason',
'failureRetriesBeforeAiConnectionWait', ${heartbeatRuns.contextSnapshot}->'failureRetriesBeforeAiConnectionWait',
'failureRetriesBeforeWorkspaceWait', ${heartbeatRuns.contextSnapshot}->'failureRetriesBeforeWorkspaceWait',
'executionRetryAccounting', ${heartbeatRuns.contextSnapshot}->'executionRetryAccounting')`,
resultJson: sql<Record<string, unknown>>`jsonb_build_object(
'finalizationPhase', ${heartbeatRuns.resultJson}->'finalizationPhase',
'finalizationReasonCode', ${heartbeatRuns.resultJson}->'finalizationReasonCode')`,
};
type Run = Pick<typeof heartbeatRuns.$inferSelect, keyof typeof executionRunColumns>;
type Coordinator = typeof nativeRunFinalizations.$inferSelect;
type WaitingExternalProvider = "muse" | "openai_dot";
type Recovery = Pick<
typeof issueRecoveryActions.$inferSelect,
"cause" | "nextAction"
> &
Partial<
Pick<typeof issueRecoveryActions.$inferSelect, "status" | "evidence">
>;
/** Batched reads; list consumers do not perform per-task polling. */
export async function executionProjectionsForRuns(
db: Db,
companyId: string,
runIds: string[],
now = new Date(),
options: { retryDatabaseReads?: boolean } = {},
) {
const projections = new Map<string, ExecutionProjection>();
if (!runIds.length) return projections;
// Opt in only with a pooled Db outside a transaction. Issue enrichment also
// calls this helper with a transaction, whose failed reads must not replay.
const read = <T>(query: () => Promise<T>) => options.retryDatabaseReads
? retryIdempotentDatabaseOperation(query)
: query();
const runs = await read(async () => db
.select(executionRunColumns)
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.id, runIds),
),
));
const coordinators = await read(async () => db
.select()
.from(nativeRunFinalizations)
.where(
and(
eq(nativeRunFinalizations.companyId, companyId),
inArray(nativeRunFinalizations.runId, runIds),
),
));
const issueIds = [
...new Set(
runs
.map((run) => run.nativeIssueId ?? text(run.contextSnapshot?.issueId))
.filter((id): id is string => !!id),
),
];
const pending = issueIds.length
? await read(async () => db
.select({
issueId: issueThreadInteractions.issueId,
kind: issueThreadInteractions.kind,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
inArray(issueThreadInteractions.issueId, issueIds),
eq(issueThreadInteractions.status, "pending"),
),
))
: [];
const recovery = issueIds.length
? await read(async () => db
.select({
issueId: issueRecoveryActions.sourceIssueId,
cause: issueRecoveryActions.cause,
nextAction: issueRecoveryActions.nextAction,
evidence: issueRecoveryActions.evidence,
status: issueRecoveryActions.status,
})
.from(issueRecoveryActions)
.where(
and(
eq(issueRecoveryActions.companyId, companyId),
inArray(issueRecoveryActions.sourceIssueId, issueIds),
inArray(issueRecoveryActions.status, [
"active",
"escalated",
"resolved",
]),
),
)
.orderBy(desc(issueRecoveryActions.updatedAt)))
: [];
// A live controller is not evidence that an external agent accepted work.
// Read each provider journal once for the batch, never once per task.
const waitingByRun = new Map<string, WaitingExternalProvider>();
const externalRunIds = (driverKind: string) => runs
.filter(run => run.status === "running" && run.runtimeMode === "native" && run.driverKind === driverKind)
.map(run => run.id);
const museRunIds = externalRunIds("muse_external");
if (museRunIds.length) {
const offered = await read(async () => db
.select({ runId: museRunnerAssignments.runId })
.from(museRunnerAssignments)
.innerJoin(museAgentBindings, and(
eq(museAgentBindings.id, museRunnerAssignments.bindingId),
eq(museAgentBindings.companyId, museRunnerAssignments.companyId),
eq(museAgentBindings.agentId, museRunnerAssignments.agentId),
eq(museAgentBindings.generation, museRunnerAssignments.bindingGeneration),
isNull(museAgentBindings.revokedAt),
))
.where(and(
eq(museRunnerAssignments.companyId, companyId),
inArray(museRunnerAssignments.runId, museRunIds),
inArray(museRunnerAssignments.status, ["offered", "claimed"]),
isNull(museRunnerAssignments.nativeAcceptedAt),
)));
for (const row of offered) waitingByRun.set(row.runId, "muse");
}
const dotRunIds = externalRunIds("openai_dot_mcp");
if (dotRunIds.length) {
const offered = await read(async () => db
.select({ runId: dotRunnerAssignments.runId })
.from(dotRunnerAssignments)
.innerJoin(dotAgentBindings, and(
eq(dotAgentBindings.id, dotRunnerAssignments.bindingId),
eq(dotAgentBindings.companyId, dotRunnerAssignments.companyId),
eq(dotAgentBindings.agentId, dotRunnerAssignments.agentId),
eq(dotAgentBindings.generation, dotRunnerAssignments.bindingGeneration),
isNull(dotAgentBindings.revokedAt),
))
.where(and(
eq(dotRunnerAssignments.companyId, companyId),
inArray(dotRunnerAssignments.runId, dotRunIds),
eq(dotRunnerAssignments.status, "offered"),
isNull(dotRunnerAssignments.acceptedAt),
)));
for (const row of offered) waitingByRun.set(row.runId, "openai_dot");
}
// A queued turn cannot start while its predecessor's cleanup is pending
// (for example a stopped run whose runner has not released yet). Name that
// wait instead of an unexplained "Queued". The predecessors are the ones
// admission checks: the task's execution owner, the run this one retries,
// and the agent's latest settled run on the same task, whose in-process
// executor the claim also waits for. Pending uses admission's own predicate.
const settlingPredecessorKeys = new Set<string>();
const queuedRuns = runs.filter(run => run.status === "queued");
const setupAgentIds = [...new Set(queuedRuns.filter(run => !run.startedAt).map(run => run.agentId))];
const setupAgents = setupAgentIds.length ? await read(async () => db
.select({ id: agents.id, lifecycleState: agents.lifecycleState,
lifecycleHolds: agents.lifecycleHolds,
retryRequired: sql<boolean>`coalesce(${agents.lifecycleOperation}->>'retryRequired', 'false') = 'true'` })
.from(agents).where(and(eq(agents.companyId, companyId), inArray(agents.id, setupAgentIds)))) : [];
const setupByAgent = new Map(setupAgents.map(agent => [agent.id, agent]));
const issueIdOf = (run: Run) => run.nativeIssueId ?? text(run.contextSnapshot?.issueId);
const queuedIssueIds = [...new Set(queuedRuns.map(issueIdOf).filter((id): id is string => !!id))];
if (queuedIssueIds.length) {
const issueKey = sql<string>`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId')`;
// Two set-based reads for the whole batch, then one bounded, parallel
// admission check per distinct predecessor.
const [owners, latestSettled] = await Promise.all([
read(async () => db
.select({ id: issues.id, executionRunId: issues.executionRunId })
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, queuedIssueIds)))),
read(async () => db
.selectDistinctOn([heartbeatRuns.agentId, issueKey], { id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, issueId: issueKey })
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.agentId, [...new Set(queuedRuns.map(run => run.agentId))]),
inArray(issueKey, queuedIssueIds),
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out"]),
))
.orderBy(heartbeatRuns.agentId, issueKey, desc(heartbeatRuns.createdAt))),
]);
const ownerByIssue = new Map(owners.map(row => [row.id, row.executionRunId]));
const latestByAgentIssue = new Map(latestSettled.map(row => [`${row.agentId}:${row.issueId}`, row.id]));
const candidatesByRun = new Map<string, string[]>();
for (const run of queuedRuns) {
const issueId = issueIdOf(run);
if (!issueId) continue;
candidatesByRun.set(run.id, [...new Set([ownerByIssue.get(issueId), run.retryOfRunId,
latestByAgentIssue.get(`${run.agentId}:${issueId}`)].filter((id): id is string => !!id && id !== run.id))]);
}
const distinct = [...new Set([...candidatesByRun.values()].flat())];
// A label is not worth an unbounded fan-out on large list reads.
if (distinct.length <= MAX_PREDECESSOR_CLEANUP_CHECKS) {
const pendingIds = new Set((await Promise.all(distinct.map(async id =>
(await read(() => hasPendingRunCleanup(db, companyId, id))) ? id : null))).filter((id): id is string => !!id));
for (const [runId, candidates] of candidatesByRun)
if (candidates.some(id => pendingIds.has(id))) settlingPredecessorKeys.add(runId);
}
}
const coordinatorByRun = new Map(coordinators.map((row) => [row.runId, row]));
const responseWaitRuns = runs.filter(run => run.status === "succeeded"
&& run.resultJson?.finalizationReasonCode === NATIVE_RESPONSE_WAKE_WAITING_REASON && issueIdOf(run));
const responseWaitIssues = responseWaitRuns.length ? await read(async () => db
.select({ id: issues.id, status: issues.status, lastStatusDecisionId: issues.lastStatusDecisionId })
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, [...new Set(responseWaitRuns.map(run => issueIdOf(run)!))])))) : [];
const responseWaitIssueById = new Map(responseWaitIssues.map(row => [row.id, row]));
const currentResponseWait = (run: Run) => {
const issue = responseWaitIssueById.get(issueIdOf(run) ?? "");
const decisionId = coordinatorByRun.get(run.id)?.decisionId;
return Boolean(issue && decisionId && issue.status === "in_progress" && issue.lastStatusDecisionId === decisionId);
};
for (const run of runs) {
const issueId = run.nativeIssueId ?? text(run.contextSnapshot?.issueId);
const matching = recovery.filter(
(row) =>
row.issueId === issueId &&
(row.status !== "resolved" || row.evidence.executionReconciliation || row.evidence.automaticRecovery) &&
(row.evidence.runId === run.id || row.evidence.sourceRunId === run.id),
);
const action =
matching.find((row) => row.status !== "resolved") ?? matching[0];
projections.set(
run.id,
projectExecution(
run,
coordinatorByRun.get(run.id),
pending.filter((row) => row.issueId === issueId),
action,
now,
waitingByRun.get(run.id),
settlingPredecessorKeys.has(run.id),
setupByAgent.get(run.agentId),
currentResponseWait(run),
),
);
}
return projections;
}
export async function executionProjectionForRun(
db: Db,
companyId: string,
runId: string,
now = new Date(),
): Promise<ExecutionProjection | null> {
return (
(await executionProjectionsForRuns(db, companyId, [runId], now)).get(
runId,
) ?? null
);
}
export function projectExecution(
run: Run,
coordinator: Coordinator | undefined,
pending: Array<{ kind: string }>,
recoveryAction: Recovery | undefined,
now = new Date(),
waitingForProvider?: WaitingExternalProvider,
waitingForPredecessor = false,
setupAgent?: { lifecycleState: string; lifecycleHolds: readonly string[]; retryRequired?: boolean },
responseWaitCurrent = false,
): ExecutionProjection {
const detail = coordinator?.failureDetail ?? {};
const successorRunId = text(detail.successorRunId);
const projection: ExecutionProjection = {
// A live run is always stoppable by the board (POST /heartbeat-runs/:id/cancel);
// a failed native run is stoppable only while its retry is still pending.
canStop: run.status === "running" || run.status === "queued" || (
run.runtimeMode === "native" && run.status === "failed" && run.driverKind !== "grok_bot_mcp" && !!run.nativeIssueId
&& coordinator?.runId === run.id && coordinator.issueId === run.nativeIssueId && coordinator.resultId === null
&& coordinator.phase === "retryable_failure"),
phase: "queued",
label: "Queued",
cause: run.errorCode ?? coordinator?.failureCode ?? null,
lastConfirmedActivityAt:
(
run.lastUsefulActionAt ??
run.lastOutputAt ??
run.startedAt
)?.toISOString() ?? null,
retryAt:
(coordinator?.nextAttemptAt ?? run.scheduledRetryAt)?.toISOString() ??
null,
attempt: coordinator?.attempt ?? executionFailureRetryCount(run) + 1,
maxAttempts: 3,
recoveryOwner: null,
nextAction: text(detail.nextAction) ?? run.nextAction,
permittedActions: ["inspect_run"],
predecessorRunId: run.retryOfRunId,
successorRunId,
};
const set = (phase: ExecutionProjection["phase"], label: string) =>
Object.assign(projection, { phase, label });
if (recoveryAction?.status === "resolved" && recoveryAction.evidence?.automaticRecovery) {
projection.cause = recoveryAction.cause;
projection.nextAction = workspaceRecoveryDisplay(recoveryAction.evidence, run.id)?.message
?? recoveryAction.nextAction;
const continuation = recoveryAction.evidence.explicitUserContinuation as
{ previousRunId?: unknown; runId?: unknown } | undefined;
const explicitSuccessor = text(continuation?.runId);
if (continuation?.previousRunId === run.id && explicitSuccessor && explicitSuccessor !== run.id) {
// The admission transaction already recorded the user's successor. Keep
// the old failure diagnostic without making it hold the newer attempt.
projection.successorRunId = explicitSuccessor;
projection.nextAction = null;
return set("completed", "Continued in another run");
}
// Diagnostic projection only: no user decision or replay affordance.
return set("recovery_needed", "Stopped");
}
if (
recoveryAction?.status === "resolved" &&
recoveryAction.evidence?.executionReconciliation
) {
if (recoveryAction.evidence.continuationDelivery === "invalidated") {
projection.cause = "continuation_ownership_changed";
projection.nextAction =
"The task was closed or reassigned before delivery. Inspect the current task owner and status.";
return set("failed", "Continuation cancelled");
}
const reconciliation = recoveryAction.evidence.executionReconciliation;
if (recoveryAction.cause === "native_restart_recovery_blocked"
&& recoveryAction.evidence.reason === "controller_process_space_unverified"
&& recoveryAction.evidence.continuationDelivery === "not_requested"
&& reconciliation && typeof reconciliation === "object" && !Array.isArray(reconciliation)
&& "runId" in reconciliation && reconciliation.runId === run.id
&& "controllerStopped" in reconciliation && reconciliation.controllerStopped === true
&& "providerStopped" in reconciliation && reconciliation.providerStopped === true
&& "controllerAuthorityDigest" in reconciliation && typeof reconciliation.controllerAuthorityDigest === "string" && /^[0-9a-f]{64}$/.test(reconciliation.controllerAuthorityDigest)) {
const continuation = recoveryAction.evidence.explicitUserContinuation as
{ previousRunId?: unknown; runId?: unknown } | undefined;
const explicitSuccessor = text(continuation?.runId);
if (continuation?.previousRunId === run.id && explicitSuccessor && explicitSuccessor !== run.id) {
projection.successorRunId = explicitSuccessor;
projection.nextAction = null;
return set("completed", "Continued in another run");
}
projection.cause = null;
projection.nextAction = "The original execution was contained and its action outcomes reviewed. Use Retry to request another attempt.";
return set("failed", "Execution contained");
}
const deliveredRunId = text(recoveryAction.evidence.continuationRunId);
projection.successorRunId = deliveredRunId;
projection.cause = null;
projection.nextAction = deliveredRunId
? null
: "The previous execution is reconciled. Its continuation is queued for delivery.";
return set(
deliveredRunId ? "completed" : "queued",
deliveredRunId ? "Continued in another run" : "Continuation queued",
);
}
if (
(run.status === "running" &&
(run.executionControlDeadlineAt || coordinator?.controlDeadlineAt)) ||
[
"result_persisted",
"ready_for_assessment",
"arbitrating",
"workspace_pending",
].includes(coordinator?.phase ?? "")
)
return set("finishing", "Finishing");
if (successorRunId) return set("completed", "Continued in another run");
if (run.status === "scheduled_retry" && run.scheduledRetryReason === "ai_connection_busy") {
projection.nextAction = "Waiting for the AI subscription's current execution to finish; the scheduled check will revalidate access.";
return set("retry_scheduled", "Waiting for AI subscription");
}
if (run.status === "scheduled_retry" && run.scheduledRetryReason === "ai_connection_pool_wait"
&& run.contextSnapshot?.wakeReason === "provider_limit_retry") {
const resetAt = run.scheduledRetryAt ? new Date(run.scheduledRetryAt).toISOString() : "its reported reset";
projection.nextAction = `Waiting for the agent's provider usage limit to reset; the work starts at ${resetAt}. To continue sooner, restore the provider allowance or change the agent's AI connection, then start a manual run.`;
return set("retry_scheduled", "Waiting for usage limit reset");
}
if (run.status === "scheduled_retry" && run.scheduledRetryReason === "agent_directory_busy") {
projection.nextAction = "Waiting for another run of this agent to finish with its files; this task continues automatically.";
return set("retry_scheduled", "Waiting for agent files");
}
if (run.status === "scheduled_retry" && run.scheduledRetryReason === "workspace_busy") {
projection.nextAction = "Waiting for the live workspace holder to finish; the scheduled check will revalidate ownership.";
return set("retry_scheduled", "Waiting for workspace");
}
// Cleanup can fail before a finalization coordinator exists. The missing
// row must not turn a quarantined native session into an ordinary Retry.
const cleanupQuarantined = run.runtimeMode === "native" &&
["failed", "timed_out"].includes(run.status) &&
run.errorCode === "native_session_cleanup_quarantined";
if (!cleanupQuarantined && (
coordinator?.phase === "retryable_failure" ||
run.status === "scheduled_retry"
)) {
projection.recoveryOwner = "agent";
if (coordinator?.failureCode === "native_governed_wait_settlement_pending") {
projection.nextAction = "The work and waiting state are saved. Paperclip is confirming shutdown before continuing.";
return set("finishing", "Finishing shutdown");
}
return set(
projection.retryAt && new Date(projection.retryAt) > now
? "retry_scheduled"
: "reconnecting",
projection.retryAt && new Date(projection.retryAt) > now
? "Retry scheduled"
: "Reconnecting",
);
}
if (coordinator?.phase === "terminal_failure" || recoveryAction || cleanupQuarantined) {
if (
!cleanupQuarantined &&
coordinator?.failureCode === "native_provider_terminal_failed" &&
!detail.replacementDenied &&
run.finishedAt &&
now.getTime() - run.finishedAt.getTime() < EXECUTION_CONTROL_DEADLINE_MS
) {
projection.recoveryOwner = "agent";
projection.nextAction =
"Checking that the previous provider stopped and its action outcomes are known before continuing.";
return set("reconnecting", "Checking recovery");
}
projection.recoveryOwner = "board";
projection.cause =
recoveryAction?.cause ??
text(detail.replacementDenied) ??
projection.cause;
projection.nextAction = recoveryAction?.nextAction ?? projection.nextAction;
if (cleanupQuarantined && !projection.nextAction) {
projection.nextAction = "Verify the stopped session and its saved work before starting a new attempt.";
}
projection.permittedActions.push("inspect_recovery");
return set("recovery_needed", "Recovery needed");
}
if (run.status === "succeeded") {
if (pending.length)
return set(
pending.some((row) => row.kind === "connection_intent")
? "waiting_for_access"
: "waiting_for_answer",
pending.some((row) => row.kind === "connection_intent")
? "Waiting for access"
: "Waiting for answer",
);
if (responseWaitCurrent && run.resultJson?.finalizationPhase === "committed" &&
run.resultJson.finalizationReasonCode === NATIVE_RESPONSE_WAKE_WAITING_REASON) {
projection.nextAction = "The agent finished its turn and is waiting for a reply. Comment on the task or change its status to continue.";
return set("waiting_for_answer", "Waiting for a reply");
}
return set("completed", "Completed");
}
if (["failed", "cancelled", "timed_out", "interrupted"].includes(run.status))
return set("failed", run.status === "cancelled" ? "Cancelled" : "Failed");
if (run.status === "running") {
const leaseConfirmed =
coordinator?.phase === "observed" &&
coordinator.leaseExpiresAt &&
coordinator.leaseExpiresAt > now;
let processConfirmed = false;
if (run.runtimeMode === "legacy" && run.processPid) {
try {
process.kill(run.processPid, 0);
processConfirmed = true;
} catch {
/* No execution confirmation. */
}
}
if (leaseConfirmed && waitingForProvider) {
projection.nextAction = "The assignment is offered. Waiting for the external agent to accept it.";
return set("waiting_for_provider", `Waiting for ${waitingForProvider === "muse" ? "Muse" : "Dot"}`);
}
return leaseConfirmed || processConfirmed
? set("working", "Working")
: set("reconnecting", "Confirming execution");
}
if (run.status === "queued" && !run.startedAt && setupAgent
&& isAgentAwaitingSetup(setupAgent)) {
const needsRepair = setupAgent.retryRequired === true;
projection.cause = needsRepair ? "agent_setup_failed" : "agent_setup_pending";
projection.nextAction = needsRepair
? "Open the agent's Harness / Runtime settings to repair its configuration, then retry verification."
: "This turn starts automatically after the agent finishes setup and verification.";
return set("queued", needsRepair ? "Agent setup needs attention" : "Waiting for agent setup");
}
if (run.status === "queued" && !run.startedAt && readTaskDrain(now)) {
projection.cause = "task_drain";
projection.nextAction = "This turn will start automatically when maintenance finishes.";
return set("queued", "Waiting for maintenance");
}
// Process-local, like the drain: while this server starts, recovers, or
// shuts down, its full queued-run pass has not run (or no longer runs).
const serverPhase = getStartupRecoveryState().phase;
if (run.status === "queued" && serverPhase !== "ready") {
projection.cause = "server_restarting";
projection.nextAction = serverPhase === "draining"
? "The server is restarting for an update. This turn starts automatically on the replacement server."
: "The server restarted and is finishing recovery. This turn starts automatically when recovery completes.";
return set("queued", "Waiting for server restart");
}
if (run.status === "queued" && waitingForPredecessor) {
projection.cause = "previous_run_settling";
projection.nextAction = "The previous run on this task is still releasing its runner. This turn starts automatically when it settles.";
return set("queued", "Waiting for previous run to stop");
}
return projection;
}