mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip manages AI agents doing work for people. > - Agent verification must finish before an accepted task can start. > - A missing AI account default can prevent verification before a provider starts. > - Lifecycle policy treated that owned configuration refusal as a temporary failure and retried each minute. > - The accepted turn remained Queued without explaining the prerequisite. > - Park account configuration failures and reuse the task's existing inline connection card. > - Successful repair rechecks verification and releases the original queued turn under its original authority. ## Linked Issues or Issue Description **What happened?** An OpenCode agent selected the responsible user's OpenRouter default, but that default did not exist. Verification retried unchanged configuration indefinitely and its accepted task stayed Queued. **Expected behavior:** Preserve accepted work, explain the setup prerequisite, and let the appropriate person repair it using the existing task connection UI. Completion must verify the agent before starting the same queued turn. Genuine pause, approval and access gates remain effective. **Reproduction:** Create an OpenCode Runner agent with a responsible-user OpenRouter binding and no personal default; assign a task while verification is pending. The base retries verification and shows an unexplained queue. Related: #18702 handles pending task connection recovery after account repair. This PR handles the earlier agent-verification boundary. #20330 addresses a separate Codex governed-wait timeout and is already merged. ## What Changed - Share owned account-selection failure evidence through a pure library contract. Lifecycle policy parks those configuration failures with fixed, redacted guidance; temporary infrastructure errors retain their retries. - Batch setup-state reads for unstarted queued turns. Distinguish manual repair from automatic setup retries and preserve Stop, started/history state and lifecycle holds. - Reuse the existing `connection_intent` card, account selection and completion APIs. Its controller-only `agentSetup` marker accepts only never-started legacy turns; runtime tokens still require a running run. - Address the card to the lifecycle verifier's account owner, preserving the task's execution principal and private-task visibility. Attach missing cards on admission, verification, startup and full recovery, including retained retries and continuations. Repeated delivery and publication remain bounded. A new queued turn supersedes the setup card of a stopped turn; generic connection cards retain cross-run reuse. - Recheck lifecycle verification after usable-account completion, then release the existing turn through the normal readiness path. Preserve decline, Stop, reassignment and changed setup-owner gates. Fence an in-flight verification failure from overwriting a repair; add no provider wake or recurring worker. - Park owned account refusals during resume as well as initial verification. Bind setup cards to the lifecycle operation, settle obsolete deliveries, and keep explicit Retry guidance accurate. - Reuse the router’s existing lifecycle pin for setup repair without rerouting the task, advancing the pool cursor, or replacing its saved binding. Legacy direct-account cards remain readable. - Hide the incorrect Resume banner during setup, retaining genuine manual/budget pause controls. Document the execution and retry contract. ## Verification - Red: eight original behavior assertions failed on the base, including the real selector's retry decision and the real batched queue projection. Six owned selection cases failed classification. Temporary-error presentation, different task/verifier users, retained retry/continuation recovery and the setup Resume banner also have failing-before/passing-after regressions. - Green: 430 affected backend, lifecycle, shared-contract and UI tests passed before the final review corrections. Subsequent focused runs cover one inline card, real account selection/completion, duplicate delivery/publication, verification before readiness, unchanged run authority, private tasks, no extra provider wake, decline/Stop/reassignment, company isolation, invalid runtime tokens, transient retries and redacted diagnostics. - Final setup/card and generic connection-service suites: 49 tests passed. The stopped-turn case failed before the correction, then passed with real account completion, the stopped source remaining cancelled, and the replacement source remaining queued. - After rebase, all 129 focused assertions passed. The local queue suite’s 88 execution assertions passed repeatedly, but its database-fixture teardown exceeded both 30-second and 120-second hooks. An isolated queue case completed, and the setup/connection suites fully passed. The original queue test and cleanup remain unchanged; broad CI supplies the full-suite result. - Server/UI typechecks, module boundaries, token gates and diff checks passed. `pnpm verify --explain` requested broad CI because the execution-contract document changed; no redundant full local suite was substituted. - Real source acceptance: macOS arm64, Node 26.4.0, isolated embedded PostgreSQL and local board identity. Created an OpenCode/OpenRouter agent and task through the API with no default. Admission retained one unstarted turn and produced the existing inline Connect OpenRouter card. The browser opened its credential form and closed it without entering credentials. The same card/run persisted after server restart and reload, with the setup label and no incorrect Resume. The browser also reproduced Stop followed by a new queued turn retaining the stale card on the prior head. On `12f609c98372fd1e50644028db75cfcc8deb210d`, startup superseded the stale card and retained the same replacement turn, with the original turn still cancelled and no provider execution. - The earlier CI case-search failure also reproduces on exact base `0e3fdebea8257bdff11573081c76b306f12db230` without this PR. Its repair is now in master via #20371; this branch is rebased onto it and contains no API-search changes. - Staging's actual Runtime test returned “Connect an account and choose your personal default” before provider startup. The original turn later started independently on deployed code, then hit a separate workspace-export failure. Automatic file recovery subsequently marked that run successful; its task remains Blocked and the trial’s useful output is unverified. This PR is not deployed; provider authentication through its card and downstream publication remain unverified. No account, credential, default, permission or binding was changed during diagnosis. - Previous revision review: Greptile 5/5 on `12f609c98372fd1e50644028db75cfcc8deb210d`, with all four inline findings addressed and resolved. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42713494508) on that revision: 59 successful checks, one neutral check and three intentional skips, with no pending or failed checks. - Latest revision `976bcae993805a8a2222395bbff9394f6791b424`: resume, router and Retry-copy regressions failed before repair. Green: 92 lifecycle/setup/shared tests, 83 setup/generic-connection/router tests, and 137 UI/shared tests. Server/UI typechecks, module boundaries and token gates passed. [Full CI passed](https://github.com/paperclipai/paperclip/actions/runs/42720731137) on this exact revision: 59 successful checks and three intentional skips, with no pending or failed checks. Greptile reviewed this head at 5/5; all six review findings are addressed and resolved. Real Stop/retry acceptance retained the same replacement turn and current-operation card through two actual server restarts, with no provider execution. ## Risks - Known account configuration failures now require repair or explicit Retry. Temporary failures keep automatic retries; accepted work and authority remain intact. - The projection adds one company-scoped batch read only for unstarted queued turns. Card attachment examines at most 100 active candidates and adds no timer, worker, schema or migration. - The optional stored setup-card marker requires an aware reader during rollback. No run/session/checkpoint identity format changes. Setup presentation grants no access and cannot bypass verification or other gates. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, tool use, code editing and test execution. The exact serving model identifier and context-window size are not exposed in this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
521 lines
24 KiB
TypeScript
521 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')`,
|
|
};
|
|
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 === "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;
|
|
}
|