Files
PaperClipAI/server/src/services/task-interruption-presentation.ts
T
DottaandClaude Opus 5.5 4420a5adcf fix(issues): a Retry held by a blocked subtask names it instead of failing silently (#20109)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board's Retry is the one action a person takes when a task
stops; it must either start work or say why it can't yet
> - Restart chaos t05 left a parent task blocked on a blocked subtask.
The board clicked Retry twice; each was accepted and nothing happened
> - The Retry was saved and the queue deferred it behind the unresolved
blocker, which is correct. But the task still looked failed with Retry
offered, so the wait was invisible
> - This pull request presents a saved Retry that waits only on
unfinished blockers as waiting, names the blocked subtask and links it
> - The benefit is that the board sees what to act on (the subtask's own
Retry) instead of clicking a Retry that cannot run

## Linked Issues or Issue Description

Refs #19831 (durable ownership plan). Restart chaos wave
`w-20261108T200620-s9701`, task t05 (claude, M1, K4).

**Problem:** parent CHA-5 was blocked by subtask CHA-10, which was
itself blocked on a board recovery action. The board pressed Retry on
CHA-5 at 20:27:46 and 20:40:46. Each `POST /issues/:id/retry` returned
202, and the saved Retry stayed `deferred_issue_execution` behind
CHA-10, so no run started. The task's interruption presentation kept
saying `failed` with `canRetry: true` and gave no reason.

## What Changed

- `server/src/services/task-interruption-presentation.ts`:
- For tasks with a saved (deferred, user-requested) Retry, the loader
reads the queue's own dependency readiness (`listDependencyReadiness`).
That counts cancelled blockers and done blockers whose workspace has not
finalized, like the queue does.
- When the saved Retry for the latest run is held only by unfinished
blockers (no execution wait, no preparation), the task is presented as
`working` with `canRetry: false` and `retryWait: { reason:
"dependency_blocked", message }`.
  - The message has three forms:
    - a blocked blocker: "Retry or unblock it";
- a cancelled blocker: "Remove or replace that dependency to continue",
because the queue keeps it unresolved;
    - otherwise: waiting for the blockers to finish.
- The presentation carries no blocker names or ids. Issue reads return
it to restricted agents too.
- The Retry response (`POST /issues/:id/retry`) already returns this
presentation, so the API says the Retry is waiting on dependencies as
soon as it is saved. The blockers themselves are in the issue's
`blockedBy`, which issue reads already filter per viewer.
- `ui/src/components/ExecutionBlockerNotice.tsx` takes `blockers` (the
issue's `blockedBy`, from `IssueDetail`). For a `dependency_blocked`
wait it lists each readable unfinished blocker with a link and what it
needs: "Blocked: open it to Retry" or "Cancelled: remove or replace it".
Tokens only; `pnpm check:token-gates` is clean.
- No shared type change, and no code comments added.

## Verification

Red/green:
- `server/src/__tests__/task-interruption-query.test.ts` "explains a
saved Retry held by unfinished blockers instead of offering Retry again
(restart chaos t05)". It sets up a parent with a failed run, a saved
Retry deferred, and a blocked subtask that blocks it.
  - On master: `{ state: "failed", canRetry: true }`, no reason.
- With this PR: `working`, `canRetry: false`, `dependency_blocked`. The
presentation has no blocker title or identifier.
- A cancelled blocker gets the remove-or-replace message. Once the
blocker is done, the dependency wait is gone.
- `ui/src/components/ExecutionBlockerNotice.test.tsx`: the notice links
the blocked and the cancelled blockers with their actions, skips the
done one, and offers no second Retry.

Regression: `task-interruption-query` (25 tests) and
`ExecutionBlockerNotice` (16 tests) pass, as do the presentation unit
tests, workspace-recovery admission, gates and copyback tests, and
`heartbeat-dependency-scheduling`. `tsc --noEmit` is clean for `server`
and `ui`.

## Risks

- One more batched read (dependency readiness plus blocker statuses),
only for tasks in the page that have a saved Retry.
- Presentation only. Admission is unchanged: the queue still runs the
saved Retry when the blockers are done.
- A Retry saved before a blocker was added shows the same wait, which is
accurate, because the queue holds it too.

## Model Used

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

## 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 (#20069)
- [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 (code
comments; no user docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending)
- [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-08 17:18:55 -06:00

580 lines
44 KiB
TypeScript

import { and, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm";
import { agents, agentWakeupRequests, approvals, chatActions, companies, heartbeatRuns, issueApprovals, issueRecoveryActions,
issues, issueTreeHoldMembers, issueTreeHolds, nativeRunFinalizations, projects, type Db } from "@paperclipai/db";
import { unionAll } from "drizzle-orm/pg-core";
import { HEARTBEAT_RUN_STATUSES, readProviderFailure, type TaskInterruptionPresentation } from "@paperclipai/shared";
import { captureTaskInterruptionFailure } from "../sentry.js";
import { isAiAuthenticationBlocked } from "./ai-auth-failure.js";
import { readSystemRecovery } from "./system-recovery-state.js";
import { PROJECT_ACCESS_DENIED } from "./project-access-denial.js";
import { authorizationService } from "./authorization.js";
import { taskWorkspaceProjectRequirements } from "./task-workspace-access.js";
import { isAcknowledgedNativeStop } from "./acknowledged-native-stop.js";
import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
import { instanceSettingsService } from "./instance-settings.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { logger } from "../middleware/logger.js";
import { canonicalExecutionUuid } from "./execution-identity.js";
import { chatActionDeadline } from "./chat-action-work.js";
import { isQueuedDependencyWait } from "./run-cancellation.js";
import { AI_CONNECTION_BUSY_RETRY_REASON, AI_CONNECTION_POOL_WAIT_RETRY_REASON, RUNNER_REVISION_WAIT_RETRY_REASON,
WORKSPACE_BUSY_RETRY_REASON } from "../modules/run-dispatch/index.js";
// These scheduled runs wait for a shared resource; none records a failed
// attempt. Their dispatcher re-checks the resource, so the run can stay
// scheduled past its retry time. Retry cannot free the resource either.
const resourceWaitRetryReasons = new Set<string>([WORKSPACE_BUSY_RETRY_REASON, AI_CONNECTION_BUSY_RETRY_REASON,
AI_CONNECTION_POOL_WAIT_RETRY_REASON, RUNNER_REVISION_WAIT_RETRY_REASON]);
// A due same-run resume dispatches within seconds; past this, offer Retry.
const NATIVE_RESUME_DISPATCH_GRACE_MS = 2 * 60_000;
// A queued-message Interrupt waits for the stopped run's cleanup before its
// successor starts. Past this, a hand-off that never started offers Retry.
const QUEUED_INTERRUPT_HANDOFF_GRACE_MS = 5 * 60_000;
type ProjectionIssue = Pick<typeof issues.$inferSelect, "id" | "companyId" | "status" | "assigneeAgentId" | "projectId">;
type ProjectionRun = Pick<typeof heartbeatRuns.$inferSelect,
"id" | "status" | "scheduledRetryAt" | "errorCode" | "livenessState" | "resultJson"> &
Partial<Pick<typeof heartbeatRuns.$inferSelect, "agentId" | "error" | "companyId" | "nativeIssueId" | "runtimeMode" | "startedAt"
| "scheduledRetryReason" | "createdAt" | "finishedAt">> & {
/** Due time of the native coordinator's own same-run resume, if one is pending. */
nativeResumeAt?: Date | null;
/** When the native coordinator saved this run's terminal failure, if it did. */
nativeFailedAt?: Date | null;
};
type ProjectionWait = { id: string; requestedAt: Date; sourceRunId?: string | null; executionWaitReason?: string | null;
preparationStatus?: string | null; preparationRetryAt?: number | string | null; preparationUpdatedAt?: Date | null;
/** When the user pressed Interrupt to deliver this queued message now. */
queuedInterruptRequestedAt?: string | null };
type ProjectionState = {
run?: ProjectionRun;
action?: typeof issueRecoveryActions.$inferSelect;
wait?: ProjectionWait;
retryWait?: ProjectionWait;
/** The pending queued message this run was interrupted for, if any. */
interruptWait?: ProjectionWait;
agent?: Pick<typeof agents.$inferSelect, "status" | "pauseReason">;
projectAccessAllowed?: boolean;
projectAccessProjectId?: string | null;
workspaceSelectionAvailable?: boolean;
project?: Pick<typeof projects.$inferSelect, "pausedAt" | "pauseReason">;
company?: Pick<typeof companies.$inferSelect, "status" | "pauseReason">;
paused: boolean;
approval: boolean;
/** A board-owned workspace-owner fence on this run that no automatic proof has released. */
ownerStopRequired?: boolean;
/** The latest wake for a task with no run yet, when admission refused it. */
admissionWait?: { id: string; status: string; reason: string | null; agentId: string };
retryDependencies?: RetryDependencies;
};
// A task read reports the change that made a task fail, not every later read of
// retained history. The delivery cache is process-local, so a restart or another
// replica would otherwise resend each old failure whenever a list shows it.
const TASK_INTERRUPTION_REPORT_WINDOW_MS = 60 * 60_000;
/** Latest saved fact that can move a task into the failed state, if known. The
* recovery row's update time covers recovery that newly fails on an old run:
* each saved recovery transition stamps it, while activity heartbeats do not.
* The native resume due time and terminal finalization cover a same-run resume
* that lapses or is exhausted on an old run. */
export function taskInterruptionChangedAt(input: Pick<ProjectionState, "run" | "action" | "wait" | "retryWait">): number | null {
const { run, wait, action } = input;
const retryWait = input.retryWait ?? wait;
const preparationFailedAt = retryWait?.preparationStatus === "failed" || retryWait?.preparationStatus === "cancelled"
? retryWait.preparationUpdatedAt : null;
const times: unknown[] = [run?.finishedAt ?? run?.createdAt, run?.scheduledRetryAt, run?.nativeResumeAt, run?.nativeFailedAt,
wait?.requestedAt, retryWait?.requestedAt, action?.updatedAt, preparationFailedAt].map(value => value?.getTime());
times.push(readSystemRecovery(action?.evidence)?.nextAttemptAt,
retryWait?.preparationRetryAt == null ? null : Number(retryWait.preparationRetryAt));
const known = times.filter((time): time is number => typeof time === "number" && Number.isFinite(time));
return known.length ? Math.max(...known) : null;
}
function boundedJsonString(value: SQL, limit: number) {
return sql<string | null>`case when jsonb_typeof(${value}) = 'string' then left(${value} #>> '{}', ${limit}) else null end`;
}
// Persisted wait messages can contain diagnostics. Ordinary task views expose
// only these stable categories, never arbitrary payloads or source identifiers.
const retryWaitMessages: Record<string, string> = {
workspace_repair_required: "Retry is waiting for Paperclip to recover the saved files. Your message is saved.",
remote_cleanup: "Retry is waiting for the previous environment to stop. Your message is saved.",
local_cleanup: "Retry is waiting for the previous execution to finish cleanup. Your message is saved.",
cleanup_pending: "Retry is waiting for the previous execution to finish cleanup. Your message is saved.",
execution_settling: "Retry is waiting for the previous execution to stop. Your message is saved.",
controller_settling: "Retry is waiting for the previous execution to finish recovery. Your message is saved.",
process_identity_missing: "Retry is waiting for Paperclip to verify that the previous execution stopped. Your message is saved.",
process_running: "Retry is waiting for the previous execution to stop. Your message is saved.",
execution_active: "Retry is waiting for the current run to finish. Your message is saved.",
decision_pending: "Retry is waiting for a pending approval or question to be resolved. Your message is saved.",
"budget.blocked": "Retry is waiting for the budget to allow new work. Check Costs for unrecorded or unpriced usage.",
finalizing_saved_result: "Paperclip is finishing the saved result before Retry can continue.",
// Another click cannot release a copyback owner that stopped without reporting.
// Retry starts a new recovery generation; the agent does not repeat its work.
saved_work_recovery_failed: "Saving the agent's files failed after automatic retries. Retry to try again; the agent will not repeat its work.",
workspace_owner_stop_required: "Retry is waiting until Paperclip confirms that the previous server stopped saving files. The agent's result and your Retry are saved.",
cleanup_quarantined: "Retry cannot continue this attempt. Send a new message after the previous execution has stopped.",
cancelled_by_operator: "This attempt was stopped. Send a new message to continue from saved work.",
// A new click replaces a Retry declined for its requester's access with a
// fresh attempt under the clicker's access, so Retry stays available.
retry_authority_denied: "Retry could not start with the requesting user's access. Press Retry to start it with yours.",
// An edited saved message can never be replayed; only new input continues.
retry_source_changed: "Retry cannot continue because its saved message changed. Send a new message to continue from saved work.",
};
type RetryDependencies = { count: number; blocked: boolean; cancelled: boolean; savingFilesOnly: boolean };
function dependencyRetryWaitMessage(dependencies: RetryDependencies): string {
if (dependencies.cancelled) return "Retry is waiting on a cancelled task this one depends on. Remove or replace that dependency to continue. Your Retry is saved.";
if (dependencies.blocked) return "Retry is waiting on a blocked task this one depends on. Retry or unblock it; this task continues when it is done. Your Retry is saved.";
if (dependencies.savingFilesOnly) return "Retry is waiting for a finished task this one depends on to save its files. Your Retry is saved.";
return "Retry is waiting for the tasks this one depends on to finish. Your Retry is saved.";
}
// A task that never started can wait for a temporary admission hold. The
// assigned-task liveness sweep admits it once the hold clears; no Retry needed.
const admissionWaitMessages: Record<string, string> = {
"budget.blocked": "Waiting for the budget to allow new work. The task starts automatically when it does.",
};
/** Recognize old persisted failures without displaying arbitrary run error text. */
function deniedProjectId(issue: ProjectionIssue, run?: ProjectionRun): string | null | undefined {
const historical = run?.errorCode === "setup_failed" && run.error === "Project is outside this low-trust boundary.";
if (!run || run.status !== "failed" || run.agentId !== issue.assigneeAgentId
|| (run.errorCode !== PROJECT_ACCESS_DENIED && !historical)) return undefined;
const detail = run.resultJson?.projectAccessDenied;
return detail && typeof detail === "object" && "projectId" in detail && typeof detail.projectId === "string"
? detail.projectId : null;
}
/** Presentation never grants authority. Dispatch repeats all gates under its
* locks. The same projection is used in detail, lists and attention surfaces. */
export function projectTaskInterruption(issue: ProjectionIssue, input: ProjectionState, now = Date.now()): TaskInterruptionPresentation | null {
const { run, wait, action, agent, project, company } = input;
const retryWait = input.retryWait ?? wait;
if (!run) {
const admission = input.admissionWait;
if (!admission || admission.status !== "skipped" || !Object.hasOwn(admissionWaitMessages, admission.reason ?? "")
|| issue.status !== "todo" || admission.agentId !== issue.assigneeAgentId) return null;
const waiting: TaskInterruptionPresentation = { intentId: admission.id, responsibility: "system", state: "working",
nextRetryAt: null, canRetry: false, userAction: null, sourceRunIds: [], observedRunId: null };
// Budget pauses, ordinary pauses and approvals wait on a person; only an
// accounting hold settles by itself. Admission records all of them as budget.blocked.
const userAction = !issue.assigneeAgentId ? "assign_agent" : input.approval ? "approval"
: [agent?.pauseReason, project?.pauseReason, company?.pauseReason].some(reason => reason === "budget" || reason === "budget_exceeded")
? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null;
if (userAction) return { ...waiting, responsibility: "user", state: "waiting_for_user", userAction };
return { ...waiting, retryWait: { reason: admission.reason!, message: admissionWaitMessages[admission.reason!] } };
}
const system = readSystemRecovery(action?.evidence);
const base: TaskInterruptionPresentation = { intentId: wait?.id ?? null, responsibility: "system",
state: "resolved", nextRetryAt: null, canRetry: false, userAction: null,
sourceRunIds: [run.id], observedRunId: run.id };
if (["done", "cancelled"].includes(issue.status) || (run.status === "succeeded" && !wait)) return base;
const providerFailure = run.status === "failed" || run.status === "scheduled_retry"
? readProviderFailure(run.resultJson?.providerFailure) : null;
if (providerFailure) base.providerFailure = providerFailure;
if (["failed", "scheduled_retry"].includes(run.status) && run.errorCode === "workspace_validation_failed"
&& typeof run.error === "string" && run.error.trim()) base.workspaceFailure = { message: run.error };
const userAction = !issue.assigneeAgentId ? "assign_agent" : input.approval || run.errorCode === "native_provider_approval_required" ? "approval"
: isAiAuthenticationBlocked(run) ? "reconnect"
: run.errorCode === "native_provider_model_rejected" ? "choose_model"
: [agent?.pauseReason, project?.pauseReason, company?.pauseReason].some(reason => reason === "budget" || reason === "budget_exceeded")
? "budget" : input.paused || project?.pausedAt || agent?.status === "paused" || company?.status === "paused" ? "resume" : null;
if (userAction) return { ...base, responsibility: "user", state: "waiting_for_user", userAction };
// The previous controller cannot confirm its own stop. Name the one board
// action instead of showing the fenced run as working (RC-47).
if (input.ownerStopRequired) return { ...base, responsibility: "user", state: "waiting_for_user", userAction: "confirm_owner_stop" };
// The task's existing dependency UI owns this wait. Keep actual recovery and
// Retry preparation failures authoritative; this does not change admission.
if (!action && !retryWait?.preparationStatus && isQueuedDependencyWait(run)) return base;
// Intentional Stop is not an unsuccessful attempt. This is presentation only;
// physical containment and pending cleanup keep their execution-side gates.
if (!wait && run.runtimeMode === "native" && run.companyId === issue.companyId && run.nativeIssueId === issue.id
&& isAcknowledgedNativeStop({ ...run, companyId: run.companyId, nativeIssueId: run.nativeIssueId })) return base;
// A queued-message Interrupt stops this run to deliver that exact message.
// The stop is the user's intent and the message starts next, so show the
// hand-off as working, not as a failed attempt.
// Newer queued input can follow it, so match the recorded queue, not the latest wait.
const interruptWait = input.interruptWait ?? wait;
const interruptAt = Date.parse(interruptWait?.queuedInterruptRequestedAt ?? "");
if (interruptWait && run.status === "cancelled" && run.resultJson?.queuedCommentInterruptQueueId === interruptWait.id
&& Number.isFinite(interruptAt) && now - interruptAt < QUEUED_INTERRUPT_HANDOFF_GRACE_MS
&& !(retryWait?.sourceRunId === run.id && retryWait.preparationStatus) && system?.state !== "exhausted")
return { ...base, intentId: interruptWait.id, state: "working" };
if (["running", "queued"].includes(run.status)) return { ...base, state: "working" };
const deniedProject = deniedProjectId(issue, run);
let projectAccess: TaskInterruptionPresentation["projectAccess"];
if (deniedProject !== undefined) {
// Permissions cannot repair a stale or unavailable workspace selection.
if (input.workspaceSelectionAvailable === false) return { ...base, state: "failed" };
const ready = input.projectAccessAllowed === true;
const projectId = input.projectAccessProjectId === undefined ? deniedProject : input.projectAccessProjectId;
projectAccess = { agentId: run.agentId!, projectId: projectId === issue.projectId ? projectId : null, ready };
if (!ready) return { ...base, responsibility: "user", state: "waiting_for_user",
userAction: "project_access", projectAccess };
}
const recoveryStep: TaskInterruptionPresentation["recoveryStep"] = !system ? undefined
: system.phase === "verify_execution" ? "checking_previous_run"
: system.phase === "preserve_files" ? "saving_previous_files" : "finishing_recovery";
const activeObservation = action?.evidence.systemRecoveryActiveAt;
const recoveryRunning = system?.state === "running" && typeof activeObservation === "number"
&& activeObservation > now - 45_000;
const recoveryScheduled = Boolean(system?.nextAttemptAt && system.nextAttemptAt > now);
const preparationDeadline = retryWait?.preparationRetryAt == null ? NaN : Number(retryWait.preparationRetryAt);
// A pending Retry preparation is already a Retry; never offer another one.
const preparationPending = retryWait?.sourceRunId === run.id && Boolean(retryWait.preparationStatus)
&& ["issued", "processing"].includes(retryWait.preparationStatus!) && Number.isFinite(preparationDeadline);
if (retryWait?.sourceRunId === run.id && retryWait.preparationStatus) {
const deadline = preparationDeadline;
const pending = preparationPending;
// Saved-work recovery gates a pending preparation, so it names the wait.
// Failed or cancelled preparation stays authoritative.
if (pending && !recoveryRunning && !recoveryScheduled)
return { ...base, state: "retry_scheduled", nextRetryAt: new Date(deadline).toISOString() };
if (!pending) return { ...base, state: "failed", canRetry: retryWait.preparationStatus !== "cancelled" };
}
if (run.status === "scheduled_retry" && resourceWaitRetryReasons.has(run.scheduledRetryReason ?? "") && !system)
return { ...base, state: "working" };
// The native coordinator resumes this same run by itself. A manual Retry
// would race it, so show the resume until it is due and briefly while its
// dispatch runs. A resume that never ran falls through to one Retry.
const nativeResumeAt = run.nativeResumeAt?.getTime();
const retryAt = system?.nextAttemptAt ?? nativeResumeAt ?? run.scheduledRetryAt?.getTime();
if (retryAt && retryAt > now) return { ...base, state: "retry_scheduled",
nextRetryAt: new Date(retryAt).toISOString(), canRetry: !preparationPending && !nativeResumeAt,
...(system?.nextAttemptAt && recoveryStep ? { recoveryStep } : {}) };
if (nativeResumeAt && !system && now - nativeResumeAt < NATIVE_RESUME_DISPATCH_GRACE_MS)
return { ...base, state: "working" };
if (recoveryRunning) return { ...base, state: "working", ...(recoveryStep ? { recoveryStep } : {}) };
const dependencies = input.retryDependencies;
if (retryWait?.sourceRunId === run.id && !retryWait.executionWaitReason && !retryWait.preparationStatus && dependencies?.count)
return { ...base, intentId: retryWait.id, state: "working",
retryWait: { reason: "dependency_blocked", message: dependencyRetryWaitMessage(dependencies) } };
if (retryWait?.sourceRunId === run.id && retryWait.executionWaitReason) {
const reason = Object.hasOwn(retryWaitMessages, retryWait.executionWaitReason) ? retryWait.executionWaitReason : "execution_recovery";
// Keep the latest input receipt so message submission can settle independently.
return { ...base, state: "failed",
canRetry: !["cleanup_quarantined", "cancelled_by_operator", "retry_source_changed", "workspace_owner_stop_required"].includes(reason), retryWait: { reason,
message: retryWaitMessages[reason] ?? "Retry is waiting for execution checks. Your message is saved." } };
}
if (wait && wait.requestedAt.getTime() > now - 10_000 && system?.state !== "exhausted")
return { ...base, state: "working" };
return { ...base, state: "failed", canRetry: true, ...(projectAccess ? { projectAccess } : {}) };
}
/** A scheduled retry has no outcome yet, so its provider failure is read from
* the predecessor it retries: the countdown keeps its reason.
* Batch list reads rather than doing seven queries per row. Keep run logs and
* large result bodies out of this projection. */
export async function taskInterruptionPresentations(db: Db, companyId: string, input: ProjectionIssue[]) {
const selected = input.filter(issue => issue.companyId === companyId);
const result = new Map<string, TaskInterruptionPresentation | null>();
if (!selected.length) return result;
const ids = selected.map(issue => issue.id);
const requested = sql`unnest(${sql.param([...new Set(ids)])}::text[]) as requested_interruption_issues(requested_issue_id)`;
const requestedIssueId = sql<string>`"requested_interruption_issues"."requested_issue_id"`;
const requestedUuid = sql<string>`case when ${requestedIssueId} ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
then ${requestedIssueId}::uuid else null end`;
const legacyIssue = sql<string>`${heartbeatRuns.contextSnapshot}->>'issueId'`;
const candidate = (condition: ReturnType<typeof and>) => db.select({ id: heartbeatRuns.id, createdAt: heartbeatRuns.createdAt })
.from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), condition))
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
const candidates = unionAll(
candidate(eq(heartbeatRuns.nativeIssueId, requestedUuid)),
candidate(and(isNull(heartbeatRuns.nativeIssueId), eq(heartbeatRuns.issueId, requestedUuid))),
candidate(and(isNull(heartbeatRuns.nativeIssueId), isNull(heartbeatRuns.issueId), eq(legacyIssue, requestedIssueId))),
).as("interruption_run_candidates");
const latestRuns = db.select({ id: candidates.id }).from(candidates)
.orderBy(desc(candidates.createdAt), desc(candidates.id)).limit(1).as("latest_interruption_runs");
const wakeIssue = sql<string>`${agentWakeupRequests.payload}->>'issueId'`;
const isRetryWait = sql<boolean>`coalesce(jsonb_typeof(${agentWakeupRequests.payload}#>'{taskRetry,sourceRunId}') = 'string', false)`;
const agentIds = selected.flatMap(issue => issue.assigneeAgentId ? [issue.assigneeAgentId] : []);
const projectIds = selected.flatMap(issue => issue.projectId ? [issue.projectId] : []);
const startingIssueIds = selected.filter(issue => issue.status === "todo" && issue.assigneeAgentId).map(issue => issue.id);
const [runs, waits, agentRows, projectRows, pauseRows, approvalRows, companyRows, admissionWaits] = await Promise.all([
db.select({ issueId: requestedIssueId, id: heartbeatRuns.id, status: heartbeatRuns.status,
createdAt: heartbeatRuns.createdAt, finishedAt: heartbeatRuns.finishedAt, agentId: heartbeatRuns.agentId, companyId: heartbeatRuns.companyId,
nativeIssueId: heartbeatRuns.nativeIssueId, runtimeMode: heartbeatRuns.runtimeMode, startedAt: heartbeatRuns.startedAt,
agentAdapter: agents.adapterType,
error: sql<string | null>`case when ${heartbeatRuns.errorCode} = 'setup_failed'
and ${heartbeatRuns.error} = 'Project is outside this low-trust boundary.'
or ${heartbeatRuns.errorCode} = 'workspace_validation_failed' then ${heartbeatRuns.error} else null end`,
errorCode: heartbeatRuns.errorCode, livenessState: heartbeatRuns.livenessState, scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
// Candidate only: the coordinator also pushes nextAttemptAt forward while
// it defers an ineligible run. The scheduler's own admission check below
// decides whether this resume is real.
nativeResumeAt: sql<Date | null>`(select ${nativeRunFinalizations.nextAttemptAt} from ${nativeRunFinalizations}
where ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId} and ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
and ${heartbeatRuns.runtimeMode} = 'native' and ${heartbeatRuns.status} = 'failed'
and ${nativeRunFinalizations.resultId} is null and ${nativeRunFinalizations.phase} = 'retryable_failure'
and ${heartbeatRuns.contextSnapshot}->'nativeQuestionCancellation' is null
and ${heartbeatRuns.runnerProfileJson}->'legacyWorkspaceRecoveryMigration' is null)`.mapWith(value => value == null ? null : new Date(value)),
nativeFailedAt: sql<Date | null>`(select ${nativeRunFinalizations.updatedAt} from ${nativeRunFinalizations}
where ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId} and ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
and ${nativeRunFinalizations.phase} = 'terminal_failure')`.mapWith(value => value == null ? null : new Date(value)),
// The run whose resultJson supplied providerFailure. A scheduled retry
// shows its predecessor's reason, so redaction must use that run's registry.
providerFailureRunId: sql<string>`case when ${heartbeatRuns.resultJson}->'providerFailure' is null
and ${heartbeatRuns.status} = 'scheduled_retry' and ${heartbeatRuns.retryOfRunId} is not null
then ${heartbeatRuns.retryOfRunId} else ${heartbeatRuns.id} end`,
resultJson: sql<Record<string, unknown>>`jsonb_build_object('configurationIncomplete', ${heartbeatRuns.resultJson}->'configurationIncomplete',
'stopReason', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'stopReason'`, 80)},
'timeoutSource', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'timeoutSource'`, 80)},
'timeoutFired', coalesce(${heartbeatRuns.resultJson}->'timeoutFired' = 'true'::jsonb, false),
'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied',
'cancelledByActorType', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'cancelledByActorType'`, 32)},
'cancelledByUserId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'cancelledByUserId'`, 160)},
'queuedCommentInterruptQueueId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'queuedCommentInterruptQueueId'`, 160)},
'nativeCancellation', jsonb_build_object(
'schema', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'schema'`, 64)},
'runId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'runId'`, 160)},
'companyId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'companyId'`, 160)},
'issueId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'issueId'`, 160)},
'scope', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'scope'`, 32)},
'reasonCode', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'reasonCode'`, 64)},
'dispatchState', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'dispatchState'`, 32)},
'dispatched', coalesce(${heartbeatRuns.resultJson}->'nativeCancellation'->'dispatched' = 'true'::jsonb, false),
'intentAuditId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'intentAuditId'`, 160)},
'acknowledgementAuditId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'acknowledgementAuditId'`, 160)}),
'providerFailure', coalesce(${heartbeatRuns.resultJson}->'providerFailure', case when ${heartbeatRuns.status} = 'scheduled_retry'
then (select predecessor.result_json->'providerFailure' from ${heartbeatRuns} predecessor
where predecessor.company_id = ${heartbeatRuns.companyId} and predecessor.id = ${heartbeatRuns.retryOfRunId}) end))`,
}).from(requested).innerJoinLateral(latestRuns, sql`true`).innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, latestRuns.id)))
.leftJoin(agents, and(eq(agents.companyId, heartbeatRuns.companyId), eq(agents.id, heartbeatRuns.agentId)))
.orderBy(requestedIssueId),
// Keep at most two candidates per task: latest Retry and latest other input.
// A new message must not hide an existing Retry's admission explanation.
db.selectDistinctOn([wakeIssue, isRetryWait], { issueId: wakeIssue, id: agentWakeupRequests.id, requestedAt: agentWakeupRequests.requestedAt,
sourceRunId: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{taskRetry,sourceRunId}'`, 160),
executionWaitReason: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{executionWait,reason}'`, 80),
preparationStatus: chatActions.status, preparationRetryAt: chatActionDeadline("failed_run_retry"),
preparationUpdatedAt: chatActions.updatedAt })
.from(agentWakeupRequests).leftJoin(chatActions, and(eq(chatActions.companyId, agentWakeupRequests.companyId),
eq(chatActions.id, canonicalExecutionUuid(sql`${agentWakeupRequests.payload}#>>'{taskRetry,chatActionId}'`)),
eq(chatActions.kind, "failed_run_retry"), sql`${chatActions.payload}->>'taskRetryIntentId' = ${agentWakeupRequests.id}::text`,
sql`${chatActions.payload}->>'issueId' = ${wakeIssue}`,
sql`${chatActions.payload}->>'agentId' = ${agentWakeupRequests.agentId}::text`,
sql`${chatActions.payload}->>'failedRunId' = ${agentWakeupRequests.payload}#>>'{taskRetry,sourceRunId}'`,
inArray(chatActions.status, ["issued", "processing", "failed", "cancelled"])))
.where(and(eq(agentWakeupRequests.companyId, companyId), inArray(wakeIssue, ids),
eq(agentWakeupRequests.status, "deferred_issue_execution"), eq(agentWakeupRequests.requestedByActorType, "user")))
.orderBy(wakeIssue, isRetryWait, desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.id)),
agentIds.length ? db.select({ id: agents.id, status: agents.status, pauseReason: agents.pauseReason }).from(agents)
.where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))) : [],
projectIds.length ? db.select({ id: projects.id, pausedAt: projects.pausedAt, pauseReason: projects.pauseReason }).from(projects)
.where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds))) : [],
db.select({ issueId: issueTreeHoldMembers.issueId }).from(issueTreeHoldMembers).innerJoin(issueTreeHolds, and(
eq(issueTreeHolds.companyId, issueTreeHoldMembers.companyId), eq(issueTreeHolds.id, issueTreeHoldMembers.holdId)))
.where(and(eq(issueTreeHoldMembers.companyId, companyId), inArray(issueTreeHoldMembers.issueId, ids),
eq(issueTreeHolds.status, "active"), eq(issueTreeHolds.mode, "pause"))),
db.select({ issueId: issueApprovals.issueId }).from(issueApprovals).innerJoin(approvals, and(
eq(approvals.companyId, issueApprovals.companyId), eq(approvals.id, issueApprovals.approvalId)))
.where(and(eq(issueApprovals.companyId, companyId), inArray(issueApprovals.issueId, ids),
inArray(approvals.status, ["pending", "revision_requested"]))),
db.select({ status: companies.status, pauseReason: companies.pauseReason }).from(companies).where(eq(companies.id, companyId)),
// Only consulted for tasks without a run: the latest wake says whether admission refused it.
startingIssueIds.length ? db.selectDistinctOn([wakeIssue], { issueId: wakeIssue, id: agentWakeupRequests.id,
status: agentWakeupRequests.status, reason: agentWakeupRequests.reason, agentId: agentWakeupRequests.agentId })
.from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), inArray(wakeIssue, startingIssueIds)))
.orderBy(wakeIssue, desc(agentWakeupRequests.requestedAt), desc(agentWakeupRequests.id)) : [],
]);
// Advertise a pending resume only when the scheduler would admit it, using
// its own check, so the task never hides Retry behind a deferred candidate
// and never offers Retry beside a resume that will run. Rare rows only.
// Candidates are failed native runs inside an active coordinator retry, so
// there are few. Their rows load in three batched reads; admission itself
// runs a few bounded reads per candidate, four at a time.
const resumeCandidates = runs.filter(run => run.nativeResumeAt);
if (resumeCandidates.length) {
const { readNativeSessionResumptionAgent } = await import("./native-runtime/native-session-resumption-admission.js");
const candidateIds = resumeCandidates.map(run => run.id);
const [fullRuns, coordinators] = await Promise.all([
db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, candidateIds))),
db.select().from(nativeRunFinalizations).where(and(eq(nativeRunFinalizations.companyId, companyId),
inArray(nativeRunFinalizations.runId, candidateIds))),
]);
const issueIds = [...new Set(fullRuns.flatMap(run => run.nativeIssueId ? [run.nativeIssueId] : []))];
const issueRows = issueIds.length ? await db.select().from(issues).where(and(eq(issues.companyId, companyId),
inArray(issues.id, issueIds))) : [];
const runById = new Map(fullRuns.map(run => [run.id, run]));
const coordinatorByRun = new Map(coordinators.map(row => [row.runId, row]));
const issueById = new Map(issueRows.map(row => [row.id, row]));
for (let start = 0; start < resumeCandidates.length; start += 4) {
await Promise.all(resumeCandidates.slice(start, start + 4).map(async candidate => {
const run = runById.get(candidate.id);
const issue = run?.nativeIssueId ? issueById.get(run.nativeIssueId) : undefined;
const coordinator = coordinatorByRun.get(candidate.id);
const admitted = run && issue && coordinator
&& await readNativeSessionResumptionAgent(db, { issue, run, coordinator }).catch(() => null);
if (!admitted) candidate.nativeResumeAt = null;
}));
}
}
// A queued-message Interrupt records its queue on the stopped run. Newer
// input can hide that wake from the latest-wait read, so load it by id.
const interruptQueueId = (run: typeof runs[number]) => {
const id = run.status === "cancelled" ? run.resultJson?.queuedCommentInterruptQueueId : null;
return typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id) ? id : null;
};
const interruptQueueIds = runs.flatMap(run => interruptQueueId(run) ?? []);
const interruptWaits = interruptQueueIds.length ? await db.select({ issueId: wakeIssue, id: agentWakeupRequests.id,
requestedAt: agentWakeupRequests.requestedAt,
queuedInterruptRequestedAt: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{queuedCommentInterrupt,requestedAt}'`, 40) })
.from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.id, interruptQueueIds),
eq(agentWakeupRequests.status, "deferred_issue_execution"), eq(agentWakeupRequests.requestedByActorType, "user"))) : [];
const interruptWaitById = new Map(interruptWaits.map(wait => [wait.id, wait]));
const runIds = runs.map(run => run.id);
const actions = runIds.length ? await db.selectDistinctOn([issueRecoveryActions.sourceIssueId]).from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.sourceIssueId, ids),
inArray(sql`${issueRecoveryActions.evidence}->>'runId'`, runIds),
sql`${issueRecoveryActions.evidence} ? 'systemRecovery'`,
)).orderBy(issueRecoveryActions.sourceIssueId, desc(issueRecoveryActions.createdAt)) : [];
const ownerStops = runIds.length ? await db.select({ issueId: issueRecoveryActions.sourceIssueId,
runId: sql<string>`${issueRecoveryActions.evidence}->>'runId'` }).from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.sourceIssueId, ids),
eq(issueRecoveryActions.cause, "native_workspace_finalization_owner_unverified"), eq(issueRecoveryActions.ownerType, "board"),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
inArray(sql`${issueRecoveryActions.evidence}->>'runId'`, runIds),
sql`${issueRecoveryActions.evidence}->'workspaceOwnerStop' is null`,
sql`coalesce(${issueRecoveryActions.evidence}#>>'{workspaceOwnerRecovery,state}', '') <> 'resuming'`,
)) : [];
const ownerStopRequired = new Set(ownerStops.map(row => `${row.issueId}:${row.runId}`));
const byIssue = <T extends { issueId: string }>(rows: T[]) => new Map(rows.map(row => [row.issueId, row]));
const runMap = byIssue(runs), waitMap = new Map<string, typeof waits[number]>();
const retryWaitMap = byIssue(waits.filter(wait => wait.sourceRunId));
const retryIssueIds = [...retryWaitMap.keys()];
const retryDependencyMap = new Map<string, RetryDependencies>();
if (retryIssueIds.length) {
const { issueService } = await import("./issues.js");
const readiness = await issueService(db).listDependencyReadiness(companyId, retryIssueIds);
const blockerIds = [...new Set([...readiness.values()].flatMap(row => row.unresolvedBlockerIssueIds))];
const statusRows = blockerIds.length ? await db.select({ id: issues.id, status: issues.status }).from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, blockerIds))) : [];
const statusById = new Map(statusRows.map(row => [row.id, row.status]));
for (const [waitingIssueId, row] of readiness) {
const ids = [...new Set(row.unresolvedBlockerIssueIds)];
if (!ids.length) continue;
const statuses = ids.map(id => statusById.get(id));
const saving = new Set(row.pendingFinalizeBlockerIssueIds);
retryDependencyMap.set(waitingIssueId, { count: ids.length,
blocked: statuses.includes("blocked"), cancelled: statuses.includes("cancelled"),
savingFilesOnly: ids.every(id => saving.has(id)) });
}
}
for (const wait of waits) {
const current = waitMap.get(wait.issueId);
if (!current || wait.requestedAt > current.requestedAt ||
(wait.requestedAt.getTime() === current.requestedAt.getTime() && wait.id > current.id)) waitMap.set(wait.issueId, wait);
}
// Recheck current authority, including standard trust, root-issue grants and
// project policy. Do not reuse the failed run's frozen policy for a new retry.
// Only denied tasks need these reads; deduplicate projects and batch per agent.
// A task's organizational project can differ from its retained source. Old
// failures did not record the denied project, and even typed failures can
// become obsolete after a workspace selection changes.
const deniedIssues = selected.filter(issue => issue.assigneeAgentId && !["done", "cancelled"].includes(issue.status)
&& deniedProjectId(issue, runMap.get(issue.id)) !== undefined);
const requirements = await taskWorkspaceProjectRequirements(db, companyId, deniedIssues.map(issue => issue.id));
const deniedByAgent = new Map<string, Set<string>>();
for (const issue of deniedIssues) {
const ids = deniedByAgent.get(issue.assigneeAgentId!) ?? new Set<string>();
for (const projectId of requirements.get(issue.id)?.projectIds ?? []) ids.add(projectId);
if (ids.size) deniedByAgent.set(issue.assigneeAgentId!, ids);
}
const currentAccess = new Map<string, boolean>();
const authorization = authorizationService(db);
for (const [agentId, ids] of deniedByAgent) {
const projectIds = [...ids];
const decisions = await authorization.decideReads({ type: "agent", agentId, companyId, source: "agent_key" },
projectIds.map(projectId => ({ type: "project" as const, companyId, projectId })));
projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true));
}
const actionMap = new Map(actions.map(action => [action.sourceIssueId, action]));
const interruptWaitFor = (issueId: string) => {
const run = runMap.get(issueId);
const wait = run ? interruptWaitById.get(interruptQueueId(run) ?? "") : undefined;
return wait?.issueId === issueId ? wait : undefined;
};
const agentMap = new Map(agentRows.map(agent => [agent.id, agent]));
const projectMap = new Map(projectRows.map(project => [project.id, project]));
const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId));
const admissionWaitMap = byIssue(admissionWaits);
const now = Date.now();
for (const issue of selected) {
const required = requirements.get(issue.id);
const denied = required?.projectIds.filter(projectId => currentAccess.get(`${issue.assigneeAgentId}:${projectId}`) !== true);
const facts = { run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id),
retryWait: retryWaitMap.get(issue.id) };
const presentation = projectTaskInterruption(issue, { ...facts,
interruptWait: interruptWaitFor(issue.id),
projectAccessAllowed: denied?.length === 0,
projectAccessProjectId: denied?.includes(issue.projectId ?? "") ? issue.projectId : null,
workspaceSelectionAvailable: required?.available ?? false,
agent: agentMap.get(issue.assigneeAgentId ?? ""), project: projectMap.get(issue.projectId ?? ""),
company: companyRows[0], paused: pauses.has(issue.id), approval: approvalsSet.has(issue.id),
ownerStopRequired: ownerStopRequired.has(`${issue.id}:${facts.run?.id}`),
admissionWait: admissionWaitMap.get(issue.id),
retryDependencies: retryDependencyMap.get(issue.id),
}, now);
result.set(issue.id, presentation);
const run = facts.run;
const runStatus = HEARTBEAT_RUN_STATUSES.find(status => status === run?.status);
const changedAt = taskInterruptionChangedAt(facts);
if (presentation?.responsibility === "system" && presentation.state === "failed" && run && runStatus
&& (changedAt === null || changedAt > now - TASK_INTERRUPTION_REPORT_WINDOW_MS)) {
captureTaskInterruptionFailure({ companyId, taskId: issue.id, agentId: run.agentId,
runId: run.id, runStatus, intentId: presentation.intentId, runErrorCode: run.errorCode ?? null,
agentAdapter: run.agentAdapter ?? "unknown" });
}
}
// Redact each explanation with the registry of the run that recorded it: the
// workspace failure is the observed run's, while a scheduled retry's provider
// failure may be its predecessor's.
const disclosures = [...result.entries()].flatMap(([issueId, presentation]) =>
(presentation?.providerFailure || presentation?.workspaceFailure) && presentation.observedRunId
? [{ id: presentation.observedRunId, providerFailureRunId: runMap.get(issueId)?.providerFailureRunId ?? presentation.observedRunId,
providerFailure: presentation.providerFailure, workspaceFailure: presentation.workspaceFailure, presentation }] : []);
if (disclosures.length) {
try {
const [redacted, settings] = await Promise.all([
createRunSecretRedactionRegistry(db).redactForRuns(companyId, disclosures.flatMap(({ id, providerFailureRunId, providerFailure, workspaceFailure }) => [
{ id: providerFailureRunId, providerFailure, workspaceFailure: undefined },
{ id, providerFailure: undefined, workspaceFailure },
])),
instanceSettingsService(db).getGeneral(),
]);
const safe = disclosures.map((_, index) => ({ providerFailure: redacted[2 * index]?.providerFailure,
workspaceFailure: redacted[2 * index + 1]?.workspaceFailure }));
const safeMessage = (value: unknown) => typeof value === "string"
? [...redactCurrentUserText(value, { enabled: settings.censorUsernameInLogs })].slice(0, 4000).join("") : "";
disclosures.forEach(({ presentation }, index) => {
if (presentation.providerFailure) {
const value = safe[index]?.providerFailure;
const failure = readProviderFailure({ ...value, message: safeMessage(value?.message) });
if (failure) presentation.providerFailure = failure;
else delete presentation.providerFailure;
}
if (presentation.workspaceFailure) {
const message = safeMessage(safe[index]?.workspaceFailure?.message);
if (message.trim()) presentation.workspaceFailure = { message };
else delete presentation.workspaceFailure;
}
});
} catch {
for (const { presentation } of disclosures) {
delete presentation.providerFailure;
delete presentation.workspaceFailure;
}
logger.warn({ companyId, runCount: disclosures.length }, "Could not redact run failure display; explanations omitted");
}
}
return result;
}
export async function taskInterruptionPresentation(db: Db, issue: ProjectionIssue) {
return (await taskInterruptionPresentations(db, issue.companyId, [issue])).get(issue.id) ?? null;
}