mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## 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>
549 lines
26 KiB
TypeScript
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;
|
|
}
|