Files
PaperClipAI/server/src/services/execution-projection.ts
T
DottaandClaude Opus 5.5 90b8190c33 fix(native-runtime): detect half-open runner ingress and project Stop for live runs (#19407)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs in a managed sandbox talk to the server through a
provider-ingress WebSocket. The server opens this socket to the runner
(`paperclip-runnerd`) through the sandbox preview proxy.
> - A staging run ran a 20-minute foreground shell command that printed
nothing. After the first second, no run events arrived. The run stayed
"Working" for over 35 minutes. No watchdog acted, and the run projection
said `canStop: false`.
> - The server only detected a lost runner connection from a `close` or
`error` event. It never sent pings. A half-open route, for example a
proxy that drops the connection without a FIN, never emits `close`. The
run then waits forever and the 7-minute reconnect grace never starts.
> - A long silent tool call is legitimate. The fix must tell "quiet but
alive" apart from "nothing alive". A protocol ping does that: runnerd
answers every ping with a pong while its provider waits on the tool.
> - This pull request pings authenticated runner connections and
terminates the socket after 90 seconds of total silence. The existing
reconnect grace and transport-failure path then recover the run. It also
makes the execution projection report `canStop: true` for every queued
or running run.
> - The benefit is that a run with a dead runner connection now ends in
a bounded time instead of hanging. API readers also see the Stop that
the board already offers.

## Linked Issues or Issue Description

**What happened?**
A native-runner run (managed sandbox, OpenCode with an OpenRouter model)
ran a foreground shell command for about 20 minutes with no output. The
last run event came at about T+1s. The command finished on disk, but the
run never got another event and stayed `running` / "Working" for over 35
minutes. Its projection had `canStop: false` and `permittedActions:
["inspect_run"]`. A board cancel through the API worked.

**Expected behavior**
The server detects a dead runner connection and recovers or fails the
run in a bounded time. A runner that is alive but quiet stays attached.
The projection reports that a live run can be stopped.

**Steps to reproduce**
1. Start a native run in a managed sandbox.
2. Make the agent run a long foreground command that prints nothing.
3. Let the preview route drop the WebSocket without a close frame.
4. Before this fix, the run stays "Working" forever.

## What Changed

- `server/src/realtime/runner-prp-outbound.ts`: after PRP
authentication, `WsJsonWireConnection.monitorLiveness` sends a WebSocket
ping every 20 s. Any inbound frame or pong counts as liveness. After 90
s with nothing inbound, it terminates the socket. That emits an ordinary
`close`, so the PRP authority and the reconnect loop use the existing
7-minute grace and then fail the transport. If the server's own event
loop stalls, the window restarts instead of blaming the runner. The 90 s
bound is longer than runnerd's own 60 s connection lease, which already
requires runnerd to read the socket.
- `server/src/services/execution-projection.ts`: `canStop` is now `true`
for every `queued` or `running` run. The old failed-native-retry rule is
unchanged.
- `server/src/services/execution-projection.ts`: a queued run now shows
"Waiting for previous run to stop" (cause `previous_run_settling`) when
a predecessor that admission checks (the task execution owner, the
retried run, or the agent's latest settled run on the task) still has
pending cleanup by `hasPendingRunCleanup`. Before, a turn queued after a
Stop showed only "Queued" with no reason while the stopped run's
controller still waited on the dead runner. That in-process wait
(`claimQueuedRun` settling-owner gate) also made Retry a no-op. The
liveness bound above makes that controller settle. Its existing cleanup
then calls `startNextQueuedRunForAgent`, which starts the queued turn.
- `server/src/services/run-cancellation.ts` and
`server/src/modules/run-dispatch/adapters/postgres.ts`: a user-stopped
native run whose runner was unreachable can later settle as `failed`
(`native_provider_terminal_failed`) when its transport fails closed. The
dispatch gate's message deferral required a `cancelled` source and
compared the message with `finishedAt`. The user's follow-up message was
therefore marked `skipped`, and the task asked for a second message. The
new `recordedUserStop` helper honors the recorded user Stop on such a
failed source, so the gate saves the message for the deferred-receipt
worker.
- `packages/shared/src/types/execution-projection.ts`: documents the
`canStop` meaning.
- `doc/execution-semantics.md`: documents the ingress liveness bound and
the new `canStop` meaning.

## Verification

- Red then green: `runner-prp-outbound.test.ts` "detects a half-open
authenticated ingress and fails the run within a bounded window". On the
old code it failed because no ping was sent and the socket was never
terminated. It now passes and the active-run `failure` rejects with
`runner_ingress_unavailable`.
- Safety: "keeps a quiet but live runner attached through a long silent
tool call" checks that a runner answering pings stays attached for 20
simulated minutes with no termination and no failure.
- Red then green: `execution-projection.test.ts` "offers Stop for every
live run, including a long-silent native turn". On the old code it
failed with `canStop: false`.
- Red then green: `execution-projection-recovery.test.ts` "names the
wait when a stopped predecessor still holds the task's runner" (real
Postgres). On the old code the successor projected a plain "Queued". It
also checks that the wait clears on lease release and ignores another
agent's run.
- Red then green: `workspace-recovery.test.ts` "continues a message sent
after Stop when the stopped run later failed closed". On the old code it
failed 3 of 3 runs with the receipt `skipped`. It now passes, and the
full file passes twice (392 tests). The test now pins the message after
the recorded Stop. Before, the fixture could write both in the same
millisecond, which made the sibling variants flaky.
- Passed: run-cancellation, explicit-native-continuation, run-dispatch
and run-control suites (622 tests).
- Passed: `npx vitest run src/realtime/
src/services/execution-projection*.test.ts
src/__tests__/heartbeat-run-privacy-routes.test.ts`, the
native-runner-execution-driver tests, and the plugin-runner-task tests.
- Passed: `pnpm --filter @paperclipai/server typecheck`.
- I read runnerd (`runner-core/src/durable/transport.rs`) and confirmed
it answers `Ping` with `Pong` in its non-blocking read loop.
- Not run: a live managed-sandbox reproduction. Staging and Daytona were
not reachable from this environment. `plugin-runner-probe.test.ts` needs
a local cargo build of runnerd, and it did not start in this worktree.

## Risks

- A false termination is possible if a runner fails to answer pings for
90 s while it is still alive. runnerd already loses its own connection
lease after 60 s without reading, so this bound does not add a stricter
requirement. A terminated live runner reconnects within the existing
grace.
- If the preview proxy drops WebSocket control frames, ping alone does
not prove liveness. Inbound PRP frames (runnerd's lease renewals, about
every 30 s) still count, so a live runner stays attached.
- `canStop` now means "the board can Stop now" for live runs. The UI
already shows Stop for running and queued runs without reading this
hint, so the UI does not change.
- Out of scope: the inbound runner WebSocket server
(`runner-prp-ws.ts`), and a lost OpenCode SSE event while the runner is
alive. A stopped run whose controller hangs for a reason other than a
dead transport still holds the queued successor. The projection now
names that wait, but this PR does not force-release it, because the
controller has no termination evidence. A queued reason for the agent
concurrency cap is also not covered. The wait label calls the admission
predicate (`hasPendingRunCleanup`) for up to three predecessor runs per
queued run. This adds a few reads to projections of queued runs only.

## Model Used

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

## 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-06 00:52:01 -06:00

465 lines
20 KiB
TypeScript

import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
import {
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 { hasPendingRunCleanup } from "./execution-blocker.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')`,
};
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 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]));
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),
),
);
}
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,
): 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 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 === "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";
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",
);
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 && readTaskDrain(now)) {
projection.cause = "task_drain";
projection.nextAction = "This turn will start automatically when maintenance finishes.";
return set("queued", "Waiting for maintenance");
}
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;
}