mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 00:56:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A board user can stop or interrupt a native run and send a message for the next turn > - When the stop leaves sandbox files unsaved, automatic recovery saves them, re-stops the sandbox, and the saved message continues > - For agents whose sandbox is destroyed after each turn (an ephemeral lease), recovery then queues the destroy. That step removed the lease's stop receipt > - The continuation gate waits for a stop receipt, so the message waited for the destroy and its cleanup retry, about 5 to 8 minutes > - Agents with retained sandboxes continued within seconds, so the delay looked agent specific > - This pull request keeps the verified stop on the cleanup lease and lets the continuation and the next remote writer accept it > - The benefit is that a redirect, Retry or interrupt continues right after its files are saved, for every lease policy ## Linked Issues or Issue Description Refs #19206 **What happened?** On hosted staging, a Stop or `interrupt: true` comment on a native run left a follow-up turn that started 5 to 8 minutes later for some agents, and 25 to 40 seconds later for others. The slow agents use ephemeral sandbox leases. In each slow case, the stopped run's lease was released by `pending_cleanup_retry` about 5 minutes after recovery, and the saved message started one second later. The fast agents use retained leases, which recovery releases at once. **Expected behavior** The saved message continues right after recovery saves the files and the sandbox is stopped. The destroy of a stopped sandbox is resource cleanup and must not hold the next turn. **Steps to reproduce** 1. Use an agent whose sandbox lease is ephemeral (destroy after each turn). 2. Interrupt or stop its native run so that recovery must save the sandbox files. 3. Post a message. See the follow-up start only after the sandbox destroy finishes. **Paperclip version or commit** Hosted, at `56517dda`. ## What Changed - `server/src/services/workspace-recovery.ts`: `scheduleRecoveredWorkspaceCleanup` keeps the verified stop receipt as `workspaceRecoveryCleanup.stopReceipt` when it queues the destroy. - `server/src/services/remote-execution-termination.ts`: new `hasRecoveredCleanupStopReceipt(lease)`. It is true only when the lease has recovery cleanup for its own run, was released, and the carried receipt matches the lease, run, provider and provider lease id. - `server/src/services/explicit-native-continuation.ts`: the `remote_cleanup` gate accepts that receipt. - `server/src/services/execution-blocker.ts`: `hasPendingRunCleanup` ignores a lease with that valid receipt, so a saved native interrupt send (and Retry or fresh-workspace checks) does not wait for the destroy. Other cleanup holds are unchanged. - `server/src/services/remote-workspaces.ts`: the next remote writer accepts that receipt for the ended writer's lease. - `server/src/services/execution-blocker.ts` (from #19206 review, not in the merged head): pre-turn evidence uses the exact startup lifecycle event list, so turn-scoped events such as `workspace.diff.recorded` count as provider work. - `doc/execution-semantics.md`: describes the rule. ## Verification - Red before the fix, green after: `cd server && npx vitest run src/services/workspace-recovery.test.ts -t "admits the interrupt redirect"`. An interrupted native run with recovered files and a queued destroy blocked the user message with `remote_cleanup` before the fix. After the fix the message is admitted. A lease without the stop receipt, or with a receipt for another run, still blocks with `remote_cleanup`. The test runs the same input as a plain message and as a saved native interrupt send (`queuedCommentInterruptId`); `hasPendingRunCleanup` was true before the fix and blocked the interrupt send with `cleanup_pending`. - `cd server && npx vitest run src/services/remote-workspace-reservation.test.ts -t "ended writer"`: the next writer reserves the workspace from an ended writer whose recovered lease awaits destroy; a missing or foreign receipt still blocks. Removing the new branch fails the positive case. - Updated `saves the authoritative remote workspace after native failed ...`: Retry now continues before the destroy, and the cleanup lease carries the stop receipt for its run and lease. - `workspace-recovery.test.ts -t "provider work cannot be excluded"` includes `workspace.diff.recorded` as provider work. - Related suites pass: `workspace-recovery.test.ts`, `explicit-native-continuation.test.ts`, `remote-workspace-reservation.test.ts`, `remote-workspaces.integration.test.ts`, `remote-workspace-lifecycle.test.ts`, `task-retry*.test.ts`, `native-cancelled-workspace-preservation.test.ts`, `workspace-recovery-source.test.ts`. `cd server && npx tsc --noEmit` passes. - Hosted evidence (read only): slow cases have ephemeral leases released by `pending_cleanup_retry` about 5 minutes after `issue.workspace_recovered`, with settlement one second later. Fast cases have retained leases released by recovery. ## Risks - Low to medium. The next turn can start while the old stopped sandbox is still being destroyed. The receipt proves that the sandbox was stopped after the files were saved, and an ephemeral successor uses a new sandbox. A destroy failure stays with the existing cleanup retry. - Leases without the carried receipt behave as before. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
374 lines
26 KiB
TypeScript
374 lines
26 KiB
TypeScript
import { isPreDispatchReviewWait, neverStartedDispatchCancellationPredicate } from "./pre-dispatch-review-wait.js";
|
|
import { canonicalExecutionUuid } from "./execution-identity.js";
|
|
import { preDispatchExecutionWaitRecoveryActionPredicate } from "./pre-dispatch-execution-wait.js";
|
|
import { and, desc, eq, gt, inArray, not, or, sql, type SQL } from "drizzle-orm";
|
|
import { conversationRecoveryActionPredicate, getConversationOwnershipBlocker } from "./conversation-continuation.js";
|
|
import { z } from "zod";
|
|
import { agentWakeupRequests, chatConversations, chatEndpoints, environmentLeases, heartbeatRunEvents, heartbeatRuns, issueComments, issues, issueRecoveryActions, nativeRunFinalizations, nativeRunResults, toolConnections, workspaceOperations, type Db } from "@paperclipai/db";
|
|
import { canContinueCancelledRun, readRunCancellation } from "./run-cancellation.js";
|
|
import { canRetryStoppedRun } from "./cancelled-native-startup.js";
|
|
import { queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
|
|
import { EXECUTION_RECONCILIATION_CAUSES, type ExecutionBlocker } from "@paperclipai/shared";
|
|
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA, nativeRunHasNoWorkspaceAccess } from "./workspace-restore-recovery-state.js";
|
|
import { unreconciledNativeExportPredicate } from "./native-runtime/native-workspace-reconstruction.js";
|
|
import { hasRecoveredCleanupStopReceipt, hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
|
|
import { readNativeWorkspaceSyncReference } from "./native-runtime/native-workspace-sync.js";
|
|
import { completedWorkspaceRecoveryCondition, hasVerifiedWorkspaceRepair, workspaceRecoveryDisplay } from "./workspace-recovery-receipt.js";
|
|
import { nativeManualAdmissionHoldsExecution } from "./native-runtime/native-runner-ownership.js";
|
|
|
|
const blockerContextKeys = ["issueId", "taskId", "source", "connectionIntentResolution",
|
|
"interactionKind", "interactionStatus", "mutation", "interactionId"] as const;
|
|
const blockerProfileKeys = ["adapterDispatch", "nativeExecutionInput", "nativeWorkspaceSync", "remoteWorkspace", "legacyWorkspaceRecoveryMigration", "finalizedWorkspaceRecoveryMigration"] as const;
|
|
const blockerContextColumn = sql<Record<string, unknown> | null>`case
|
|
when ${heartbeatRuns.contextSnapshot} is null then null else (
|
|
select coalesce(jsonb_object_agg(field.key, field.value), '{}'::jsonb)
|
|
from jsonb_each(case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object'
|
|
then ${heartbeatRuns.contextSnapshot} else '{}'::jsonb end) as field(key, value)
|
|
where field.key in (${sql.join(blockerContextKeys.map(key => sql`${key}`), sql`, `)})
|
|
) end`;
|
|
const blockerProfileColumn = sql<Record<string, unknown> | null>`case
|
|
when ${heartbeatRuns.runnerProfileJson} is null then null else (
|
|
select coalesce(jsonb_object_agg(field.key, case field.key
|
|
when 'adapterDispatch' then jsonb_build_object('adapterType', field.value->'adapterType')
|
|
when 'nativeExecutionInput' then case when jsonb_typeof(field.value) = 'object' then (
|
|
select coalesce(jsonb_object_agg(input_field.key, input_field.value), '{}'::jsonb)
|
|
from jsonb_each(field.value) as input_field(key, value)
|
|
where input_field.key in ('schema', 'binding', 'provider', 'workspace')
|
|
) else field.value end
|
|
else field.value end), '{}'::jsonb)
|
|
from jsonb_each(case when jsonb_typeof(${heartbeatRuns.runnerProfileJson}) = 'object'
|
|
then ${heartbeatRuns.runnerProfileJson} else '{}'::jsonb end) as field(key, value)
|
|
where field.key in (${sql.join(blockerProfileKeys.map(key => sql`${key}`), sql`, `)})
|
|
) end`;
|
|
const blockerRunFields = {
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
status: heartbeatRuns.status,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
error: heartbeatRuns.error,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
issueId: heartbeatRuns.issueId,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
nativeSessionId: heartbeatRuns.nativeSessionId,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
runtimeModeResolvedAt: heartbeatRuns.runtimeModeResolvedAt,
|
|
driverKind: heartbeatRuns.driverKind,
|
|
processPid: heartbeatRuns.processPid,
|
|
processGroupId: heartbeatRuns.processGroupId,
|
|
processStartedAt: heartbeatRuns.processStartedAt,
|
|
sessionIdAfter: heartbeatRuns.sessionIdAfter,
|
|
controllerBootId: heartbeatRuns.controllerBootId,
|
|
controllerLeaseExpiresAt: heartbeatRuns.controllerLeaseExpiresAt,
|
|
executionStage: heartbeatRuns.executionStage,
|
|
resultJson: heartbeatRuns.resultJson,
|
|
contextSnapshot: blockerContextColumn,
|
|
runnerProfileJson: blockerProfileColumn,
|
|
};
|
|
|
|
export async function hasPendingRunCleanup(db: Db, companyId: string, runId: string): Promise<boolean> {
|
|
if (await nativeManualAdmissionHoldsExecution(db, { companyId, runId })) return true;
|
|
// Recovery saved the files and re-stopped this sandbox before queueing its
|
|
// destroy. That destroy is resource cleanup, not a live owner of the run.
|
|
const recoveredStopped = (await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, runId),
|
|
sql`${environmentLeases.metadata}->'workspaceRecoveryCleanup' is not null`,
|
|
))).filter(hasRecoveredCleanupStopReceipt).map(lease => lease.id);
|
|
const [state] = await db.execute<{ pending: boolean }>(sql`SELECT
|
|
${heartbeatRuns.runnerProfileJson}->>'nativeWorkspaceFinalizationOwner' IS NOT NULL
|
|
OR EXISTS (SELECT 1 FROM ${environmentLeases}
|
|
WHERE ${environmentLeases.companyId} = ${companyId} AND ${environmentLeases.heartbeatRunId} = ${runId}
|
|
${recoveredStopped.length ? sql`AND ${environmentLeases.id}::text NOT IN (${sql.join(recoveredStopped.map(id => sql`${id}`), sql`, `)})` : sql``}
|
|
AND (( ${environmentLeases.releasedAt} IS NULL AND NOT COALESCE(
|
|
${environmentLeases.status} = 'retained' AND ${environmentLeases.leasePolicy} = 'reuse_by_environment'
|
|
AND ${environmentLeases.cleanupStatus} = 'success', false))
|
|
OR ${environmentLeases.status} IN ('active', 'pending_cleanup') OR ${environmentLeases.cleanupStatus} = 'failed'
|
|
OR COALESCE(${environmentLeases.metadata}->>'pendingCleanupInFlight', 'false') <> 'false'
|
|
OR (${heartbeatRuns.runtimeMode} = 'native' AND NOT COALESCE(
|
|
(${environmentLeases.status} IN ('released', 'expired', 'failed')
|
|
AND ${environmentLeases.releasedAt} IS NOT NULL AND (${environmentLeases.cleanupStatus} = 'success'
|
|
OR (${environmentLeases.cleanupStatus} IS NULL AND (
|
|
(${environmentLeases.provider} = 'local' AND ${environmentLeases.providerLeaseId} IS NULL)
|
|
OR (${environmentLeases.provider} = 'ssh' AND ${environmentLeases.metadata}->>'driver' = 'ssh'
|
|
AND ${environmentLeases.providerLeaseId} LIKE 'ssh://%')))))
|
|
OR (${environmentLeases.status} = 'retained' AND ${environmentLeases.leasePolicy} = 'reuse_by_environment'
|
|
AND ${environmentLeases.cleanupStatus} = 'success'), false))))
|
|
OR EXISTS (SELECT 1 FROM ${nativeRunFinalizations}
|
|
WHERE ${nativeRunFinalizations.companyId} = ${companyId} AND ${nativeRunFinalizations.runId} = ${runId}
|
|
AND (${nativeRunFinalizations.leaseOwner} IS NOT NULL
|
|
OR ${nativeRunFinalizations.phase} NOT IN ('committed', 'applied', 'terminal_failure'))) AS pending
|
|
FROM ${heartbeatRuns} WHERE ${heartbeatRuns.companyId} = ${companyId} AND ${heartbeatRuns.id} = ${runId} LIMIT 1`);
|
|
return state?.pending !== false;
|
|
}
|
|
|
|
/** Resolved recovery bookkeeping can still carry an effective no-replay hold. */
|
|
export function executionBlockerPredicate() {
|
|
return and(
|
|
not(conversationRecoveryActionPredicate()!),
|
|
not(preDispatchExecutionWaitRecoveryActionPredicate()),
|
|
sql`not (${issueRecoveryActions.cause} = 'legacy_execution_requires_reconciliation' and exists (
|
|
select 1 from ${heartbeatRuns}
|
|
where ${heartbeatRuns.companyId} = ${issueRecoveryActions.companyId}
|
|
and ${heartbeatRuns.id} = ${canonicalExecutionUuid(sql`coalesce(${issueRecoveryActions.evidence}->>'runId', ${issueRecoveryActions.evidence}->>'sourceRunId')`)}
|
|
and ${issueRecoveryActions.sourceIssueId} = ${canonicalExecutionUuid(sql`${heartbeatRuns.contextSnapshot}->>'issueId'`)}
|
|
and (${heartbeatRuns.issueId} is null or ${heartbeatRuns.issueId} = ${issueRecoveryActions.sourceIssueId})
|
|
and ${neverStartedDispatchCancellationPredicate()}
|
|
and ${heartbeatRuns.errorCode} <> 'issue_continuation_waiting_on_review'
|
|
and coalesce(${issueRecoveryActions.evidence}->>'workspaceRestoreFailure', '') <> 'restore_unsafe_archive'
|
|
and coalesce(${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema', '') <> ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}
|
|
))`,
|
|
or(unreconciledNativeExportPredicate(), and(
|
|
inArray(issueRecoveryActions.cause, [...EXECUTION_RECONCILIATION_CAUSES]),
|
|
or(inArray(issueRecoveryActions.status, ["active", "escalated"]),
|
|
sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`),
|
|
)),
|
|
);
|
|
}
|
|
|
|
export function nativeExecutionRecoveryActionPredicate() {
|
|
return sql`exists (select 1 from ${heartbeatRuns}
|
|
where ${heartbeatRuns.companyId} = ${issueRecoveryActions.companyId}
|
|
and ${heartbeatRuns.id}::text = coalesce(${issueRecoveryActions.evidence}->>'runId', ${issueRecoveryActions.evidence}->>'sourceRunId')
|
|
and ${heartbeatRuns.runtimeMode} = 'native')`;
|
|
}
|
|
|
|
// Exact runner startup lifecycle events (event-v3 schema): they open no turn.
|
|
// Every other runner-sourced event counts as provider work, so a new, unlisted
|
|
// or turn-scoped event (for example workspace.diff.recorded or a session goal)
|
|
// keeps the hold (fail closed). Server-side records without a source event
|
|
// (spans, process identity, lifecycle) count only when they name turn or
|
|
// provider work.
|
|
export const PROVIDER_STARTUP_EVENT_TYPES = ["harness.starting", "harness.ready", "harness.diagnostic", "harness.exited",
|
|
"session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.updated",
|
|
"session.capabilities.updated", "session.closed", "session.failed", "workspace.ready", "runtime.phase.changed",
|
|
"runtime.activity.recorded", "provider.notice.recorded", "auth.credentials.updated", "run.attached", "run.detached",
|
|
"mcp_app.discovered", "mcp_app.initializing", "mcp_app.ready", "mcp_app.resource.resolved",
|
|
"mcp_app.host_context.changed", "mcp_app.teardown", "mcp_app.failed"] as const;
|
|
const SERVER_TURN_WORK_EVENT_PREFIXES = ["turn.", "item.", "tool.", "semantic_tool.", "run.result."] as const;
|
|
const SERVER_TURN_WORK_EVENT_TYPES = ["provider.event", "provider.rpc_result"] as const;
|
|
|
|
const anyPrefix = (eventType: SQL, prefixes: readonly string[]) =>
|
|
sql.join(prefixes.map(prefix => sql`starts_with(${eventType}, ${prefix})`), sql` or `);
|
|
const anyType = (eventType: SQL, types: readonly string[]) =>
|
|
sql`${eventType} in (${sql.join(types.map(type => sql`${type}`), sql`, `)})`;
|
|
|
|
/** SQL truth for an event row (alias columns) that may prove provider turn work. */
|
|
export function providerTurnWorkEventCondition(eventType: SQL, sourceEventId: SQL) {
|
|
return sql`((${sourceEventId} is not null and not ${anyType(eventType, PROVIDER_STARTUP_EVENT_TYPES)})
|
|
or ${anyType(eventType, SERVER_TURN_WORK_EVENT_TYPES)} or ${anyPrefix(eventType, SERVER_TURN_WORK_EVENT_PREFIXES)})`;
|
|
}
|
|
|
|
/**
|
|
* A Stop that the native startup owner fenced before it published a provider
|
|
* handle proves that no provider turn ran. The run's own durable record must
|
|
* agree: no turn, tool or provider event and no native result. `stopped` is
|
|
* the SQL alias of the stopped run.
|
|
*/
|
|
export function stoppedBeforeProviderTurnCondition() {
|
|
return sql`(stopped.status = 'cancelled' and stopped.runtime_mode = 'native'
|
|
and stopped.result_json->'nativeCancellation'->>'schema' = 'paperclip.native-cancellation.v1'
|
|
and stopped.result_json->'nativeCancellation'->>'runId' = stopped.id::text
|
|
and stopped.result_json->'nativeCancellation'->>'scope' = 'run'
|
|
and stopped.result_json->'nativeCancellation'->>'dispatchState' = 'acknowledged'
|
|
and stopped.result_json->'nativeCancellation'->'beforeProviderTurn' = 'true'::jsonb
|
|
and not exists (select 1 from ${heartbeatRunEvents} work
|
|
where work.company_id = stopped.company_id and work.run_id = stopped.id
|
|
and ${providerTurnWorkEventCondition(sql`work.event_type`, sql`work.source_event_id`)})
|
|
and not exists (select 1 from ${nativeRunResults} result
|
|
where result.company_id = stopped.company_id and result.run_id = stopped.id))`;
|
|
}
|
|
|
|
/**
|
|
* A file-preservation hold binds a newer task owner only until automatic
|
|
* recovery saved the stopped run's files. That owner's turn is a fresh
|
|
* assignment, not a replay: the stopped run's unknown outcomes stay with its
|
|
* own agent, which keeps the hold. A Stop fenced before any provider turn has
|
|
* no unknown outcome, so its hold releases for the same agent too. The proof is
|
|
* the durable completed-recovery receipt bound to this action, so later reuse
|
|
* of the workspace by the current owner (new epoch, writer or snapshot) cannot
|
|
* re-arm the hold.
|
|
*/
|
|
export function restoreHoldReleasedForCurrentOwnerPredicate() {
|
|
const sourceRunId = sql`coalesce(${issueRecoveryActions.evidence}->>'runId', ${issueRecoveryActions.evidence}->>'sourceRunId')`;
|
|
const operationIds = sql`case when jsonb_typeof(${issueRecoveryActions.evidence}#>'{systemRecovery,preservationOperationIds}') = 'array'
|
|
then ${issueRecoveryActions.evidence}#>'{systemRecovery,preservationOperationIds}' else '[]'::jsonb end`;
|
|
// JSON reads are NULL for other holds. Callers negate this predicate, so it
|
|
// must be strictly true or false: NOT NULL would hide unrelated blockers.
|
|
return sql`coalesce((${and(
|
|
sql`${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`,
|
|
sql`coalesce(${issueRecoveryActions.evidence}->>'workspaceRestoreFailure', '') <> 'restore_unsafe_archive'`,
|
|
sql`exists (select 1 from ${heartbeatRuns} stopped
|
|
join ${issues} task on task.company_id = stopped.company_id and task.id = ${issueRecoveryActions.sourceIssueId}
|
|
where stopped.company_id = ${issueRecoveryActions.companyId}
|
|
and stopped.id::text = ${sourceRunId}
|
|
and stopped.status in ('failed', 'timed_out', 'interrupted', 'cancelled')
|
|
and stopped.finished_at is not null
|
|
and task.assignee_agent_id is not null
|
|
and ((task.assignee_agent_id <> stopped.agent_id
|
|
and not coalesce(task.status = 'in_review'
|
|
and task.execution_state->>'status' = 'pending'
|
|
and task.execution_state->'currentParticipant'->>'type' = 'agent'
|
|
and task.execution_state->'currentParticipant'->>'agentId' = stopped.agent_id::text, false))
|
|
or ${stoppedBeforeProviderTurnCondition()}))`,
|
|
completedWorkspaceRecoveryCondition(sourceRunId),
|
|
// Copyback put the files back into the task workspace that the new owner
|
|
// reuses. A snapshot-only save needs an explicit fresh-workspace request
|
|
// to attach it, so that hold stays until such a request or a repair.
|
|
sql`not exists (select 1 from jsonb_array_elements_text(${operationIds}) saved(id)
|
|
join ${workspaceOperations} op on op.id::text = saved.id
|
|
where op.company_id = ${issueRecoveryActions.companyId} and op.phase <> 'workspace_recovery')`,
|
|
)}), false)`;
|
|
}
|
|
|
|
export async function getExecutionBlocker(db: Db, companyId: string, issueId: string, options?: { conversationResetCommentId?: string | null }): Promise<ExecutionBlocker | null> {
|
|
const [conversation] = await db.select({ agentId: issues.conversationAgentId,
|
|
boundaryId: issues.conversationBoundaryCommentId, status: issues.status,
|
|
assigneeAgentId: issues.assigneeAgentId, statusVersion: issues.statusVersion,
|
|
executionRunId: issues.executionRunId, checkoutRunId: issues.checkoutRunId }).from(issues).where(and(
|
|
eq(issues.companyId, companyId), eq(issues.id, issueId),
|
|
)).limit(1);
|
|
// Resetting model context cannot repair or recover a workspace. This hold
|
|
// survives conversation boundaries until the existing repair/reconciliation path clears it.
|
|
const [restoreHold] = await db.select().from(issueRecoveryActions).where(and(
|
|
eq(issueRecoveryActions.companyId, companyId),
|
|
eq(issueRecoveryActions.sourceIssueId, issueId),
|
|
executionBlockerPredicate(),
|
|
not(restoreHoldReleasedForCurrentOwnerPredicate()),
|
|
or(
|
|
unreconciledNativeExportPredicate(),
|
|
sql`${issueRecoveryActions.evidence}->>'workspaceRestoreFailure' = 'restore_unsafe_archive'`,
|
|
sql`${issueRecoveryActions.evidence}->'workspaceRestoreRecovery'->>'schema' = ${LEGACY_WORKSPACE_RECOVERY_SCHEMA}`,
|
|
),
|
|
)).orderBy(desc(issueRecoveryActions.updatedAt)).limit(1);
|
|
// A persisted user /new is an ordered context command, not a retry of uncertain work.
|
|
// The normal issue execution lock still serializes it behind any active turn.
|
|
if (!restoreHold && conversation?.agentId && options?.conversationResetCommentId) {
|
|
const [command] = await db.select().from(issueComments).where(and(
|
|
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
|
eq(issueComments.id, options.conversationResetCommentId),
|
|
)).limit(1);
|
|
if (command?.authorUserId && !command.deletedAt && command.body.trim() === "/new") return null;
|
|
}
|
|
const [boundary] = conversation?.agentId && conversation.boundaryId
|
|
? await db.select({ createdAt: issueComments.createdAt }).from(issueComments).where(and(
|
|
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
|
eq(issueComments.id, conversation.boundaryId),
|
|
)).limit(1) : [];
|
|
|
|
const ownership = await getConversationOwnershipBlocker(db, companyId, issueId);
|
|
if (ownership) return { ...ownership, recoveryActionId: null };
|
|
const [action] = restoreHold ? [restoreHold] : await db.select().from(issueRecoveryActions).where(and(
|
|
eq(issueRecoveryActions.companyId, companyId),
|
|
eq(issueRecoveryActions.sourceIssueId, issueId),
|
|
executionBlockerPredicate(),
|
|
not(restoreHoldReleasedForCurrentOwnerPredicate()),
|
|
boundary ? or(gt(issueRecoveryActions.createdAt, boundary.createdAt), nativeExecutionRecoveryActionPredicate()) : undefined,
|
|
)).orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id)).limit(1);
|
|
if (!action) return null;
|
|
const parsedRunId = z.string().guid().safeParse(action.evidence.runId ?? action.evidence.sourceRunId);
|
|
const runId = parsedRunId.success ? parsedRunId.data : null;
|
|
const [run] = runId ? await db.select(blockerRunFields).from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId),
|
|
)).limit(1) : [];
|
|
const withoutWorkspace = Boolean(run?.driverKind === "grok_bot_mcp" && nativeRunHasNoWorkspaceAccess(run));
|
|
const workspaceRepairRequired = Boolean(restoreHold && !withoutWorkspace && (!run || !await hasVerifiedWorkspaceRepair(db, run)));
|
|
const workspaceRecovery = workspaceRecoveryDisplay(action.evidence, runId);
|
|
const reconstructableExport = ["native_workspace_sync_out_unrecoverable", "native_workspace_sync_out_retry_exhausted"].includes(action.cause);
|
|
const exportSource = reconstructableExport ? readNativeWorkspaceSyncReference(run?.runnerProfileJson?.nativeWorkspaceSync) : null;
|
|
const [exportLease] = exportSource ? await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, run!.id), eq(environmentLeases.id, exportSource.leaseId),
|
|
eq(environmentLeases.providerLeaseId, exportSource.providerLeaseId),
|
|
)).limit(1) : [];
|
|
const canReconstructWorkspace = Boolean(exportLease && hasRemoteTerminationReceipt(exportLease)
|
|
&& (exportLease.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "destroyed"
|
|
&& !action.evidence.executionReconciliation && conversation && ["blocked", "in_progress"].includes(conversation.status)
|
|
&& !conversation.executionRunId && !conversation.checkoutRunId && conversation.assigneeAgentId === run?.agentId);
|
|
const reconstructionPending = Boolean((action.evidence.executionReconciliation as { workspaceReconstruction?: unknown } | undefined)?.workspaceReconstruction
|
|
&& action.evidence.continuationDelivery === "pending");
|
|
const queue = await db.select({ payload: agentWakeupRequests.payload }).from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
|
sql`${agentWakeupRequests.payload}->>'issueId' = ${issueId}`,
|
|
));
|
|
const savedIds = [...new Set(queue.flatMap(entry => {
|
|
const context = entry.payload?._paperclipWakeContext as Record<string, unknown> | undefined;
|
|
return [...queuedCommentIdsFromWakePayload(entry.payload), entry.payload?.commentId, context?.wakeCommentId]
|
|
.filter((id): id is string => z.string().guid().safeParse(id).success);
|
|
}))];
|
|
const saved = savedIds.length ? await db.select({ id: issueComments.id }).from(issueComments).where(and(
|
|
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
|
inArray(issueComments.id, savedIds), eq(issueComments.authorType, "user"), sql`${issueComments.deletedAt} is null`,
|
|
sql`not exists (select 1 from (
|
|
select consumed.context_snapshot->>'wakeCommentId' as comment_id,
|
|
consumed.context_snapshot->'wakeCommentIds' as comment_ids
|
|
from ${heartbeatRuns} consumed where consumed.company_id = ${companyId}
|
|
and consumed.context_snapshot->>'issueId' = ${issueId}
|
|
and not (consumed.status = 'cancelled' and consumed.started_at is null)
|
|
offset 0
|
|
) consumed where consumed.comment_id = ${issueComments.id}::text
|
|
or consumed.comment_ids @> jsonb_build_array(${issueComments.id}::text))`,
|
|
)) : [];
|
|
const cancellation = run ? readRunCancellation(run.resultJson) : null;
|
|
const eligibleContinuation = Boolean(!restoreHold && run && conversation?.assigneeAgentId === run.agentId &&
|
|
!["done", "cancelled"].includes(conversation.status) && canContinueCancelledRun(run));
|
|
const canRetry = Boolean(!restoreHold && run && conversation?.assigneeAgentId === run.agentId &&
|
|
!["done", "cancelled"].includes(conversation.status) && await canRetryStoppedRun(db, run));
|
|
const runError = run && canRetry && isPreDispatchReviewWait(run)
|
|
? "Waiting for review; this continuation never started."
|
|
: cancellation?.reason ?? (run?.status === "cancelled"
|
|
? "Execution was cancelled; its source was not recorded."
|
|
: run?.error);
|
|
const [chatBinding] = eligibleContinuation || canRetry ? await db.select({
|
|
state: chatConversations.state, endpointStatus: chatEndpoints.status,
|
|
connectionStatus: toolConnections.status, connectionEnabled: toolConnections.enabled,
|
|
}).from(chatConversations).leftJoin(chatEndpoints, and(
|
|
eq(chatEndpoints.companyId, companyId), eq(chatEndpoints.id, chatConversations.endpointId),
|
|
)).leftJoin(toolConnections, and(
|
|
eq(toolConnections.companyId, companyId), eq(toolConnections.id, chatEndpoints.connectionId),
|
|
)).where(and(
|
|
eq(chatConversations.companyId, companyId), eq(chatConversations.issueId, issueId),
|
|
)).limit(1) : [];
|
|
let nextAction = action.nextAction;
|
|
if (canRetry && !eligibleContinuation) nextAction += " Try again or send a new message to continue once the previous execution has stopped.";
|
|
if (chatBinding && !restoreHold) {
|
|
if (chatBinding.state === "endpoint_removed" || !chatBinding.endpointStatus || chatBinding.endpointStatus === "archived") {
|
|
nextAction = "This chat connection was removed. Inspect the stopped run and create a new task to continue the work.";
|
|
} else if (chatBinding.state === "unavailable" || !["active", "verifying"].includes(chatBinding.endpointStatus) ||
|
|
chatBinding.connectionStatus !== "active" || !chatBinding.connectionEnabled) {
|
|
nextAction = "This chat connection is unavailable. Restore access in Apps or create a new task to continue the work.";
|
|
} else {
|
|
nextAction = "Send a new chat message to continue this conversation.";
|
|
}
|
|
} else if (!restoreHold && run?.status === "cancelled" && !eligibleContinuation && !canRetry && action.cause === "legacy_execution_requires_reconciliation") {
|
|
nextAction += " Inspect the run before sending a new message to request continuation.";
|
|
}
|
|
|
|
if (withoutWorkspace && (restoreHold || run?.status === "cancelled") && !run?.resultJson?.externalWorkspaceInspection) {
|
|
nextAction = run?.driverKind === "grok_bot_mcp"
|
|
? "Inspect this Bot in Grok Bot, then clear the recovery hold and verify the connection in the agent's Runtime settings before assigning more work."
|
|
: "Inspect the stopped work in the external agent and its connection before requesting continuation.";
|
|
}
|
|
|
|
if (run?.resultJson?.externalWorkspaceInspection && !restoreHold) {
|
|
nextAction = "Verify the Grok connection, then send a new message to continue. The stopped work will not be replayed.";
|
|
}
|
|
|
|
return {
|
|
recoveryActionId: action.id,
|
|
runId,
|
|
// A stopped reviewer can differ from the task owner who receives the work back.
|
|
agentId: run?.agentId ?? null,
|
|
cause: action.cause,
|
|
nextAction: workspaceRecovery?.message ?? (reconstructionPending ? "Continuation from saved sources is queued and waiting for execution checks." : nextAction),
|
|
...(workspaceRecovery ? { workspaceRecovery } : {}),
|
|
runStatus: run?.status ?? null,
|
|
runError: runError?.slice(0, 1024) ?? null,
|
|
canContinue: eligibleContinuation && !chatBinding,
|
|
canRetry: canRetry && !chatBinding,
|
|
...(restoreHold ? { workspaceRepairRequired, canRecordWorkspaceRepair: workspaceRepairRequired && hasRequiredWorkspaceRecovery(action.evidence) } : {}),
|
|
savedMessageCount: saved.length,
|
|
...(canReconstructWorkspace ? { canReconstructWorkspace, workspaceRecoveryStatusVersion: conversation!.statusVersion } : {}),
|
|
...(reconstructionPending ? { workspaceReconstructionPending: true } : {}),
|
|
};
|
|
}
|