mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
fix: resume unfinished work after repository preparation (#17047)
## Thinking Path
> - Paperclip manages agents that perform work for a company.
> - Task repositories are prepared at normal run admission.
> - A running native task can stage a repository while its work remains
unfinished.
> - The controller did not offer that saved setup as a continuation.
> - The agent could add a timer or wait for a user restart.
> - This change uses the existing same-agent continuation and status
ledger.
> - The accepted explanation and proven conversation remain available
after setup.
## Linked Issues or Issue Description
Refs #17045. That prerequisite has merged. This PR targets master.
**What happened?**
A projectless native task can request a repository for the next
admission. An unfinished task has no server-selected repository
continuation. An explicit same-agent continuation also loses chat
provenance.
**Expected behavior**
An unfinished standard task can yield and resume through the existing
admission queue. Its explanation and final result retain the authorized
conversation. A task that only stages a repository can complete without
another run.
**Steps to reproduce**
1. Start a native standard task in a task directory.
2. Request an authorized repository through prepare_repository.
3. Yield with unfinished work and no explicit continuation.
4. Observe that the former driver rejects the yield, although the
pending repository is saved.
## What Changed
- Offer a saved same-agent continuation for the current task's pending
repository.
- Accept that current server continuation in the shared provider driver.
- Resolve chat provenance through applied status decisions, delivered
effects, and admitted wakes.
- Preserve accepted setup explanations without repeating an owned
question card.
- Document optional reproduction details and configured repository
discovery.
- Extend database regressions and workspace access maps.
- Preserve Copilot profile v52 and bind the shared continuation source
in v53. Include the active identity in the QA image fingerprint.
## Verification
- Five focused server suites: 100 checks pass.
- Shared continuation driver: 12 checks pass.
- Server typecheck passes.
- Map contract and whitespace checks pass.
- Existing API and driver assertions fail on the former implementation.
- Real dispatcher and queue cohort: 43 checks pass. The original
reply-loss regression fails before the dispatch receipt fix.
- Continuation driver and installation identity cohort: 62 checks pass.
- Regenerated capability inventory and contract checks pass.
- Final response precedence cohort: 109 checks pass. The stale-comment
case fails on the former implementation.
- All required CI checks, build, and both source reviews pass on
b9c8677635.
- Live provider journeys and deployed acceptance remain pending. No live
qualification is claimed.
## Risks
A pending repository now offers a next admission when unfinished
standard work yields. Incorrect eligibility could create unwanted work.
The lookup requires the current workspace and task. Explicit completion
does not use it. Existing Stop, admission, approval, and transport
authorization remain authoritative. No schema or public task-status
change is included. The shared continuation change requires a new
Copilot source identity. This PR preserves v52 history and does not
claim fresh Copilot live qualification.
## Model Used
OpenAI Codex, GPT-6 family, with repository tools and code execution.
The runtime does not expose its exact deployment ID or context-window
size. Those values are not inferred.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8b873e0112
commit
dff63551fe
32 files changed
+660
-279
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
projectHistoricalHeartbeatRunComment,
|
||||
findHeartbeatRunCompletionComment,
|
||||
isExternalChatPresentationContext,
|
||||
isChatOriginPublicationCandidate,
|
||||
mergeHeartbeatRunResultJson,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
resolveHeartbeatRunResponse,
|
||||
@@ -61,6 +62,19 @@ describe("scheduled continuation presentation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("continuation origin verification", () => {
|
||||
it.each(["native_status_decision", "issue.monitor", "tool_action_review", "issue.comment", "issue.comment.created", "issue.update"])
|
||||
("checks %s without treating candidacy as publication authority", source => {
|
||||
expect(isChatOriginPublicationCandidate({ source })).toBe(true);
|
||||
expect(isExternalChatPresentationContext({ source })).toBe(false);
|
||||
expect(isExternalChatPresentationContext({ source }, true)).toBe(true);
|
||||
});
|
||||
it("keeps internal work internal", () => {
|
||||
expect(isChatOriginPublicationCandidate({ source: "issue.assigned" })).toBe(false);
|
||||
expect(isExternalChatPresentationContext({ source: "issue.assigned" }, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectHeartbeatRunFinalAgentMessage", () => {
|
||||
const substantive = {
|
||||
seq: 80,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { heartbeatRuns } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { vi, type Mock, type Mocked } from "vitest";
|
||||
import { createRunDispatch } from "../../modules/run-dispatch/index.js";
|
||||
import type { createWakeQueue } from "../../modules/wake-queue/index.js";
|
||||
import { createHeartbeatRunState } from "../../services/heartbeat/run-state.js";
|
||||
import { createHeartbeatRunPreparation } from "../../services/heartbeat/run-preparation.js";
|
||||
import type { HeartbeatQueueDependencies } from "../../services/heartbeat/queue.js";
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
type MockedCallback = "getAgent" | "setRunStatus" | "setWakeupStatus" | "appendRunEvent" | "finalizeAgentStatus" | "getAgentInvokability" | "getWorktreeExecutionCutoff" | "applyRunDispatchPostCommitEffects" | "getSchedulingSuppression" | "canStartNewExternalBotWork" | "executeRun" | "applyWakeQueuePostCommitEffects" | "releaseIssueExecutionAndPromote" | "publishRunLifecyclePluginEvent" | "cancelRunInternal" | "cancelActiveForAgentInternal" | "resumeExecutionWaitComments" | "resumeQueuedCommentInterrupt" | "resumeSavedLegacyComments" | "sweepPendingCleanupLeases";
|
||||
type QueueTestDependencies = HeartbeatQueueDependencies & { [K in MockedCallback]: Mock<HeartbeatQueueDependencies[K]> } & {
|
||||
issuesSvc: Mocked<HeartbeatQueueDependencies["issuesSvc"]>;
|
||||
treeControlSvc: Mocked<HeartbeatQueueDependencies["treeControlSvc"]>;
|
||||
budgets: Mocked<HeartbeatQueueDependencies["budgets"]>;
|
||||
instanceSettings: Mocked<HeartbeatQueueDependencies["instanceSettings"]>;
|
||||
wakeQueue: Mocked<HeartbeatQueueDependencies["wakeQueue"]>;
|
||||
liveRunExecutions: Mocked<HeartbeatQueueDependencies["liveRunExecutions"]>;
|
||||
};
|
||||
|
||||
export function createHeartbeatQueueTestDependencies(db: Db): QueueTestDependencies {
|
||||
const state = createHeartbeatRunState(db);
|
||||
const preparation = createHeartbeatRunPreparation(db);
|
||||
return {
|
||||
...preparation,
|
||||
...state,
|
||||
getAgent: vi.fn<HeartbeatQueueDependencies["getAgent"]>(async () => { throw new Error("Unexpected agent lookup"); }),
|
||||
options: {},
|
||||
setRunStatus: vi.fn<HeartbeatQueueDependencies["setRunStatus"]>(async (id, status, patch) =>
|
||||
db.update(heartbeatRuns).set({ ...patch, status }).where(eq(heartbeatRuns.id, id)).returning().then(rows => rows[0] ?? null)),
|
||||
setWakeupStatus: vi.fn<HeartbeatQueueDependencies["setWakeupStatus"]>(async () => undefined),
|
||||
appendRunEvent: vi.fn<HeartbeatQueueDependencies["appendRunEvent"]>(async () => undefined),
|
||||
finalizeAgentStatus: vi.fn<HeartbeatQueueDependencies["finalizeAgentStatus"]>(async () => undefined),
|
||||
getAgentInvokability: vi.fn<HeartbeatQueueDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
|
||||
getWorktreeExecutionCutoff: vi.fn<HeartbeatQueueDependencies["getWorktreeExecutionCutoff"]>(async () => null),
|
||||
applyRunDispatchPostCommitEffects: vi.fn<HeartbeatQueueDependencies["applyRunDispatchPostCommitEffects"]>(),
|
||||
issuesSvc: {
|
||||
getDependencyReadiness: vi.fn<HeartbeatQueueDependencies["issuesSvc"]["getDependencyReadiness"]>(),
|
||||
listDependencyReadiness: vi.fn(async () => new Map()),
|
||||
},
|
||||
treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) },
|
||||
budgets: { getInvocationBlock: vi.fn(async () => null) },
|
||||
instanceSettings: { getExperimental: vi.fn<HeartbeatQueueDependencies["instanceSettings"]["getExperimental"]>() },
|
||||
runDispatch: createRunDispatch(db),
|
||||
wakeQueue: {
|
||||
createAdmissionTransactionScope: vi.fn<ReturnType<typeof createWakeQueue>["createAdmissionTransactionScope"]>(),
|
||||
admitWakeBehindIssueExecution: vi.fn<ReturnType<typeof createWakeQueue>["admitWakeBehindIssueExecution"]>(),
|
||||
},
|
||||
activeRunExecutions: new Set<string>(),
|
||||
activeRunExecutionPromises: new Set<Promise<void>>(),
|
||||
activeWakeupPromises: new Set<Promise<unknown>>(),
|
||||
liveRunExecutions: { has: vi.fn(() => false) },
|
||||
isHeartbeatRunTerminalStatus: (status: string | null | undefined) => ["succeeded", "failed", "cancelled", "timed_out"].includes(status ?? ""),
|
||||
isSameTaskScope: (left: string | null, right: string | null) => left === right,
|
||||
runTaskKey: (run: Run) => typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null,
|
||||
filterZombieCoalesceTarget: <T extends { id: string; status: string }>(run: T | null) => run,
|
||||
getSchedulingSuppression: vi.fn<HeartbeatQueueDependencies["getSchedulingSuppression"]>(async () => ({ suppressed: false, reason: null } as Awaited<ReturnType<HeartbeatQueueDependencies["getSchedulingSuppression"]>>)),
|
||||
canStartNewExternalBotWork: vi.fn<HeartbeatQueueDependencies["canStartNewExternalBotWork"]>(async () => true),
|
||||
executeRun: vi.fn<HeartbeatQueueDependencies["executeRun"]>(async () => undefined),
|
||||
applyWakeQueuePostCommitEffects: vi.fn<HeartbeatQueueDependencies["applyWakeQueuePostCommitEffects"]>(async () => undefined),
|
||||
releaseIssueExecutionAndPromote: vi.fn<HeartbeatQueueDependencies["releaseIssueExecutionAndPromote"]>(async () => undefined),
|
||||
publishRunLifecyclePluginEvent: vi.fn<HeartbeatQueueDependencies["publishRunLifecyclePluginEvent"]>(),
|
||||
cancelRunInternal: vi.fn<HeartbeatQueueDependencies["cancelRunInternal"]>(async () => undefined),
|
||||
cancelActiveForAgentInternal: vi.fn<HeartbeatQueueDependencies["cancelActiveForAgentInternal"]>(async () => undefined),
|
||||
resumeExecutionWaitComments: vi.fn<HeartbeatQueueDependencies["resumeExecutionWaitComments"]>(async () => undefined),
|
||||
resumeQueuedCommentInterrupt: vi.fn<HeartbeatQueueDependencies["resumeQueuedCommentInterrupt"]>(async () => undefined),
|
||||
resumeSavedLegacyComments: vi.fn<HeartbeatQueueDependencies["resumeSavedLegacyComments"]>(async () => undefined),
|
||||
formatIssueIdentifierLink: (_identifier: string | null, fallback: string) => fallback,
|
||||
sweepPendingCleanupLeases: vi.fn<HeartbeatQueueDependencies["sweepPendingCleanupLeases"]>(),
|
||||
} satisfies HeartbeatQueueDependencies;
|
||||
}
|
||||
@@ -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" } });
|
||||
|
||||
@@ -3,15 +3,18 @@ import { and, eq } from "drizzle-orm";
|
||||
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,
|
||||
chatPublications, companies, companyMemberships, completionContracts, createDb, heartbeatRuns, issueComments, issues, nativeRunFinalizations, nativeRunResults,
|
||||
statusDecisions, statusDecisionEffects, toolApplications, toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
|
||||
import { enqueueChatRunMilestones, resolveChatRunPresentationAuthorizationReason } from "./chat-run-publications.js";
|
||||
import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON } from "./heartbeat-run-summary.js";
|
||||
import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isChatOriginPublicationCandidate, isExternalChatPresentationContext, resolveHeartbeatRunResponse } from "./heartbeat-run-summary.js";
|
||||
import { resolveChatOriginPublicationBindings } from "./issues.js";
|
||||
import { NativeRunCoordinatorStore } from "./native-runtime/native-run-coordinator-store.js";
|
||||
import { finalizeNativeRun, repairCommittedNativeChatResponse } from "./native-runtime/native-run-finalizer.js";
|
||||
import { createHeartbeatQueueTestDependencies } from "../__tests__/helpers/heartbeat-queue.js";
|
||||
import { createHeartbeatQueue } from "./heartbeat/queue.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
if (!support.supported) console.warn(`Monitor publication database unavailable: ${support.reason}`);
|
||||
@@ -27,8 +30,9 @@ if (!support.supported) console.warn(`Monitor publication database unavailable:
|
||||
|
||||
async function fixture() {
|
||||
const [company] = await db.insert(companies).values({ name: "Continuation", issuePrefix: randomUUID().slice(0, 8) }).returning();
|
||||
await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "requester", status: "active", membershipRole: "owner" });
|
||||
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Worker", adapterType: "paperclip_runner" }).returning();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Repository QA", status: "in_progress", assigneeAgentId: agent.id }).returning();
|
||||
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Repository QA", status: "in_progress", assigneeAgentId: agent.id, responsibleUserId: "requester" }).returning();
|
||||
const [application] = await db.insert(toolApplications).values({ companyId: company.id, applicationKey: randomUUID(), name: "Slack", type: "chat" }).returning();
|
||||
const [connection] = await db.insert(toolConnections).values({ companyId: company.id, applicationId: application.id, name: "Slack", uid: randomUUID(), connectionPurpose: "channel", transport: "chat_sdk" }).returning();
|
||||
const [endpoint] = await db.insert(chatEndpoints).values({ companyId: company.id, connectionId: connection.id, provider: "slack", publicId: randomUUID(), assignedAgentId: agent.id, status: "active" }).returning();
|
||||
@@ -133,6 +137,95 @@ 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();
|
||||
const deps = createHeartbeatQueueTestDependencies(db);
|
||||
deps.getAgent.mockImplementation(async id => (await db.select().from(agents).where(eq(agents.id, id)))[0] ?? null);
|
||||
deps.instanceSettings.getExperimental.mockImplementation(() => instanceSettingsService(db).getExperimental());
|
||||
await db.update(agents).set({ runtimeConfig: { heartbeat: { wakeOnDemand: true } } }).where(eq(agents.id, f.agent.id));
|
||||
const queue = createHeartbeatQueue(db, deps);
|
||||
expect(await queue.dispatchPendingNativeStatusWakeups({ companyId: f.company.id })).toMatchObject({ dispatched: 1 });
|
||||
await queue.startNextQueuedRunForAgent(f.agent.id);
|
||||
await Promise.all([...deps.activeRunExecutionPromises]);
|
||||
const [delivered] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
|
||||
expect(delivered).toMatchObject({ status: "coalesced" });
|
||||
expect(delivered.runId).toBeTruthy();
|
||||
const [successor] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, delivered.runId!));
|
||||
expect(successor.responsibleUserId).toBe("requester");
|
||||
return { ...f, successor, continuationWake: delivered, summary,
|
||||
bindings: (runId = successor.id) => resolveChatOriginPublicationBindings(db, f.company.id, f.issue.id, runId) };
|
||||
}
|
||||
|
||||
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("selects the continued chat run's completed answer over an earlier bookkeeping comment", async () => {
|
||||
const f = await continuedTaskFixture();
|
||||
const authorization = isChatOriginPublicationCandidate(f.successor.contextSnapshot)
|
||||
? await resolveChatRunPresentationAuthorizationReason(db, { companyId: f.company.id, issueId: f.issue.id, runId: f.successor.id }) : null;
|
||||
const context = isExternalChatPresentationContext(f.successor.contextSnapshot, authorization === CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON);
|
||||
const answer = "Migration QA is complete. The verified report is available at the attached result.";
|
||||
const resolved = resolveHeartbeatRunResponse({ resultJson: {},
|
||||
existingComment: { id: "earlier-comment", body: "The checkout is ready; QA is underway." },
|
||||
finalAgentMessage: { text: answer, channel: "final", sourceEventId: "completed-final" },
|
||||
preferFinalResponseOverExistingComment: context,
|
||||
});
|
||||
expect(resolved.text).toBe(answer);
|
||||
expect(resolved.decision).toMatchObject({ chosenSource: "final_agent_message", commentAction: "create" });
|
||||
});
|
||||
|
||||
it.each(["effect", "decision", "wake", "dispatch", "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 === "dispatch") await db.update(agentWakeupRequests).set({ requestedByActorId: "unrelated-dispatch" }).where(and(
|
||||
eq(agentWakeupRequests.companyId, f.company.id), eq(agentWakeupRequests.requestedByActorId, `native-status-wake-dispatch:${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 ('queued', 'claimed', 'completed', 'coalesced')
|
||||
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}
|
||||
|
||||
@@ -192,6 +192,11 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
function requiresChatOriginProof(source: string): boolean {
|
||||
return source === "tool_action_review" || source === "issue.monitor" || source === "native_status_decision" ||
|
||||
source.startsWith("issue.comment") || source === "issue.update";
|
||||
}
|
||||
|
||||
export function isExternalChatPresentationContext(
|
||||
contextSnapshot: unknown,
|
||||
verifiedToolReviewChatOrigin = false,
|
||||
@@ -202,12 +207,7 @@ export function isExternalChatPresentationContext(
|
||||
typeof context.source === "string" ? context.source.trim() : "";
|
||||
// Approvals and Board comments also resume ordinary internal tasks. Only
|
||||
// durable source-run or mirrored-comment proof authorizes an external reply.
|
||||
if (
|
||||
source === "tool_action_review" ||
|
||||
source === "issue.monitor" ||
|
||||
source.startsWith("issue.comment") ||
|
||||
source === "issue.update"
|
||||
)
|
||||
if (requiresChatOriginProof(source))
|
||||
return verifiedToolReviewChatOrigin;
|
||||
return (
|
||||
source.startsWith("chat:") ||
|
||||
@@ -216,6 +216,12 @@ export function isExternalChatPresentationContext(
|
||||
);
|
||||
}
|
||||
|
||||
export function isChatOriginPublicationCandidate(contextSnapshot: unknown): boolean {
|
||||
const context = record(contextSnapshot);
|
||||
const source = typeof context.source === "string" ? context.source.trim() : "";
|
||||
return requiresChatOriginProof(source) || isExternalChatPresentationContext(contextSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only completed assistant prose that can participate in final-response
|
||||
* presentation. A canonical `final` is authoritative; `unknown` is retained
|
||||
@@ -285,16 +291,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 +419,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"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,66 +4,12 @@ import { agents, agentWakeupRequests, caseHandoffs, cases, companies, createDb,
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { createRunDispatch } from "../../modules/run-dispatch/index.js";
|
||||
import type { createWakeQueue } from "../../modules/wake-queue/index.js";
|
||||
import { createPostgresWakeQueueAdapter } from "../../modules/wake-queue/adapters/postgres.js";
|
||||
import { createReleaseIssueExecution } from "../../modules/wake-queue/application/use-cases.js";
|
||||
import { createHeartbeatRunState } from "./run-state.js";
|
||||
import { createHeartbeatRunPreparation } from "./run-preparation.js";
|
||||
import { createHeartbeatQueue, type HeartbeatQueueDependencies } from "./queue.js";
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
function callbacks(db: Db) {
|
||||
const state = createHeartbeatRunState(db);
|
||||
const preparation = createHeartbeatRunPreparation(db);
|
||||
return {
|
||||
...preparation,
|
||||
...state,
|
||||
getAgent: vi.fn<HeartbeatQueueDependencies["getAgent"]>(async () => { throw new Error("Unexpected agent lookup"); }),
|
||||
options: {},
|
||||
setRunStatus: vi.fn<HeartbeatQueueDependencies["setRunStatus"]>(async (id, status, patch) =>
|
||||
db.update(heartbeatRuns).set({ ...patch, status }).where(eq(heartbeatRuns.id, id)).returning().then(rows => rows[0] ?? null)),
|
||||
setWakeupStatus: vi.fn<HeartbeatQueueDependencies["setWakeupStatus"]>(async () => undefined),
|
||||
appendRunEvent: vi.fn<HeartbeatQueueDependencies["appendRunEvent"]>(async () => undefined),
|
||||
finalizeAgentStatus: vi.fn(async () => undefined),
|
||||
getAgentInvokability: vi.fn<HeartbeatQueueDependencies["getAgentInvokability"]>(async () => ({ invokable: true })),
|
||||
getWorktreeExecutionCutoff: vi.fn(async () => null),
|
||||
applyRunDispatchPostCommitEffects: vi.fn(),
|
||||
issuesSvc: {
|
||||
getDependencyReadiness: vi.fn<HeartbeatQueueDependencies["issuesSvc"]["getDependencyReadiness"]>(),
|
||||
listDependencyReadiness: vi.fn(async () => new Map()),
|
||||
},
|
||||
treeControlSvc: { getActivePauseHoldGate: vi.fn(async () => null) },
|
||||
budgets: { getInvocationBlock: vi.fn(async () => null) },
|
||||
instanceSettings: { getExperimental: vi.fn<HeartbeatQueueDependencies["instanceSettings"]["getExperimental"]>() },
|
||||
runDispatch: createRunDispatch(db),
|
||||
wakeQueue: {
|
||||
createAdmissionTransactionScope: vi.fn<ReturnType<typeof createWakeQueue>["createAdmissionTransactionScope"]>(),
|
||||
admitWakeBehindIssueExecution: vi.fn<ReturnType<typeof createWakeQueue>["admitWakeBehindIssueExecution"]>(),
|
||||
},
|
||||
activeRunExecutions: new Set<string>(),
|
||||
activeRunExecutionPromises: new Set<Promise<void>>(),
|
||||
activeWakeupPromises: new Set<Promise<unknown>>(),
|
||||
liveRunExecutions: { has: vi.fn(() => false) },
|
||||
isHeartbeatRunTerminalStatus: (status: string | null | undefined) => ["succeeded", "failed", "cancelled", "timed_out"].includes(status ?? ""),
|
||||
isSameTaskScope: (left: string | null, right: string | null) => left === right,
|
||||
runTaskKey: (run: Run) => typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null,
|
||||
filterZombieCoalesceTarget: <T extends { id: string; status: string }>(run: T | null) => run,
|
||||
getSchedulingSuppression: vi.fn(async () => ({ suppressed: false, reason: null } as Awaited<ReturnType<HeartbeatQueueDependencies["getSchedulingSuppression"]>>)),
|
||||
canStartNewExternalBotWork: vi.fn(async () => true),
|
||||
executeRun: vi.fn(async () => undefined),
|
||||
applyWakeQueuePostCommitEffects: vi.fn(async () => undefined),
|
||||
releaseIssueExecutionAndPromote: vi.fn<HeartbeatQueueDependencies["releaseIssueExecutionAndPromote"]>(async () => undefined),
|
||||
publishRunLifecyclePluginEvent: vi.fn(),
|
||||
cancelRunInternal: vi.fn(async () => undefined),
|
||||
cancelActiveForAgentInternal: vi.fn(async () => undefined),
|
||||
resumeExecutionWaitComments: vi.fn(async () => undefined),
|
||||
resumeQueuedCommentInterrupt: vi.fn(async () => undefined),
|
||||
resumeSavedLegacyComments: vi.fn(async () => undefined),
|
||||
formatIssueIdentifierLink: (_identifier: string | null, fallback: string) => fallback,
|
||||
sweepPendingCleanupLeases: vi.fn<HeartbeatQueueDependencies["sweepPendingCleanupLeases"]>(),
|
||||
} satisfies HeartbeatQueueDependencies;
|
||||
}
|
||||
import { createHeartbeatQueueTestDependencies as callbacks } from "../../__tests__/helpers/heartbeat-queue.js";
|
||||
|
||||
function guardedDatabase() {
|
||||
const access = vi.fn(() => { throw new Error("Unexpected queue database access"); });
|
||||
return { db: new Proxy({}, { get: access }) as Db, access };
|
||||
|
||||
@@ -20,7 +20,7 @@ import { HttpError } from "../../errors.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { normalizeResponsibleUserDenialCode } from "../responsible-user-denial-run-outcomes.js";
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isExternalChatPresentationContext, mergeHeartbeatRunResultJson, resolveHeartbeatRunResponse, type RunPresentationDecision } from "../heartbeat-run-summary.js";
|
||||
import { CHAT_RUN_PRESENTATION_AUTHORIZATION_REASON, isChatOriginPublicationCandidate, isExternalChatPresentationContext, mergeHeartbeatRunResultJson, resolveHeartbeatRunResponse, type RunPresentationDecision } from "../heartbeat-run-summary.js";
|
||||
import { normalizeMaxTurnStopReason } from "../heartbeat-stop-metadata.js";
|
||||
import { CHAT_CONTROL_RECOVERY_UNRESOLVED_CODE } from "../chat-control-recovery-stop.js";
|
||||
import { logActivity } from "../activity-log.js";
|
||||
@@ -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";
|
||||
@@ -789,12 +789,7 @@ export function createHeartbeatRunCompletion(db: Db, dependencies: HeartbeatRunC
|
||||
livenessRun.id,
|
||||
livenessRun.companyId,
|
||||
));
|
||||
const externalChatPresentationCandidate =
|
||||
isExternalChatPresentationContext(livenessRun.contextSnapshot) ||
|
||||
parseObject(livenessRun.contextSnapshot).source === "issue.monitor" ||
|
||||
parseObject(livenessRun.contextSnapshot).source === "tool_action_review" ||
|
||||
String(parseObject(livenessRun.contextSnapshot).source ?? "").startsWith("issue.comment") ||
|
||||
parseObject(livenessRun.contextSnapshot).source === "issue.update";
|
||||
const externalChatPresentationCandidate = isChatOriginPublicationCandidate(livenessRun.contextSnapshot);
|
||||
const externalChatPresentationAuthorization =
|
||||
issueId && externalChatPresentationCandidate
|
||||
? await measureRunPhase("response.authorize", () => resolveChatRunPresentationAuthorizationReason(db, {
|
||||
@@ -809,7 +804,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",
|
||||
|
||||
@@ -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,61 @@
|
||||
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 dispatch = alias(agentWakeupRequests, "continuation_dispatch");
|
||||
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"),
|
||||
eq(agentWakeupRequests.status, "coalesced"),
|
||||
eq(agentWakeupRequests.requestedByActorType, "system"),
|
||||
eq(agentWakeupRequests.requestedByActorId, "native-status-committer"),
|
||||
sql`${agentWakeupRequests.payload}->>'issueId' = ${binding.issueId}`,
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'nativeStatusWakeIntentId' = ${agentWakeupRequests.id}::text`,
|
||||
))
|
||||
.innerJoin(dispatch, and(
|
||||
eq(dispatch.companyId, binding.companyId), eq(dispatch.agentId, heartbeatRuns.agentId),
|
||||
eq(dispatch.runId, heartbeatRuns.id), eq(dispatch.reason, "issue_status_changed"),
|
||||
eq(dispatch.requestedByActorType, "system"),
|
||||
sql`${dispatch.requestedByActorId} = 'native-status-wake-dispatch:' || ${agentWakeupRequests.id}::text`,
|
||||
eq(dispatch.idempotencyKey, agentWakeupRequests.idempotencyKey),
|
||||
inArray(dispatch.status, ["queued", "claimed", "completed", "coalesced"]),
|
||||
sql`${dispatch.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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user