mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
fix(grok): avoid false workspace recovery on Stop
Use the admitted workspace-access contract to avoid inventing file export obligations for external sessions. Keep coordinator ownership settlement and native file safeguards. Give historical no-workspace holds the correct inspection guidance. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7420af26fc
commit
1146a08135
8 files changed
+119
-20
No files matched your search
@@ -80,3 +80,5 @@ inspected recovery hold before admitting another assignment.
|
||||
stops future dispatch. Remote Grok execution may continue. Open the Grok Bot app
|
||||
to inspect it. Paperclip shows reported activity, not a complete remote transcript
|
||||
or computer recording.
|
||||
|
||||
The admitted Grok execution contract has no access to the Runner's workspace files. Its managed Runner still keeps durable recovery state, but stopping that session does not require workspace file repair. Inspect the Bot in Grok, clear the connection's recovery hold in the agent's Runtime settings, and verify the routine again before assigning new work. This records inspection without asserting global Grok termination.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Grok Bot production readiness
|
||||
|
||||
Status as of October 15: real assigned and Bot-initiated tasks reach canonical completion. The active-session wake correction is merged. A real proactive test found a delayed-admission echo; its correction is under verification. Production acceptance is incomplete. The current release goal and matrix below supersede the earlier dated campaign entries. Historical successes and failures remain evidence for the revisions actually tested.
|
||||
Status as of October 15: real assigned and Bot-initiated tasks reach canonical completion. The active-session wake correction is merged. The proactive admission echo correction is merged and source-verified; image publication and a real no-echo repeat remain pending. A real controller restart and active Stop diagnostic passed their authority checks; Stop also exposed a false workspace-repair hold, now under correction. Production acceptance is incomplete. The current release goal and matrix below supersede the earlier dated campaign entries. Historical successes and failures remain evidence for the revisions actually tested.
|
||||
|
||||
## Current production finish line — October 14
|
||||
|
||||
@@ -13,7 +13,7 @@ There is no testing call, case-count, token or spending cap. The former six-case
|
||||
| Ordinary onboarding works | Actual Grok UI, fixed agent consent, saved setup, wrong-key behavior, authenticated challenge, refresh, reconnect and revocation | Resumed real Grok authorization, saved setup Done, authenticated routine challenge and automatic refresh passed on the October 15 serving revision. Runtime reconnect copy fix is merged; verify its published UI and fresh production setup next. |
|
||||
| Assigned work reaches a usable result | One actual managed workflow: progress, question and answer, document, downloaded file/hash, canonical completion or review and cleanup | A preliminary comparison task on serving Core ff0 completed progress → question → answer → report/CSV → canonical Done, with both cleanup receipts. The cited CSV downloads with the exact publication hash. Two distinct CSV uploads accompanied an active-session result wake; correct that wake behavior and requalify the final candidate. |
|
||||
| Bot initiates governed work | Real-client task creation/claim/completion with stable receipts and no outbound echo or duplicate effects | A real idle-conversation request created one task and one managed run, saved a 100-word report, reached accepted Done and cleaned up its lease. A delayed-admission wake and two acceptance attempts were also recorded. Functional completion passed; no-echo acceptance failed. Requalify the correction. |
|
||||
| Recovery and authority are reliable | Healthy work past the prior five-minute failure window; actual controller restart preserving assignment/session/process and no duplicate wake; Stop, pause, reassignment, revocation, stale calls, queues and cleanup | The old hold was inspected and cleared. The successful comparison continuation ended below five minutes. Healthy work beyond that window, an actual restart and active Stop remain unverified; protocol tests are separate evidence. |
|
||||
| Recovery and authority are reliable | Healthy work past the prior five-minute failure window; actual controller restart preserving assignment/session/process and no duplicate wake; Stop, pause, reassignment, revocation, stale calls, queues and cleanup | A real managed diagnostic ran for 13 minutes 44 seconds, survived an actual same-image controller restart with the original assignment/session/process/lease, completed canonically and cleaned up without another wake. Active Stop fenced its work, rejected a real stale progress update, kept its checkpoint unchanged and cleaned up. Cancellation also created a false file-repair hold despite an admitted workspace-access-none contract; correct and requalify that recovery behavior. These diagnostics ran on Core 11d, not the final release image. |
|
||||
| One identifiable reviewed candidate passes source gates | Focused protocol/auth/Runner/UI tests, full CI/typecheck/tests/build/token/Storybook evidence and exact artifacts | Active-session wake correction #16394 merged with 55 passing checks, two optional skips and review 5/5. The proactive echo correction started from master 7fc2dc8b3236e1149ae32756b6153a503623404b and was rebased onto freshly fetched master 250429942e27d568f20d3bc7dddcfc84a666480a before submission. Two regression cases reproduce the real delayed-admission wake before the fix. All 214 focused protocol/auth/question checks pass, including 80 Grok and Dot checks. Repository typecheck/build, token gates, the 2,731-story structure and Storybook build pass. Fresh CI, review, publication and the real no-echo repeat remain required. |
|
||||
| Production opt-in is delivered and smoke-tested | Staging acceptance, correct served Core/distribution/Runner, normal editable opt-in controls/defaults, actual production Bot workflow and inspectable task/output links | Cloud opt-in policy and release-guide corrections are merged. Staging Cloud provenance and minimal feature values were verified, with Dot disabled and defaults unchanged. The corrected wake candidate, remaining real-client cases, ordinary production opt-in controls and production smoke are outstanding. |
|
||||
|
||||
@@ -33,6 +33,12 @@ The proactive run also received an authenticated delayed-admission webhook while
|
||||
|
||||
Normal staging automation has advanced beyond the older prepared candidate. No shared CI routing switch was changed. The earlier temporary fallback request is no longer needed for the already served successor. The dedicated production workspace still has no qualification agent grant or smoke task.
|
||||
|
||||
The proactive no-echo correction merged as #16422 after 55 successful checks, two optional Storybook skips and fresh review 5/5. Canonical source verification and exact-source migrator publication passed. The first image attempt was displaced by an older rerun before any build job started; its single retry was then superseded by a newer master push. The successor contains the fix. Neither cancellation is a successful image publication, and no shared CI routing flag changed.
|
||||
|
||||
The controlled recovery diagnostic preserved one original run for 824 seconds. The operator created the task, performed one same-image controller redeploy after checking for other work and rollouts, and sent one planned normal resume comment. The Bot read that queued comment at a safe boundary, read its checkpoint back, saved revision two and reached accepted Done. Its exact source identities and successful cleanup receipt are retained privately. The Bot corrected its own rejected document operation and reported correcting an invalid finish attachment; neither was repaired by the operator. This proves observed recovery on Core 11d, not the cause of the older failure or final-image acceptance.
|
||||
|
||||
The active Stop diagnostic cancelled its run, fenced the assignment, kept the checkpoint at revision one and recorded successful sandbox cleanup. One planned negative probe in the real Grok client returned “Assignment authority expired or was revoked.” No late completion was accepted. Inspection found a generic workspace-repair obligation attached to an external native input that explicitly forbids workspace access. Four database regressions reproduce that obligation and its misleading recovery guidance. The correction keeps coordinator settlement, no-replay holds and native file safeguards while omitting file retention for an exact admitted workspace-less external contract. Repository typecheck and 68 focused file-recovery/cancellation checks pass; an assembled heartbeat regression exposed and preserved the coordinator-settlement requirement. Fresh review, full checks and deployed recovery remain required.
|
||||
|
||||
Keep native providers and person-purpose MCP connections unchanged. No browser automation belongs in normal operation. Defer localhost relays, internal Grok APIs, marketplace distribution and Paperclip-connected third-party tools. Completion of a Paperclip work session does not assert remote Grok termination.
|
||||
|
||||
## Candidate and release contract
|
||||
|
||||
@@ -3521,7 +3521,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("retains the source when a saved native descriptor fails inside runtime selection", async () => {
|
||||
it.each([false, true])("settles a failed saved native descriptor without inventing files when workspace access is disabled: %s", async withoutWorkspace => {
|
||||
await withTempPaperclipHome(async () => {
|
||||
const f = await seedQueuedIssueRunFixture();
|
||||
await db.update(agents).set({ adapterType: "paperclip_runner",
|
||||
@@ -3547,7 +3547,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null,
|
||||
workspaceId: randomUUID(), leaseId: lease!.id, providerLeaseId: lease!.providerLeaseId!, remoteCwd: "/work", resourceDisposition: "destroy" as const };
|
||||
await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, nativePhase: "observed",
|
||||
runnerProfileJson: { nativeWorkspaceSync: reference },
|
||||
driverKind: withoutWorkspace ? "grok_bot_mcp" : null,
|
||||
runnerProfileJson: { nativeWorkspaceSync: reference, ...(withoutWorkspace ? { nativeExecutionInput: {
|
||||
schema: "paperclip.native-execution-input.v6", provider: { kind: "grok_bot" },
|
||||
binding: { companyId: f.companyId, agentId: f.agentId, runId, issueId: f.issueId },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
} } : {}) },
|
||||
}).where(eq(heartbeatRuns.id, runId));
|
||||
await db.insert(nativeRunFinalizations).values({ runId, companyId: f.companyId, issueId: f.issueId, phase: "observed" });
|
||||
await nativeWorkspaceSyncInternals.readDescriptor({ companyId: f.companyId, runId, workspaceId: reference.workspaceId, reference });
|
||||
@@ -3556,15 +3561,21 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
expect(leaseId).toBeDefined();
|
||||
expect(await heartbeat.getRun(f.runId, { includeExecutionEvidence: true })).toMatchObject({ status: "failed",
|
||||
runtimeMode: "native", nativePhase: "terminal_failure", resultJson: {
|
||||
workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { leaseIds: [leaseId] },
|
||||
},
|
||||
});
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId!)))
|
||||
.toMatchObject([{ status: "pending_cleanup", leasePolicy: "retain_on_failure" }]);
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ workspaceRepairRequired: true, canRetry: false });
|
||||
expect(release).toHaveBeenCalledWith(expect.objectContaining({ providerResourceDisposition: "stop_and_retain" }));
|
||||
const failed = await heartbeat.getRun(f.runId, { includeExecutionEvidence: true });
|
||||
expect(failed).toMatchObject({ status: "failed", runtimeMode: "native", nativePhase: "terminal_failure" });
|
||||
if (withoutWorkspace) {
|
||||
expect(failed?.resultJson?.workspaceRestoreFailure ?? null).toBeNull();
|
||||
expect(failed?.resultJson?.workspaceRestoreRecovery ?? null).toBeNull();
|
||||
expect((await getExecutionBlocker(db, f.companyId, f.issueId))?.workspaceRepairRequired).not.toBe(true);
|
||||
expect(release).toHaveBeenCalled();
|
||||
expect(release).not.toHaveBeenCalledWith(expect.objectContaining({ providerResourceDisposition: "stop_and_retain" }));
|
||||
} else {
|
||||
expect(failed?.resultJson).toMatchObject({ workspaceRestoreFailure: "restore_failed", workspaceRestoreRecovery: { leaseIds: [leaseId] } });
|
||||
expect(await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId!)))
|
||||
.toMatchObject([{ status: "pending_cleanup", leasePolicy: "retain_on_failure" }]);
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toMatchObject({ workspaceRepairRequired: true, canRetry: false });
|
||||
expect(release).toHaveBeenCalledWith(expect.objectContaining({ providerResourceDisposition: "stop_and_retain" }));
|
||||
}
|
||||
expect(provider).not.toHaveBeenCalled();
|
||||
expect(mockAdapterExecute).not.toHaveBeenCalled();
|
||||
} finally { factory.mockRestore(); }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { canContinueCancelledRun, readRunCancellation } from "./run-cancellation
|
||||
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 } from "./workspace-restore-recovery-state.js";
|
||||
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA, nativeRunHasNoWorkspaceAccess } from "./workspace-restore-recovery-state.js";
|
||||
|
||||
/** Resolved recovery bookkeeping can still carry an effective no-replay hold. */
|
||||
export function executionBlockerPredicate() {
|
||||
@@ -65,6 +65,8 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st
|
||||
const [run] = runId ? await db.select().from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, runId),
|
||||
)).limit(1) : [];
|
||||
const withoutWorkspace = Boolean(run && nativeRunHasNoWorkspaceAccess(run));
|
||||
const workspaceRepairRequired = Boolean(restoreHold && !withoutWorkspace);
|
||||
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}`,
|
||||
@@ -118,6 +120,12 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st
|
||||
nextAction += " Inspect the run before sending a new message to request continuation.";
|
||||
}
|
||||
|
||||
if (withoutWorkspace && (restoreHold || run?.status === "cancelled")) {
|
||||
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.";
|
||||
}
|
||||
|
||||
return {
|
||||
recoveryActionId: action.id,
|
||||
runId,
|
||||
@@ -129,7 +137,7 @@ export async function getExecutionBlocker(db: Db, companyId: string, issueId: st
|
||||
runError: runError?.slice(0, 1024) ?? null,
|
||||
canContinue: eligibleContinuation && !chatBinding,
|
||||
canRetry: canRetry && !chatBinding,
|
||||
...(restoreHold ? { workspaceRepairRequired: true, canRecordWorkspaceRepair: hasRequiredWorkspaceRecovery(action.evidence) } : {}),
|
||||
...(restoreHold ? { workspaceRepairRequired, canRecordWorkspaceRepair: workspaceRepairRequired && hasRequiredWorkspaceRecovery(action.evidence) } : {}),
|
||||
savedMessageCount: saved.length,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { selectHeartbeatEnvironment } from "./heartbeat/environment-selection.js
|
||||
import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js";
|
||||
import { executeHeartbeatRuntime, NativeSessionResumeScheduledError, NativeWorkspaceFinalizeScheduledError } from "./heartbeat/runtime-execution.js";
|
||||
import { preserveNativeFailedWorkspace } from "./native-runtime/native-failed-workspace-recovery.js";
|
||||
import { nativeRunHasNoWorkspaceAccess } from "./workspace-restore-recovery-state.js";
|
||||
import { selectHeartbeatRuntime } from "./heartbeat/runtime-selection.js";
|
||||
import { createHeartbeatRunCompletion } from "./heartbeat/run-completion.js";
|
||||
export {
|
||||
@@ -1940,7 +1941,7 @@ export function heartbeatService(
|
||||
const failureRun = await getRun(run.id);
|
||||
if (failureRun?.runtimeMode === "native"
|
||||
&& parseObject(failureRun.runnerProfileJson?.nativeWorkspaceSync).state === "prepared") {
|
||||
providerResourceDispositionForRun = "stop_and_retain";
|
||||
if (!nativeRunHasNoWorkspaceAccess(failureRun)) providerResourceDispositionForRun = "stop_and_retain";
|
||||
try {
|
||||
await preserveNativeFailedWorkspace(db, failureRun, {
|
||||
allowPreterminal: true,
|
||||
|
||||
@@ -76,6 +76,56 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
eq(activityLog.action, "issue.workspace_recovery_required")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
async function bindExternalInput(f: Awaited<ReturnType<typeof seed>>, provider: "grok_bot" | "openai_dot", change?: (input: { schema: string; binding: { runId: string; companyId: string; agentId: string }; workspace: { access: string } }) => void) {
|
||||
const run = await readRun(f.runId);
|
||||
const input = { schema: "paperclip.native-execution-input.v6",
|
||||
binding: { runId: f.runId, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId },
|
||||
provider: { kind: provider }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null } };
|
||||
change?.(input);
|
||||
const [updated] = await db.update(heartbeatRuns).set({ driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp",
|
||||
runnerProfileJson: { ...run.runnerProfileJson, nativeExecutionInput: input } }).where(eq(heartbeatRuns.id, f.runId)).returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
it.each(["grok_bot", "openai_dot"] as const)("does not invent file recovery for %s admitted without workspace access", async provider => {
|
||||
const f = await seed(), run = await bindExternalInput(f, provider);
|
||||
await preserveNativeFailedWorkspace(db, run);
|
||||
expect((await readRun(f.runId)).resultJson).not.toHaveProperty("workspaceRestoreFailure");
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" });
|
||||
expect(await actions(f.issueId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["run", "company", "agent", "schema", "write_access"])("preserves file safeguards when no-workspace evidence has an invalid %s", async mismatch => {
|
||||
const f = await seed();
|
||||
const run = await bindExternalInput(f, "grok_bot", input => {
|
||||
if (mismatch === "run") input.binding.runId = randomUUID();
|
||||
if (mismatch === "company") input.binding.companyId = randomUUID();
|
||||
if (mismatch === "agent") input.binding.agentId = randomUUID();
|
||||
if (mismatch === "schema") input.schema = "unknown";
|
||||
if (mismatch === "write_access") input.workspace.access = "read_write";
|
||||
});
|
||||
await preserveNativeFailedWorkspace(db, run);
|
||||
expect((await readRun(f.runId)).resultJson).toHaveProperty("workspaceRestoreFailure", "restore_failed");
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
|
||||
});
|
||||
|
||||
it.each(["grok_bot", "openai_dot"] as const)("keeps historical %s inspection holds without offering file repair for a workspace-less run", async provider => {
|
||||
const f = await seed();
|
||||
// Reproduce a hold persisted by an older cancellation path, then attach its admitted contract.
|
||||
await preserveNativeFailedWorkspace(db, f.run);
|
||||
const lease = await readLease(f.lease.id);
|
||||
await settleStopOnlyCleanup(db, lease, { attemptId: String(lease.metadata?.pendingCleanupAttemptId),
|
||||
receipt: { providerLeaseId: lease.providerLeaseId, state: "stopped" } });
|
||||
await bindExternalInput(f, provider);
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
const blocker = await getExecutionBlocker(db, f.companyId, f.issueId);
|
||||
expect(blocker).toMatchObject({ runId: f.runId, canRetry: false, canContinue: false,
|
||||
workspaceRepairRequired: false, canRecordWorkspaceRepair: false });
|
||||
expect(blocker?.nextAction).not.toContain("files");
|
||||
expect(blocker?.nextAction).toContain(provider === "grok_bot" ? "Runtime" : "Inspect");
|
||||
expect(await actions(f.issueId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("records verified file repair without retrying the native run or changing its task", async () => {
|
||||
const f = await seed();
|
||||
await db.update(issues).set({ status: "done", assigneeAgentId: null }).where(eq(issues.id, f.issueId));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq, gt, isNull, sql } from "drizzle-orm";
|
||||
import { environmentLeases, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
|
||||
import { recordRequiredWorkspaceRestoreFailure } from "../legacy-execution-recovery.js";
|
||||
import { hasRequiredWorkspaceRecovery } from "../workspace-restore-recovery-state.js";
|
||||
import { hasRequiredWorkspaceRecovery, nativeRunHasNoWorkspaceAccess } from "../workspace-restore-recovery-state.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
import { readNativeWorkspaceSyncReference } from "./native-workspace-sync.js";
|
||||
import type { NativeRestartRecoveryClaim } from "./native-restart-recovery.js";
|
||||
@@ -45,9 +45,12 @@ export async function preserveNativeFailedWorkspace(db: Db, selected: typeof hea
|
||||
)).limit(1);
|
||||
if (!source || source.issueId !== issueId || source.metadata?.remoteCwd !== currentReference.remoteCwd
|
||||
|| (source.executionWorkspaceId && source.executionWorkspaceId !== currentReference.workspaceId)) return;
|
||||
// Ownership settlement still applies to workspace-less external sessions.
|
||||
// Their Runner descriptor is not evidence that user files need exporting.
|
||||
const retainFiles = !nativeRunHasNoWorkspaceAccess(run);
|
||||
const alreadyRetained = hasRequiredWorkspaceRecovery(run.resultJson);
|
||||
let retained = alreadyRetained ? run : await recordRequiredWorkspaceRestoreFailure(tx as unknown as Db, run, { workspaceRestoreFailure: "restore_failed" }, source);
|
||||
if (!retained || !hasRequiredWorkspaceRecovery(retained.resultJson)) return;
|
||||
let retained = !retainFiles || alreadyRetained ? run : await recordRequiredWorkspaceRestoreFailure(tx as unknown as Db, run, { workspaceRestoreFailure: "restore_failed" }, source);
|
||||
if (!retained || retainFiles && !hasRequiredWorkspaceRecovery(retained.resultJson)) return;
|
||||
if (options.allowPreterminal && coordinator.phase === "observed") {
|
||||
const [settled] = await tx.update(nativeRunFinalizations).set({ phase: "terminal_failure",
|
||||
leaseOwner: null, leaseExpiresAt: null, controlDeadlineAt: null, nextAttemptAt: null,
|
||||
@@ -62,7 +65,7 @@ export async function preserveNativeFailedWorkspace(db: Db, selected: typeof hea
|
||||
[retained] = await tx.update(heartbeatRuns).set({ nativePhase: "terminal_failure", nativePhaseUpdatedAt: new Date() })
|
||||
.where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId))).returning();
|
||||
}
|
||||
if (!alreadyRetained) await logActivity(tx as unknown as Db, {
|
||||
if (retainFiles && !alreadyRetained) await logActivity(tx as unknown as Db, {
|
||||
companyId: run.companyId, actorType: "system", actorId: "native-workspace-recovery",
|
||||
action: "issue.workspace_recovery_required", entityType: "issue",
|
||||
entityId: issueId,
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import type { heartbeatRuns } from "@paperclipai/db";
|
||||
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
|
||||
|
||||
export const LEGACY_WORKSPACE_RECOVERY_KEY = "workspaceRestoreRecovery";
|
||||
export const LEGACY_WORKSPACE_RECOVERY_SCHEMA = "paperclip.workspace-restore-recovery.v1";
|
||||
|
||||
/** The admitted external contract can prohibit workspace access even though its
|
||||
* managed Runner has a synchronization descriptor for durable recovery. That
|
||||
* descriptor alone does not create a user-file export obligation. */
|
||||
export function nativeRunHasNoWorkspaceAccess(run: Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "companyId" | "agentId" | "issueId" | "nativeIssueId" | "runtimeMode" | "driverKind" | "runnerProfileJson">): boolean {
|
||||
const input = run.runnerProfileJson?.nativeExecutionInput as Record<string, unknown> | undefined;
|
||||
if (run.runtimeMode !== "native" || input?.schema !== "paperclip.native-execution-input.v6") return false;
|
||||
const binding = input.binding as Record<string, unknown> | undefined;
|
||||
const workspace = input.workspace as Record<string, unknown> | undefined;
|
||||
const provider = input.provider as Record<string, unknown> | undefined;
|
||||
const driver = provider?.kind === "grok_bot" ? "grok_bot_mcp"
|
||||
: provider?.kind === "openai_dot" ? "openai_dot_mcp" : null;
|
||||
return driver !== null && run.driverKind === driver && workspace?.access === "none" && workspace.cwd === null
|
||||
&& binding?.runId === run.id && binding.companyId === run.companyId && binding.agentId === run.agentId
|
||||
&& binding.issueId === (run.nativeIssueId ?? run.issueId);
|
||||
}
|
||||
|
||||
export function hasRequiredWorkspaceRecovery(result: Record<string, unknown> | null | undefined): boolean {
|
||||
const value = result?.[LEGACY_WORKSPACE_RECOVERY_KEY];
|
||||
return hasWorkspaceRestoreFailure(result) && !!value && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
Reference in new issue
Block a user