fix: resume unfinished work after repository preparation

Use the existing same-agent continuation and status ledger for deferred repository setup. Preserve the accepted explanation and verified chat destination across its next admission.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-20 20:52:33 -05:00
1 parent c2daf906d9
commit d7ca68d4eb
16 files changed
+223 -23

No files matched your search

+14 -3
View File
@@ -153,6 +153,13 @@ and the matching server-issued scheduling receipt on the source run. Company,
task, agent, requester, schedule identity, and current delivery authorization
remain enforced. Provider-supplied source IDs do not establish that lineage.
An accepted same-agent continuation retains the same presentation contract.
Its successor inherits conversation provenance only through the applied status
decision, delivered continuation effect, and admitted wake that name the same
company, task, agent, and requester. Dispatch still checks current endpoint,
principal, and resource authorization. These receipts do not grant execution
or override Stop.
A successful Slack turn with a published final reply and no remaining execution
or decision path settles to `chat_conversations.state = waiting` and issue
`in_review`. This is a server-owned passive conversation state, displayed as
@@ -502,9 +509,13 @@ of the invalid call. Unauthorized tools still receive a generic rejection, and
corrected calls retain the same permission and approval checks.
A native provider may omit `continuation` (or send `null`) when it reports
`yielded` after saving a monitor. The controller reads current authorized task
context and fills the canonical `monitor` continuation only if that same task,
run, assignee, work mode, and monitor remain eligible. Completion admission
`yielded` after saving a monitor or requesting repository preparation. The controller reads current authorized task
context and fills the canonical continuation only if that same task,
run, assignee, and work mode remain eligible. An eligible monitor takes precedence.
A pending repository requested by that task in its current workspace offers
`same_agent`; the existing status arbiter and wake queue own the next admission.
Prepared repositories and requests belonging to another task cannot offer it.
An explicit `done` result does not acquire a continuation. Completion admission
checks that persisted monitor again. This default does not create or renew a
schedule. A consumed, cleared, exhausted, or ineligible monitor cannot authorize
a passive wait. An explicit `response_wake` still identifies a real response
+8
View File
@@ -158,6 +158,14 @@ new controller-side Git acquisition. Evidence remains source-only here; focused
[repository tests](../../server/src/__tests__/task-workspace-repositories-trust.test.ts)
are separate from live agent qualification.
An admitted native standard task can read a same-agent completion continuation
for its own pending repository in its current workspace. An explicit yield may
use that saved continuation; explicit completion remains completion. Existing
status decisions and wake admission retain Stop and authorization gates. The
[workspace API regression](../../server/src/__tests__/task-workspace-api.test.ts)
and [driver regression](../../packages/paperclip-runner/src/drivers/completion-continuation.test.ts)
cover this contract; live qualification remains separate.
Policy: Company/project/workspace visibility and runtime authority; active run, trust
preset and task ownership where required.
+8
View File
@@ -94,6 +94,14 @@ preparation API to add an authorized repository beneath that workspace;
repeat the request and verify that it reuses the checkout and pinned revision.
See [task binding tests](../../server/src/__tests__/task-workspace-binding.test.ts)
and [repository access tests](../../server/src/__tests__/task-workspace-repositories-trust.test.ts).
For native unfinished work, yield after preparation is staged and verify that
normal admission prepares the repository and continues the same task without
a timer or user message. A staging-only task that finishes must not wake again.
The saved continuation must disappear when the repository is ready or belongs
to another task. Source regressions cover those eligibility checks; a live
provider journey is required to qualify the complete automatic resume.
An existing projectless folder alias of a sandbox-restricted project must be
rejected for a standard local task before its adapter executes. Repeat after
relocating the configured source; retained ownership must still enforce the
@@ -19,6 +19,12 @@ describe("controller-owned completion continuation", () => {
.toEqual({ ...yielded, continuation });
});
it("continues staged workspace preparation from the current server-owned task", async () => {
const preparation = { kind: "same_agent", summary: "Continue with the prepared repository.", idempotencyKey: "repository:task:receipt:run" };
expect(await resolveCompletionContinuation(yielded, "run", async () => ({ ...context, completionContinuation: preparation })))
.toEqual({ ...yielded, continuation: preparation });
});
it.each([
null, { ...context, run: { id: "other" } }, { ...context, activeTask: { executionRunId: "other" } },
{ ...context, completionContinuation: null }, { ...context, completionContinuation: { kind: "response_wake" } },
@@ -14,12 +14,12 @@ export async function resolveCompletionContinuation(
const task = object(context.activeTask);
const continuation = object(context.completionContinuation);
if (object(context.run).id !== runId || task.executionRunId !== runId ||
continuation.kind !== "monitor" ||
!["monitor", "same_agent"].includes(String(continuation.kind)) ||
typeof continuation.summary !== "string" || !continuation.summary.trim() ||
typeof continuation.idempotencyKey !== "string" || !continuation.idempotencyKey.trim()) {
throw new Error("Cannot yield without a continuation: no eligible saved monitor exists on this task. Save a monitor with set_task_monitor, provide an explicit response_wake for a real response wait, or continue the unfinished work. Do not repeat completed actions.");
throw new Error("Cannot yield without a continuation: no eligible saved monitor or workspace continuation exists on this task. Save a monitor for a deferred check, provide an explicit response_wake for a real response wait, or continue the unfinished work. Do not repeat completed actions.");
}
return { ...report, continuation: { kind: "monitor", summary: continuation.summary, idempotencyKey: continuation.idempotencyKey } };
return { ...report, continuation: { kind: continuation.kind, summary: continuation.summary, idempotencyKey: continuation.idempotencyKey } };
}
function object(value: unknown): Record<string, unknown> {
@@ -51,6 +51,13 @@ const support = await getEmbeddedPostgresTestSupport();
const first = await call("prepare_repository", args) as { preparationId: string; kind: string };
expect(first.kind).toBe("requires_next_admission");
expect(await call("prepare_repository", args)).toMatchObject({ preparationId: first.preparationId });
const continued = await call("get_task_context", {}) as { completionContinuation: { kind: string; idempotencyKey: string } };
expect(continued.completionContinuation).toMatchObject({ kind: "same_agent" });
expect((await call("get_task_context", {}) as typeof continued).completionContinuation).toEqual(continued.completionContinuation);
await server.db.update(executionWorkspaceRepositories).set({ requestedByIssueId: f.blockerId }).where(eq(executionWorkspaceRepositories.id, first.preparationId));
expect(await call("get_task_context", {})).toMatchObject({ completionContinuation: null });
await server.db.update(executionWorkspaceRepositories).set({ requestedByIssueId: f.issueId, state: "ready" }).where(eq(executionWorkspaceRepositories.id, first.preparationId));
expect(await call("get_task_context", {})).toMatchObject({ completionContinuation: null });
expect(await call("select_workspace", { selection: { kind: "task_directory" }, expectedBindingRevision: view.bindingRevision, requestKey: "root-one" })).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" });
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
expect(task).toMatchObject({ executionWorkspaceId: workspace.id, projectId: null, workspacePendingSelection: { requestKey: "root-one" } });
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agents, agentWakeupRequests, authUsers, chatConversations, chatEndpoints, chatMessageLinks,
chatPublications, companies, completionContracts, createDb, heartbeatRuns, issueComments, issues, nativeRunFinalizations, nativeRunResults,
toolApplications, toolConnections,
statusDecisions, statusDecisionEffects, toolApplications, toolConnections,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { enqueueChatRunMilestones, resolveChatRunPresentationAuthorizationReason } from "./chat-run-publications.js";
@@ -133,6 +133,68 @@ if (!support.supported) console.warn(`Monitor publication database unavailable:
expect(await db.select().from(chatPublications).where(and(eq(chatPublications.issueId, f.issue.id), eq(chatPublications.idempotencyKey, `run:${f.source.id}:completed:${f.endpoint.id}`)))).toEqual([]);
});
async function continuedTaskFixture() {
const f = await fixture();
const [contract] = await db.insert(completionContracts).values({ companyId: f.company.id, issueId: f.issue.id,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard",
completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: { revision: "test", objective: "Verify repository migration", criteria: [] }, canonicalSha256: "workspace-contract",
createdByActorType: "system", createdByActorId: "test",
}).returning();
const sessionId = randomUUID(), runnerId = randomUUID();
await db.update(heartbeatRuns).set({ status: "running", nativeSessionId: sessionId, runnerInstanceId: runnerId,
completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
}).where(eq(heartbeatRuns.id, f.source.id));
await db.update(issues).set({ executionRunId: f.source.id }).where(eq(issues.id, f.issue.id));
await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.successor.id));
const port = new NativeRunCoordinatorStore(db, { companyId: f.company.id, issueId: f.issue.id,
runId: f.source.id, agentId: f.agent.id, normalizedSessionId: sessionId, completionContractId: contract.id,
completionContractSha256: contract.canonicalSha256, runnerSourceInstanceId: runnerId,
completionContractRevision: "test", completionContractCriterionIds: [],
});
const summary = "The repository is staged. I will continue QA automatically when it is prepared.";
await port.completeRun({ result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded", summary,
completionClaim: { contractRevision: "test", objectiveSatisfied: false, criteria: [], remainingWork: [{ description: "Complete QA", blocksCompletion: true }] },
continuation: { kind: "same_agent", summary: "Continue QA with the prepared checkout", idempotencyKey: "repository-admission" },
evidence: [], verification: [], attentionRequests: [], artifacts: [],
}, terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: "yielded" }, turnId: "prepare-turn" });
await finalizeNativeRun({ db, runId: f.source.id, workspaceFinalizeStatus: "succeeded", projectRunStatus: true });
const [wake] = await db.select().from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, f.company.id), eq(agentWakeupRequests.reason, "issue_status_changed")));
expect(wake).toBeDefined();
await db.update(agentWakeupRequests).set({ status: "claimed", runId: f.successor.id }).where(eq(agentWakeupRequests.id, wake.id));
await db.update(heartbeatRuns).set({ status: "running", finishedAt: null,
contextSnapshot: { source: "native_status_decision", issueId: f.issue.id },
}).where(eq(heartbeatRuns.id, f.successor.id));
return { ...f, continuationWake: wake, summary };
}
it("delivers the saved setup explanation and retains chat through the committed automatic continuation", async () => {
const f = await continuedTaskFixture();
expect(await db.select({ body: issueComments.body }).from(issueComments).where(eq(issueComments.createdByRunId, f.source.id)))
.toEqual([{ body: f.summary }]);
expect(await f.bindings()).toEqual(await f.bindings(f.source.id));
expect(await f.bindings()).toHaveLength(1);
await enqueueChatRunMilestones(db);
const publications = await db.select().from(chatPublications).where(eq(chatPublications.issueId, f.issue.id));
expect(publications.some(p => p.payload.text === f.summary && p.conversationId === f.conversation.id)).toBe(true);
expect(publications.some(p => p.payload.progressState === "working" && p.conversationId === f.conversation.id)).toBe(true);
expect(await repairCommittedNativeChatResponse(db, { companyId: f.company.id, issueId: f.issue.id, runId: f.source.id })).toBe(false);
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, f.source.id))).toHaveLength(1);
});
it.each(["effect", "decision", "wake", "requester", "agent"])("rejects automatic continuation when %s authority is no longer valid", async fault => {
const f = await continuedTaskFixture();
if (fault === "effect") await db.update(statusDecisionEffects).set({ targetId: randomUUID() }).where(eq(statusDecisionEffects.companyId, f.company.id));
if (fault === "decision") await db.update(statusDecisions).set({ applicationState: "proposed" }).where(eq(statusDecisions.companyId, f.company.id));
if (fault === "wake") await db.update(agentWakeupRequests).set({ idempotencyKey: randomUUID() }).where(eq(agentWakeupRequests.id, f.continuationWake.id));
if (fault === "requester") await db.update(heartbeatRuns).set({ responsibleUserId: "another-person" }).where(eq(heartbeatRuns.id, f.successor.id));
if (fault === "agent") {
const [other] = await db.insert(agents).values({ companyId: f.company.id, name: "Other", adapterType: "paperclip_runner" }).returning();
await db.update(heartbeatRuns).set({ agentId: other.id }).where(eq(heartbeatRuns.id, f.source.id));
}
expect(await f.bindings()).toEqual([]);
});
it("recovers the accepted continuation explanation after interrupted finalization without another provider result", async () => {
const f = await fixture();
const [contract] = await db.insert(completionContracts).values({ companyId: f.company.id, issueId: f.issue.id,
@@ -748,6 +748,19 @@ export async function enqueueChatRunMilestones(
and monitor_wake.payload->>'issueId' = ${issueIdFromContext}
)
)`;
// This is only a candidate prefilter. The origin resolver below requires the
// committed decision/effect ledger before selecting any conversation.
const hasTaskContinuation = sql<boolean>`(
${heartbeatRuns.contextSnapshot}->>'source' = 'native_status_decision' and exists (
select 1 from agent_wakeup_requests continuation_wake
where continuation_wake.company_id = ${heartbeatRuns.companyId}
and continuation_wake.run_id = ${heartbeatRuns.id}
and continuation_wake.agent_id = ${heartbeatRuns.agentId}
and continuation_wake.reason = 'issue_status_changed'
and continuation_wake.status in ('claimed', 'completed')
and continuation_wake.payload->>'issueId' = ${issueIdFromContext}
)
)`;
let inserted = 0;
let cursor: {
updatedAt: Date;
@@ -835,6 +848,7 @@ export async function enqueueChatRunMilestones(
),
hasQuestionContinuationTarget,
hasMonitorContinuation,
hasTaskContinuation,
),
// Heartbeat marks a run succeeded before the presentation resolver
// finishes. Waiting for its durable decision prevents a generic
@@ -884,6 +898,7 @@ export async function enqueueChatRunMilestones(
),
or(
hasMonitorContinuation,
hasTaskContinuation,
sql`exists (
select 1
from ${chatMessageLinks}
+13 -9
View File
@@ -205,6 +205,7 @@ export function isExternalChatPresentationContext(
if (
source === "tool_action_review" ||
source === "issue.monitor" ||
source === "native_status_decision" ||
source.startsWith("issue.comment") ||
source === "issue.update"
)
@@ -285,16 +286,18 @@ function hasYieldedSemanticResult(resultJson: Record<string, unknown>) {
);
}
export function readAcceptedMonitorContinuationSummary(
export function readAcceptedTaskContinuationSummary(
resultJson: Record<string, unknown> | null | undefined,
): string | null {
const result = record(resultJson);
if (result.finalizationPhase !== "committed" ||
!["scheduled_monitor_waiting", "scheduled_monitor_dispatched"].includes(String(result.finalizationReasonCode))) return null;
if (result.finalizationPhase !== "committed") return null;
const kind = result.finalizationReasonCode === "live_continuation_registered" ? "same_agent"
: ["scheduled_monitor_waiting", "scheduled_monitor_dispatched"].includes(String(result.finalizationReasonCode)) ? "monitor" : null;
if (!kind) return null;
for (const candidate of semanticResultCandidates(result)) {
const continuation = record(candidate.continuation);
if (candidate.schema === "paperclip.run_result.v1" && candidate.reportedWorkDisposition === "yielded" &&
continuation.kind === "monitor" && readCommentText(continuation.idempotencyKey)) {
continuation.kind === kind && readCommentText(continuation.idempotencyKey)) {
return readCommentText(candidate.summary);
}
}
@@ -411,20 +414,21 @@ export function resolveHeartbeatRunResponse(input: {
} | null;
}): ResolvedHeartbeatRunResponse {
const resultJson = record(input.resultJson);
const monitorSummary = readAcceptedMonitorContinuationSummary(resultJson);
if (monitorSummary) {
const continuationSummary = readAcceptedTaskContinuationSummary(resultJson);
if (continuationSummary) {
if (input.interactionOwnsResponse) return {
text: null,
decision: decision("none", { commentAction: "none", reasonCodes: ["interaction_owns_response"] }),
};
const existing = input.existingComment;
const reuse = existing?.body === monitorSummary;
const reuse = existing?.body === continuationSummary;
return {
text: monitorSummary,
text: continuationSummary,
decision: decision("semantic_result_summary", {
commentAction: reuse ? "reuse" : "create",
...(reuse ? { commentId: existing.id } : {}),
reasonCodes: ["accepted_scheduled_monitor_summary"],
reasonCodes: [resultJson.finalizationReasonCode === "live_continuation_registered"
? "accepted_task_continuation_summary" : "accepted_scheduled_monitor_summary"],
}),
};
}
@@ -28,7 +28,7 @@ import { isUnresolvedWorkspaceBaseRefError, readUnresolvedWorkspaceBaseRefDiagno
import { runnerGoalService } from "../runner-goals.js";
import { resolveChatRunPresentationAuthorizationReason } from "../chat-run-publications.js";
import { hasChatRunOwnedProviderInteraction } from "../chat-interaction-arbitration.js";
import { readAcceptedMonitorContinuationSummary } from "../heartbeat-run-summary.js";
import { readAcceptedTaskContinuationSummary } from "../heartbeat-run-summary.js";
import { SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON } from "../recovery/stranded-notice.js";
import { MAX_TURN_CONTINUATION_RETRY_REASON } from "../../modules/run-dispatch/index.js";
import { redactCurrentUserText } from "../../log-redaction.js";
@@ -809,7 +809,7 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
);
const resolved = resolveHeartbeatRunResponse({
resultJson: persistedResultJson,
interactionOwnsResponse: Boolean(issueId && readAcceptedMonitorContinuationSummary(persistedResultJson) &&
interactionOwnsResponse: Boolean(issueId && readAcceptedTaskContinuationSummary(persistedResultJson) &&
await hasChatRunOwnedProviderInteraction(db, { companyId: livenessRun.companyId, issueId, runId: livenessRun.id })),
conversationTurnFinished: isConversation(issueContext) && livenessRun.status === "succeeded"
&& persistedResultJson?.finalizationReasonCode === "conversation_turn_finished",
+7
View File
@@ -1,5 +1,6 @@
import { measureRunTransaction } from "./execution-transaction-timing.js";
import { readNativeMonitorSource } from "./native-runtime/native-monitor-delivery.js";
import { readNativeContinuationSource } from "./native-runtime/native-continuation-delivery.js";
import { assertNoCaseHandoffMutation, cancelCaseHandoffsForIssue, completeTerminalCaseHandoffsForIssue } from "./heartbeat/case-handoff-lifecycle.js";
import { measureRunPhase } from "./execution-preparation-timing.js";
import { automaticReadRecoveryCleanupCondition, directHumanCommentCondition, humanCreatedIssueCondition, pendingUserRequestCondition, touchedByUserCondition } from "./issue-participation.js";
@@ -1397,6 +1398,12 @@ export async function resolveChatOriginPublicationBindings(
originRunId = sourceRunId;
continue;
}
if (contextSource === "native_status_decision") {
const sourceRunId = await readNativeContinuationSource(dbOrTx, { companyId, issueId, runId: originRunId });
if (!sourceRunId) return [];
originRunId = sourceRunId;
continue;
}
if (
contextSource !== "issue.interaction.respond" &&
contextSource !== "issue.interaction.accept" &&
@@ -0,0 +1,48 @@
import { agentWakeupRequests, heartbeatRuns, statusDecisions, statusDecisionEffects, type Db } from "@paperclipai/db";
import { and, eq, inArray, ne, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { boundRunIssueIdSql } from "../run-task-binding.js";
/** Recover conversation provenance from a committed continuation, never from
* provider-supplied source IDs or the mere presence of a chat on the task. */
export async function readNativeContinuationSource(
db: Pick<Db, "select">,
binding: { companyId: string; issueId: string; runId: string },
): Promise<string | null> {
const source = alias(heartbeatRuns, "continuation_source");
const sources = await db.select({ runId: source.id }).from(heartbeatRuns)
.innerJoin(agentWakeupRequests, and(
eq(agentWakeupRequests.companyId, binding.companyId),
eq(agentWakeupRequests.runId, heartbeatRuns.id),
eq(agentWakeupRequests.agentId, heartbeatRuns.agentId),
eq(agentWakeupRequests.reason, "issue_status_changed"),
inArray(agentWakeupRequests.status, ["claimed", "completed"]),
sql`${agentWakeupRequests.payload}->>'issueId' = ${binding.issueId}`,
))
.innerJoin(statusDecisionEffects, and(
eq(statusDecisionEffects.companyId, binding.companyId), eq(statusDecisionEffects.issueId, binding.issueId),
eq(statusDecisionEffects.effectKind, "enqueue_continuation"),
eq(statusDecisionEffects.targetType, "agent_wakeup_request"),
sql`${statusDecisionEffects.targetId} = ${agentWakeupRequests.id}::text`,
eq(statusDecisionEffects.deliveryState, "delivered"),
sql`${statusDecisionEffects.payload}->>'continuationKind' = 'same_agent'`,
sql`${agentWakeupRequests.payload}->>'continuationKind' = 'same_agent'`,
))
.innerJoin(statusDecisions, and(
eq(statusDecisions.id, statusDecisionEffects.decisionId), eq(statusDecisions.companyId, binding.companyId),
eq(statusDecisions.issueId, binding.issueId), eq(statusDecisions.applicationState, "applied"),
eq(statusDecisions.reasonCode, "live_continuation_registered"),
sql`${agentWakeupRequests.idempotencyKey} = 'native-status:' || ${statusDecisions.id}::text || ':continuation'`,
sql`${agentWakeupRequests.payload}->>'nativeDecisionId' = ${statusDecisions.id}::text`,
))
.innerJoin(source, and(
eq(source.id, statusDecisions.runId), eq(source.companyId, binding.companyId),
eq(source.nativeIssueId, binding.issueId), eq(source.runtimeMode, "native"),
eq(source.agentId, heartbeatRuns.agentId), ne(source.id, heartbeatRuns.id),
sql`${source.responsibleUserId} is not distinct from ${heartbeatRuns.responsibleUserId}`,
))
.where(and(eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.companyId, binding.companyId),
eq(boundRunIssueIdSql(), binding.issueId)))
.groupBy(source.id).limit(2);
return sources.length === 1 ? sources[0]!.runId : null;
}
@@ -68,7 +68,7 @@ import {
CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON,
findHeartbeatRunCompletionComment,
isExternalChatPresentationContext,
readAcceptedMonitorContinuationSummary,
readAcceptedTaskContinuationSummary,
readCompletedAssistantMessageCandidate,
resolveHeartbeatRunResponse,
selectHeartbeatRunFinalAgentMessage,
@@ -964,7 +964,7 @@ export async function repairCommittedNativeChatResponse(
if (record(decision.decisionJson).externalChatReviewPresentation)
return false;
const monitorResult = { nativeResult: result, finalizationPhase: "committed", finalizationReasonCode: decision.reasonCode };
if (readAcceptedMonitorContinuationSummary(monitorResult) && run.status === "succeeded" &&
if (readAcceptedTaskContinuationSummary(monitorResult) && run.status === "succeeded" &&
terminal.runTerminalState === "succeeded" && terminal.turnTerminalState === "completed" &&
terminal.reportedWorkDisposition === "yielded" && record(run.contextSnapshot).skipIssueComment !== true &&
!await hasChatRunOwnedProviderInteraction(tx, input)) {
@@ -6,7 +6,7 @@ import { assertNoCaseHandoffMutation } from "../heartbeat/case-handoff-lifecycle
import { handoffCaseSchema } from "@paperclipai/shared";
import { skillAccessService } from "../skill-access.js";
import { withExternalExecutionAdmission, type ExternalAdmissionTransaction } from "../../modules/external-agents/index.js";
import { callTaskWorkspaceTool } from "../task-workspace-tools.js";
import { callTaskWorkspaceTool, pendingTaskRepositoryContinuation } from "../task-workspace-tools.js";
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js";
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
@@ -673,7 +673,10 @@ export class PaperclipRunnerToolAuthority {
kind: "monitor",
summary: "Wait for the saved task monitor.",
idempotencyKey: `task-monitor:${context.issue.id}:${monitorCheckAt}`,
} : null,
} : !this.binding.nativeReview && context.issue.workMode === "standard" && context.issue.executionRunId === this.binding.runId
? await pendingTaskRepositoryContinuation(this.db, { companyId: this.binding.companyId, issueId: this.binding.issueId,
runId: this.binding.runId, workspaceId: context.issue.executionWorkspaceId })
: null,
childTasks: visibleChildTasks.slice(0, 100).map(redactedTask),
childTasksTruncated: childTasks.length > 100,
delegationGuidance: "Reuse existing child tasks for delegated work. Inspect a completed child's comments and documents before creating another task for the same deliverable. Use search_tasks if the child task list is truncated.",
+19 -1
View File
@@ -1,5 +1,21 @@
import { prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared";
import { badRequest } from "../errors.js";
import { executionWorkspaceRepositories, type Db } from "@paperclipai/db";
import { and, asc, eq } from "drizzle-orm";
export async function pendingTaskRepositoryContinuation(db: Pick<Db, "select">, input: {
companyId: string; issueId: string; runId: string; workspaceId: string | null;
}) {
if (!input.workspaceId) return null;
const [repository] = await db.select({ id: executionWorkspaceRepositories.id }).from(executionWorkspaceRepositories)
.where(and(eq(executionWorkspaceRepositories.companyId, input.companyId),
eq(executionWorkspaceRepositories.executionWorkspaceId, input.workspaceId),
eq(executionWorkspaceRepositories.requestedByIssueId, input.issueId),
eq(executionWorkspaceRepositories.state, "pending")))
.orderBy(asc(executionWorkspaceRepositories.createdAt), asc(executionWorkspaceRepositories.id)).limit(1);
return repository ? { kind: "same_agent" as const, summary: "Continue with the prepared task repository.",
idempotencyKey: `task-repository:${input.issueId}:${repository.id}:${input.runId}` } : null;
}
function repositoryView(repo: Record<string, unknown>) {
return { id: repo.id, repoUrl: repo.repoUrl, relativePath: repo.relativePath, requestedRef: repo.requestedRef,
@@ -22,7 +38,9 @@ export async function callTaskWorkspaceTool(input: { name: string; arguments: Re
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Workspace tool failed (${response.status})`);
if (input.name === "prepare_repository") {
const { operationId, ...rest } = result;
return { ...rest, ...(result.repository ? { repository: repositoryView(result.repository) } : {}), preparationId: operationId };
return { ...rest, ...(result.repository ? { repository: repositoryView(result.repository) } : {}), preparationId: operationId,
...(result.kind === "requires_next_admission" ? { instruction: "The repository is staged for the next admission. If unfinished work needs this checkout, finish independent work and yield. Paperclip will select the saved workspace continuation; no timer or user restart is needed." } : {}),
};
}
if (input.name === "get_workspace") {
const workspace = result.workspace;
+3
View File
@@ -155,6 +155,7 @@ If `currentParticipant` does not match you, do not try to advance the stage —
**Step 7 — Do the work.** Use your tools and capabilities. Execution contract:
- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.
- Ask for information only when it prevents the next useful action. Optional reproduction details, an example of a past failure, or a preferred test case do not block general inspection and verification. Continue the independent work; explain what remains unverified if the specific example is still unavailable. Keep real access restrictions and approval gates authoritative.
- Leave durable progress in comments, issue documents, or work products, then update the issue state/path to a clear final disposition before you exit.
- Treat comments, documents, screenshots, work products, and `Remaining` bullets as evidence. They are not valid liveness paths by themselves.
- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.
@@ -552,6 +553,8 @@ Use the task's provided working directory for task files. `AGENT_HOME` holds per
Use `get_workspace` to inspect the current folder, pending selection, and capabilities. Use `list_workspaces` for authorized existing folders and `list_project_repositories` for repository choices. `select_workspace` and `prepare_repository` express intent; the harness validates and prepares it. Respect `next_normal_admission` responses: keep the current root, do not claim the requested checkout exists, and do not retry in a loop or interrupt the run. Project assignment alone does not relocate files. See `references/issue-workspaces.md` for the API equivalents.
Prefer a matching configured, authorized source over guessing a repository URL. Inspect available runtime and testing capabilities before claiming that browser or command testing is unavailable. When an authorized repository is pending and unfinished work needs it, finish independent work, explain the remaining work, and yield. Native Runner can select the saved same-agent continuation from current task context; it does not need a timer or a user restart. A request only to stage a repository can finish normally without starting more work.
## Issue Workspace Runtime Controls
When an issue needs browser/manual QA or a preview server, inspect its current execution workspace and use Paperclip's workspace runtime controls instead of starting unmanaged background servers yourself.