Files
PaperClipAI/server/src/services/execution-projection.ts
T
DottaandClaude Opus 5.5 34f71f7d57 fix(server): defer a run that meets a busy agent directory instead of failing it (#20829)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Each run of an agent with managed agent files gets a private copy of
the agent directory. Remote copies serialize their prepare, checkpoint,
collect, and release operations through one Postgres advisory lock per
agent, taken with `pg_try_advisory_xact_lock`
> - When the lock is busy, the operation fails at once with "Agent
directory operation is already active"
> - On staging, a deploy restarted the controller while three runs of
the same agent were active. When one run resumed, another run of that
agent was checkpointing its directory. The resume failed, used its last
attempt, and the task became blocked with "Retry or send a message"
> - A run must not fail because another run of the same agent is briefly
using the agent directory
> - This pull request keeps the fail-fast lock and, when it is busy
during preparation, puts the same native run back on the existing
same-run retry schedule a few seconds later
> - The benefit is that lock contention delays a run by seconds instead
of ending it as failed

## Linked Issues or Issue Description

**What happened?**
A task's run failed with `native_continuation_requires_reconciliation`
and the error "Agent directory operation is already active". The task
was blocked and asked the user to retry. The same error ended at least
two other runs of the same agent within one day.

**Steps to reproduce**
1. Use an agent with managed agent files in a remote sandbox.
2. Let one run of the agent checkpoint or collect its agent directory.
3. At the same time, let another run of the same agent prepare (for
example, a same-run resume after a controller restart).
4. The second run fails immediately with "Agent directory operation is
already active".

**Expected behavior**
The second run waits until the first operation is done, then continues.
It does not end as failed.

## What Changed

- `server/src/services/agent-directory-working-copies.ts`: The busy lock
now throws `AgentDirectoryOperationBusyError`, still a 409 with the same
message. Lock behavior is unchanged from `master`: a fail-fast
`pg_try_advisory_xact_lock` held for the life of the holder's
transaction.
- `server/src/services/heartbeat/agent-directory-busy.ts`:
`prepareWithAgentDirectoryDeferral` wraps only instruction preparation
and turns the busy error into `AgentDirectoryPreparationDeferral`
("Another run of this agent is still using its files"). A busy lock met
while collecting files after a turn is not wrapped and keeps its
existing path.
- **Before native admission** (a brand-new run, such as the first run of
a task): `server/src/services/heartbeat/retries.ts`
`finalizeAgentDirectoryBusyDeferral` cancels the run as an expected
control-plane wait (`providerWorkStarted: false`) and schedules an
`agent_directory_busy` retry 5–15 seconds later. The task keeps its
execution lock on the retry. The lane spends no failure retries
(`execution-recovery-attempt.ts`), its count is the retry run's
`scheduledRetryAttempt`, and it stops after 30 attempts.
`pre-dispatch-execution-evidence.ts` classifies this wait like the
existing workspace and AI-connection waits, so retry scheduling does not
ask for reconciliation.
- **A resumed native run**:
`server/src/services/native-runtime/native-controller-recovery.ts`
`deferNativeRunForBusyAgentDirectory` keeps the same run on the existing
same-run retry schedule without spending an attempt. It uses the same
guards as `deferNativeControllerRecovery`: it locks the task, requires
the task's execution run to be this run, requires the current lease
owner, and refuses after a Stop, a startup cancellation or a pending
native cancellation, so those settle normally. Its count is stored in
the run's `contextSnapshot`, which a resume claim does not clear, and it
stops after 30.
- `server/src/services/heartbeat.ts`: routes a preparation deferral to
the same-run path or, before admission, to the scheduled-retry path.
After the bound it follows the normal failure path with the plain
message.
- No schema or migration. Postgres releases a dead holder's advisory
lock with its session. The staging evidence showed a live holder
(another run's checkpoint), not a stale one.
- `doc/DEVELOPING.md`: Documented both paths and the bound.

Earlier revisions of this PR waited on the lock and added idle-session
expiry, keepalive, ownership fences, fenced remote removal and attempt
slots. This revision removes all of them.

### How the directory-wait retry keeps the original wake

The retry no longer replaces the wake. `scheduleBoundedRetryForRun`
takes `preserveWakeContext` for this wait: the retry keeps the original
`wakeReason`, `retryReason` and the whole wake context (interaction,
comment, continuation and admission fields), and its wake request keeps
the original reason. The wait is recorded separately as
`preparationWait: { kind: "agent_directory_busy", attempt, nextAttemptAt
}`, next to the carried `executionRetryAccounting`. Gates that key on
the wake reason therefore treat the retry exactly as they treated the
original wake.

| # | Site | Treatment for the directory wait | Why |
| --- | --- | --- | --- |
| 1 | `heartbeat/retries.ts` `scheduleBoundedRetryForRun` | Changed:
`preserveWakeContext` keeps the original wake context and reason and
adds `preparationWait` | Root cause: the retry overwrote the wake reason
|
| 2 | `run-dispatch/domain/wake-context.ts`
`isResolvedInteractionContinuationWakeContext` /
`PREPARATION_WAIT_RETRY_REASONS` | Special case removed | The original
wake context is kept, so the continuation is classified from it |
| 3 | `heartbeat/retries.ts` `interactionRunnerWait` | Special case
removed | The interaction fields are kept in the context |
| 4 | `run-dispatch/adapters/postgres.ts` scheduled-retry dependency
facts | Changed: a preparation-wait retry whose original wake was
admitted as a dependency-blocked interaction
(`dependencyBlockedInteraction`, recorded at wake time) is admitted the
same way | Without the wait the original wake would have run; this gate
is only met because of the wait |
| 5 | `run-dispatch/domain/wake-context.ts`
`isNonAssigneeWorkspaceBusyRetry` + `heartbeat/retries.ts`
`finalizeAgentDirectoryBusyDeferral` | Kept:
`preparationWaitDeferredWhileAssignee`, derived only from the recorded
admission authority | The scheduled-retry ownership gate has no
wake-reason path; the workspace and AI waits use the same recorded flag
|
| 6 | `heartbeat/retries.ts` explicit user continuation admission |
Kept: admits the `agent_directory_busy` scheduling reason | The
successor needs its own authorization receipt |
| 7 | `execution-recovery-attempt.ts` uncharged lane and
`agentDirectoryWaits` | Kept | Cap and accounting |
| 8 | `pre-dispatch-execution-evidence.ts` (`isPreDispatchResourceWait`,
`isAgentDirectoryPreparationWait`), used by
`legacy-execution-recovery.ts`, `cancelled-native-startup.ts`,
`explicit-native-continuation.ts` | Kept | Describe the cancelled
pre-provider parent run, not the retry's wake |
| 9 | `explicit-native-continuation.ts`
`undeliveredLegacyUserCommentIds`, `run-dispatch/adapters/postgres.ts`
`unstartedUserTurnOrigin` | Kept | The cancelled parent did not consume
the user's comment |
| 10 | `execution-projection.ts` label and saved accounting,
`task-interruption-presentation.ts`, UI toast and chat suppression |
Kept | Presentation of the wait |
| 11 | `run-dispatch/domain/policy.ts` AI-wait lock requirement;
AI-subscription lock recheck; AI wait detection
(`execution-configuration.ts`, `provider-limit-wait.ts`, `heartbeat.ts`,
`ai-connections.ts`, `ai-connection-runtime.ts`, `local-ai-login.ts`) |
Not applicable | AI-subscription specific |
| 12 | `workspace-busy-wait.ts`, `environment-selection.ts`,
`workspace-preparation.ts`, UI workspace-holder banners | Not applicable
| Workspace holder parking; the directory wait is a timed retry |
| 13 | `cancelled-native-startup.ts` paused workspace-retry repair;
`failureRetriesBefore*Wait` | Not applicable | Historical workspace and
AI records only; every directory wait saves its ledger |
| 14 | `failedRunRetryBudgetSpent`; storage-wait exhaustion escalation;
`status-relevance.ts` `isAdmissionWait` | Not applicable | A directory
wait never fails the run, stops at its own bound, and its run has
started |

## Verification

- Original wake kinds after a directory wait
(`heartbeat-agent-directory-busy.test.ts`, real heartbeat): an
assignment runs; a plain user comment on a dependency-blocked task is
answered with `dependencyBlockedInteraction`; an accepted interaction
continuation keeps its parked-task and reassignment gates; a user
message continuation runs with its comment; an accepted non-assignee
mention runs; and a reassignment during preparation does not let the
previous assignee restart. Each retry keeps the original `wakeReason`
and records `preparationWait`. With `preserveWakeContext` off, five of
these fail. Restart recovery keeps its attempt credit
(`native-runner-restart-recovery.integration.test.ts`).

- Non-assignee wake: an accepted mention for an agent other than the
assignee meets a busy directory; its retry records
`preparationWaitDeferredWhileAssignee: false`, survives promotion, runs
and succeeds once the directory is free. Without the shared clause in
`isNonAssigneeWorkspaceBusyRetry`, promotion cancels it.

- Interaction continuations: `heartbeat-agent-directory-busy.test.ts`
runs an accepted interaction continuation into a busy directory. The
retry keeps `mutation`, `interactionId`, `interactionKind` and
`interactionStatus` and is still classified as a continuation. If the
task is moved to todo, or reassigned, while the retry waits, the retry
does not run and the task stays parked or with its new owner. With the
old explicit storage/Runner lists, both cases fail.

- Red/green, new run:
`server/src/__tests__/heartbeat-agent-directory-busy.test.ts` runs a
real heartbeat for a new assignment whose first instruction preparation
meets a busy lock. Without the preparation wrapper, the run ends failed
(red). With it, the first run is cancelled as a wait, an
`agent_directory_busy` retry is scheduled with the task still locked to
it, the retry runs and succeeds, and no run of the task is failed
(green).
- Red/green, same-run: with the deferral as a no-op, the same-run
deferral tests failed (`expected false to be true`); they now pass.
- `native-controller-recovery.integration.test.ts` covers repeated
claim-and-defer cycles of a resumed run: each cycle keeps the attempt
count, cannot be claimed before the delay, stores the count on the run,
and the fourth deferral past a bound of 3 is refused. It also covers
Stop after the busy error, a foreign lease owner and a moved task (each
refused, nothing changed), a run before admission, and that a raw busy
error (as from collection) is never deferred.
- Focused suites: 232/232 passed (`agent-directory-working-copies`,
`instruction-working-copy`, `heartbeat-agent-directory-busy`,
`native-controller-recovery.integration`, `heartbeat-retry-scheduling`).
`tsc --noEmit` for `server` reports no errors in the changed files.
- Staging evidence (read-only): the failed run had a graceful restart at
07:25Z, then same-run resumes. Its third attempt failed at 07:31:08Z.
Another run of the same agent recorded a warm checkpoint receipt at
07:31:37Z. A second run of the same agent failed the same way at
07:59:46Z.

## Risks

- A deferred run waits 5 to 15 seconds per deferral, up to 30 times. If
the holder is still busy after that, the run fails as before with a
plain message.
- Before admission, deferral cancels the run and creates a retry run,
like the existing workspace-busy wait.

## Model Used

- Claude Opus 5.5 (claude-opus-5-5), Anthropic, with tool use and code
execution 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-11 13:56:45 -06:00

526 lines
24 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";
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')`,
};
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]));
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),
),
);
}
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 },
): 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",
);
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;
}