fix(server): schedule stopped instruction collection

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 20:26:32 -05:00
1 parent 854244092a
commit 6dc58115cd
36 files changed
+780 -93

No files matched your search

@@ -2134,3 +2134,19 @@ Local verification currently has 200 passing cases across directory, startup,
captured instruction and heartbeat release suites, plus workspace typecheck/build.
Remote acceptance is pending. Stopped collection and the remaining
original inventory and older stack review/CI gates remain outstanding.
## Slice 41 acceptance and slice 42 collection
Parent directory cleanup PR #17671 at
`854244092a993b621ab8efd2ca9f80a0e3b0b9d4` has all 53 checks successful,
Greptile 5/5 at that head, and no unresolved review findings. It remains open.
Slice 42 migrates stopped instruction collection, including terminal-run writers
that make unchanged copy rows eligible. The producer, lifecycle, partial-index,
and verification audit is in
[the stopped collection audit](2026-10-25-stopped-instruction-collection-sleep-audit.md).
The initial eight-suite acceptance selection passed 269 tests; directory-owner
coverage passed 101 separately. Exact-head remote acceptance is still required.
Other native recovery, dispatch, retention, integration and aggregate-tick duties
remain in scope. The older CI/review gates recorded above are not discharged by
this slice's local tests.
@@ -0,0 +1,158 @@
# Stopped instruction collection
Date: 2026-10-25. Parent: directory cleanup PR #17671 at
`854244092a993b621ab8efd2ca9f80a0e3b0b9d4`.
## Obligation and owner
The orphan reaper previously rediscovered stopped instruction copies on every
maintenance pass. This slice migrates that one reason to committed signals and
saved retry deadlines. Captured candidate commits (#17670) and post-save
physical release (#17671) remain separate workers. No new table is needed.
The instruction working-copy service owns eligibility, reconstruction and
collection scheduling. The directory service still owns directory locks,
materialization identity, warm handoff, stop proof and physical operations.
Native and legacy run owners emit a normalized company/run signal before their
status writes. They do not inspect instruction receipts or perform file work.
Startup only wires the existing coordinator and scheduler.
## Existing durable predicate
A copy is eligible when its same-company run is terminal (succeeded, failed,
cancelled, timed_out or interrupted), its receipt has no retainedByRunId key,
and either:
- It has fewer than three collection attempts and is prepared,
pending_collection, unchanged_turn, warm_saved, or an agent-files.v1 copy in
preparing state.
- It is an unavailable remote agent-files.v1 copy without processStoppedAt.
This existing cleanup-proof branch is not subject to the collection cap.
Active prepared copies and retained warm owners are excluded. Exhausted or
completed copy history is passive. Terminal run status is eligibility for
inspection, not proof that the provider has stopped.
Startup loads scalar run IDs, company IDs and nextAttemptAt in keyset pages of
100. The shared worker keeps outstanding IDs and selects fair batches of 20.
Each due check rereads eligibility, honors a later saved deadline, and reserves
30 seconds through the existing copy CAS before I/O. The claim also verifies
that the same-company run is still terminal. A resumed run cannot be collected
merely because it was in an earlier discovery result.
The existing collection owner then applies its rules:
- Interrupted directory preparation can be discarded because launch requires
prepared state first.
- Local collection requires saved stop proof or a valid server-authored native
stop event, including its existing launch-order validation.
- Remote collection retains exact lease and termination checks. Unknown ownership
stays pending. Recovery never starts a replacement environment to fetch files.
- Failed capture retains candidate/error state and the existing attempt limit.
- Captured bytes hand off to the existing commit owner. Directory cleanup keeps
its own receipt and owner.
The worker rereads durable state after the attempt. Settled entries disarm;
unresolved entries retain their saved future deadline. A failure or unresolved
entry without a future deadline uses the shared bounded error backoff. Later
stop evidence is covered by the already-pending retry; it needs no recurring
scan of unrelated process events or environment leases.
## Producer audit and transaction ownership
Copy writes alone are insufficient. Run terminalization creates eligibility
without changing the copy row. The audit inspected run inserts/updates as well
as both instruction-copy owners.
| Producer family | Integration |
| --- | --- |
| Common instruction-copy patch | Signal changes into, within and out of potential collection work, inside the short existing transaction |
| Legacy copy preparation | Signal before the preparing insert; stage files outside the new short transaction |
| Directory preparation and warm successor insert | Signal in a short insert transaction; preserve insertion before the agent-row handoff lock |
| Directory adoption | Signal prior and successor scopes inside the existing handoff transaction |
| Heartbeat run lifecycle and legacy terminalization | Signal exact company/run inside existing status transaction |
| Heartbeat queue and run-dispatch adapter | Signal cancellation, supersession and other terminal writes inside their existing transactions |
| Queued-comment adapter and active-run watchdog | Signal exact terminalized run in the existing transaction |
| Execution-control and task recovery | Signal after candidate eligibility checks, before terminal write |
| Native finalizer, restart recovery and finalization reconciler | Signal terminal projection and terminal recovery writes in their owning transactions |
| Native session executor and status-decision committer | Signal verified terminal failure/cancellation; retain unverified-ownership behavior |
| Agent/company deletion | Invalidate company scope before cascades |
| Worktree execution policy | Signal globally through the existing settings transaction |
Metadata-only run writes do not create eligibility. A reset to running is
rechecked before execution; the subsequent terminal transition signals again.
Deletion of an unsuccessful directory reservation cannot create new collection
work. An already-known entry is invalidated at its next scheduled check.
Signals join existing outer transaction settlement, including nested writes and
ambiguous commit outcomes. No filesystem/provider call is wrapped in a new
transaction. Signal handling can inspect the exact run even if it has no copy;
this is bounded producer-time work, not recurring empty discovery. The supported
model is one process with instrumented writers. Independent SQL writers and
additional replicas still need explicit integration.
## Lifecycle and index
The heartbeat facade retains task-drain and worktree-execution admission.
Committed changes during drain/standby remain pending without passive SQL.
Startup/resume reconstructs existing debt. Shutdown joins admitted work and
retires subscriptions and deadlines. Manual/startup recoverStopped remains
available; only the migrated periodic caller skips it.
Migration 0396 adds the concurrent, replay-safe partial collection index on
(run_id, company_id). Its predicate excludes ineligible copy history; terminal
run eligibility remains a same-company join. The migration is registered with
the existing nontransactional runner and retains the five newest snapshots.
Memory and startup cost scale with actual pending copy candidates, including
active candidates filtered by the run join; this is not a constant-cost claim.
## Verification and preserved failures
- 269 tests passed across instruction copies, startup wiring, run dispatch,
queued-comment dispatch, heartbeat queue/recovery, execution-control recovery,
and native finalization recovery. No tests were skipped in this selection.
- The instruction suite has 45 cases, including 12 stopped-worker cases. These
cover quiet active/history states, real terminalization without a copy write,
nested rollback/commit, restart, drain/standby, saved deadlines, delayed stop
evidence, resumed-run claim races, 103 candidates, a failed claim, and shutdown
during a save. Filesystem and PostgreSQL effects are exercised together.
- The directory owner suite passed all 101 cases after updating the existing
stale-collector race injector to intercept the actual transaction boundary.
- Workspace typecheck and build passed.
- Native session/restart/reconciler tests passed 814 cases. Fifteen embedded-only
remote warm ownership cases were filtered out locally. Native finalizer tests
initially skipped all 27 cases because embedded PostgreSQL was unavailable;
the suite now accepts a dedicated external URL and passed all 29 cases,
including two real terminal-projection notification cases. These skips are
not counted as passes. Full CI must exercise the embedded-only fixtures.
- Fresh migration application, migration-runner replay, direct index replay and
migration numbering/safety checks passed.
- A rolled-back PostgreSQL fixture added 10,000 completed-history rows and one
pending terminal copy. The plan used the partial collection index and run PK
join, returned one row, and used 12 buffer hits (0.026 ms local execution).
Dead index entries existed in this transaction. This is evidence of index
selection, not a production latency or one-index-tuple claim.
The first broad run passed 187 tests and failed one existing directory race
test: its direct-update spy no longer injected adoption after the mutation moved
into a transaction. The repaired test injects at that transaction and retains
its original ownership, untouched-file, attempt and stop-proof assertions. The
subsequent instruction/directory run passed 143 tests. The later eight-suite run
includes three additional collection lifecycle tests and passed 269 tests.
A first added native-finalizer notification test passed 27 cases and failed one:
it expected a collection wake for a metadata-only finalizer failure. That API
only writes terminal run status when projectRunStatus is enabled. The final test
separates both cases: metadata-only failure leaves the run running and emits no
collection signal; terminal projection emits the signal. All 29 finalizer cases
then passed on a fresh database. No production behavior was weakened.
The embedded PostgreSQL support probe failed before suite startup on this host.
Tests used fresh disposable PostgreSQL 17 databases through the existing
validated external-test-database helper. Native finalization, native finalizer and execution-control
suites now accept opt-in dedicated URLs; normal embedded CI remains unchanged.
The failed harness log and all prior test logs remain available locally.
Full remote CI and review are required for this PR's exact head. This slice does
not establish Cloud alarm coverage, deployed database/host suspension, fleet
savings, or completion of the remaining orphan-reaper migrations.
+1
View File
@@ -521,6 +521,7 @@ const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([
"0393_fearless_supernaut.sql",
"0394_breezy_mongu.sql",
"0395_colorful_darkstar.sql",
"0396_late_cerise.sql",
"0380_funny_adam_warlock.sql",
]);
@@ -0,0 +1,5 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS "agent_instruction_copies_collection_idx" ON "agent_instruction_working_copies" USING btree ("run_id","company_id") WHERE NOT (coalesce("agent_instruction_working_copies"."receipt", '{}'::jsonb) ? 'retainedByRunId') AND (
("agent_instruction_working_copies"."attempts" < 3 AND ("agent_instruction_working_copies"."state" IN ('prepared', 'pending_collection', 'unchanged_turn', 'warm_saved')
OR ("agent_instruction_working_copies"."state" = 'preparing' AND "agent_instruction_working_copies"."receipt"->>'schema' = 'paperclip.agent-files.v1')))
OR ("agent_instruction_working_copies"."state" = 'unavailable' AND "agent_instruction_working_copies"."process_stopped_at" IS NULL
AND "agent_instruction_working_copies"."location" LIKE 'remote:%' AND "agent_instruction_working_copies"."receipt"->>'schema' = 'paperclip.agent-files.v1'));
@@ -1,6 +1,6 @@
{
"id": "5f2e1d87-0147-430a-9bdf-a8df947725f1",
"prevId": "887083ec-214e-44ab-a1ab-25bff321118e",
"id": "27bec2e7-b637-4317-aa3c-a457bcc709fe",
"prevId": "e9c56475-b2c8-4cb4-b7dd-89bfa645a93a",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2637,6 +2637,50 @@
}
},
"indexes": {
"agent_instruction_copies_collection_idx": {
"name": "agent_instruction_copies_collection_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "NOT (coalesce(\"agent_instruction_working_copies\".\"receipt\", '{}'::jsonb) ? 'retainedByRunId') AND (\n (\"agent_instruction_working_copies\".\"attempts\" < 3 AND (\"agent_instruction_working_copies\".\"state\" IN ('prepared', 'pending_collection', 'unchanged_turn', 'warm_saved')\n OR (\"agent_instruction_working_copies\".\"state\" = 'preparing' AND \"agent_instruction_working_copies\".\"receipt\"->>'schema' = 'paperclip.agent-files.v1')))\n OR (\"agent_instruction_working_copies\".\"state\" = 'unavailable' AND \"agent_instruction_working_copies\".\"process_stopped_at\" IS NULL\n AND \"agent_instruction_working_copies\".\"location\" LIKE 'remote:%' AND \"agent_instruction_working_copies\".\"receipt\"->>'schema' = 'paperclip.agent-files.v1'))",
"concurrently": false,
"method": "btree",
"with": {}
},
"agent_instruction_copies_cleanup_idx": {
"name": "agent_instruction_copies_cleanup_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"agent_instruction_working_copies\".\"state\" IN ('saved', 'unchanged', 'resolved', 'unavailable')\n AND \"agent_instruction_working_copies\".\"process_stopped_at\" IS NOT NULL\n AND \"agent_instruction_working_copies\".\"receipt\"->>'schema' = 'paperclip.agent-files.v1'\n AND coalesce(\"agent_instruction_working_copies\".\"receipt\"->>'retainedByRunId', '') = ''\n AND \"agent_instruction_working_copies\".\"receipt\"->>'cleanupPending' IS DISTINCT FROM 'false'\n AND (\"agent_instruction_working_copies\".\"receipt\" ? 'baseline' OR \"agent_instruction_working_copies\".\"receipt\"->>'cleanupPending' = 'true')",
"concurrently": false,
"method": "btree",
"with": {}
},
"agent_instruction_copies_pending_idx": {
"name": "agent_instruction_copies_pending_idx",
"columns": [
@@ -9617,6 +9661,50 @@
"method": "btree",
"with": {}
},
"chat_actions_github_stale_head_idx": {
"name": "chat_actions_github_stale_head_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "(\"payload\"->'event'->>'deliveryId')",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_review_check'\n and \"chat_actions\".\"status\" = 'cancelled' and \"chat_actions\".\"result\"->>'code' = 'stale_head'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_check_work_idx": {
"name": "chat_actions_github_check_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_actions\".\"kind\" = 'github_review_check'\n and (\"chat_actions\".\"status\" = 'received' or (\"chat_actions\".\"status\" = 'failed' and \"chat_actions\".\"result\"->>'retryable' = 'true'))",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_actions_github_terminal_work_idx": {
"name": "chat_actions_github_terminal_work_idx",
"columns": [
@@ -13121,6 +13209,22 @@
}
},
"indexes": {
"chat_github_reviews_projection_work_idx": {
"name": "chat_github_reviews_projection_work_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"chat_github_reviews\".\"state\" in ('queued', 'running')\n and \"chat_github_reviews\".\"assessment\" is null\n and \"chat_github_reviews\".\"delivery_id\" ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'",
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_github_reviews_delivery_uq": {
"name": "chat_github_reviews_delivery_uq",
"columns": [
@@ -33583,6 +33687,22 @@
"method": "btree",
"with": {}
},
"heartbeat_runs_scheduled_retry_cancellation_idx": {
"name": "heartbeat_runs_scheduled_retry_cancellation_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"heartbeat_runs\".\"status\" = 'scheduled_retry' and jsonb_typeof(\"heartbeat_runs\".\"result_json\"->'startupCancellation') = 'object'",
"concurrently": false,
"method": "btree",
"with": {}
},
"heartbeat_runs_cost_accounting_pending_idx": {
"name": "heartbeat_runs_cost_accounting_pending_idx",
"columns": [
+7
View File
@@ -2759,6 +2759,13 @@
"when": 1792975788326,
"tag": "0395_colorful_darkstar",
"breakpoints": true
},
{
"idx": 396,
"version": "7",
"when": 1792976876772,
"tag": "0396_late_cerise",
"breakpoints": true
}
]
}
@@ -114,6 +114,11 @@ export const agentInstructionWorkingCopies = pgTable(
},
(t) => ({
owner: foreignKey({ columns: [t.companyId, t.agentId], foreignColumns: [agents.companyId, agents.id] }).onDelete("cascade"),
collection: index("agent_instruction_copies_collection_idx").on(t.runId, t.companyId).where(sql`NOT (coalesce(${t.receipt}, '{}'::jsonb) ? 'retainedByRunId') AND (
(${t.attempts} < 3 AND (${t.state} IN ('prepared', 'pending_collection', 'unchanged_turn', 'warm_saved')
OR (${t.state} = 'preparing' AND ${t.receipt}->>'schema' = 'paperclip.agent-files.v1')))
OR (${t.state} = 'unavailable' AND ${t.processStoppedAt} IS NULL
AND ${t.location} LIKE 'remote:%' AND ${t.receipt}->>'schema' = 'paperclip.agent-files.v1'))`),
directoryCleanup: index("agent_instruction_copies_cleanup_idx").on(t.runId, t.companyId).where(sql`${t.state} IN ('saved', 'unchanged', 'resolved', 'unavailable')
AND ${t.processStoppedAt} IS NOT NULL
AND ${t.receipt}->>'schema' = 'paperclip.agent-files.v1'
@@ -433,25 +433,15 @@ describe("persistent agent directories", () => {
const first = await run();
expect(await copies.hasChanges({ companyId, runId: first.runId })).toBe(false);
const secondId = await nextRun();
const update = db.update.bind(db);
const transaction = db.transaction.bind(db);
let injected = false;
const spy = vi.spyOn(db, "update").mockImplementation(table => {
const builder = update(table);
if (table !== agentInstructionWorkingCopies) return builder;
const set = builder.set.bind(builder);
builder.set = (values => {
const query = set(values);
if (!("processStoppedAt" in values) || !values.processStoppedAt || injected) return query;
const returning = query.returning.bind(query);
query.returning = (async () => {
injected = true;
expect(await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).not.toBeNull();
return returning();
}) as typeof query.returning;
return query;
}) as typeof builder.set;
return builder;
});
const spy = vi.spyOn(db, "transaction").mockImplementation((async (callback, options) => {
if (!injected) {
injected = true;
expect(await copies.adopt({ companyId, agentId, runId: secondId, previousRunId: first.runId, cwd: home })).not.toBeNull();
}
return transaction(callback, options);
}) as typeof db.transaction);
try {
expect(await copies.collectStopped({ companyId, runId: first.runId })).toMatchObject({
receipt: { retainedByRunId: secondId }, processStoppedAt: null, attempts: 0,
@@ -1,3 +1,4 @@
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
import { heartbeatService } from "../services/heartbeat.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
@@ -79,6 +80,216 @@ describe("registered run instruction copies", () => {
await fs.writeFile(path.join(root, entryFile), initial);
});
describe("stopped instruction collection worker", () => {
afterEach(async () => {
vi.useRealTimers(); vi.restoreAllMocks();
await db.update(agentInstructionWorkingCopies).set({ state: "saved", nextAttemptAt: null })
.where(eq(agentInstructionWorkingCopies.companyId, companyId));
});
const clock = () => vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const coordinator = (canRun = () => true, canReconcile = () => true) =>
createDeliveryWorkCoordinator({ owner: db, canRun, canReconcile, onError: () => {} });
async function prepared(stopped = true) {
const copy = await run();
await fs.writeFile(path.join(copy.localRoot, entryFile), "collected after stop");
await db.update(heartbeatRuns).set({ status: "running", runtimeMode: "legacy" }).where(eq(heartbeatRuns.id, copy.runId));
if (stopped) await db.update(agentInstructionWorkingCopies).set({ processStoppedAt: new Date() })
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
return copy;
}
async function terminal(runId: string, owner = db) {
const [run] = await owner.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(await terminalizeLegacyExecution({ db: owner, run, status: "failed", patch: { finishedAt: new Date() } }))
.toMatchObject({ id: runId, status: "failed" });
}
async function saved(runId: string) {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(100);
expect((await copies.get(companyId, runId))?.state).toBe("saved");
}, { timeout: 5000 });
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("collected after stop");
}
it("leaves active copies and completed or exhausted history quiet", async () => {
const active = await prepared(false), finished = await prepared(), exhausted = await prepared();
await terminal(finished.runId); await terminal(exhausted.runId);
await db.update(agentInstructionWorkingCopies).set({ state: "saved" }).where(eq(agentInstructionWorkingCopies.runId, finished.runId));
await db.update(agentInstructionWorkingCopies).set({ attempts: 3 }).where(eq(agentInstructionWorkingCopies.runId, exhausted.runId));
clock(); const worker = coordinator();
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
const reads = vi.spyOn(db, "select"); await vi.advanceTimersByTimeAsync(120_000);
expect(reads).not.toHaveBeenCalled(); expect(worker.nextWakeAt()).toBeNull();
expect((await copies.get(companyId, active.runId))?.state).toBe("prepared");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
} finally { await worker.stop(); }
});
it("reactivates when the real terminalization producer changes only the run", async () => {
const copy = await prepared(); clock(); const worker = coordinator();
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
expect(worker.nextWakeAt()).toBeNull();
await terminal(copy.runId); await saved(copy.runId);
await vi.advanceTimersByTimeAsync(1000); await expect.poll(() => idleWorkSnapshot().active).toBe(0);
const reads = vi.spyOn(db, "select"); await vi.advanceTimersByTimeAsync(120_000);
expect(reads).not.toHaveBeenCalled(); expect(worker.nextWakeAt()).toBeNull();
} finally { await worker.stop(); }
});
it.each(["restart", "drain", "standby"] as const)("retains terminal collection work across %s", async mode => {
const copy = await prepared(); if (mode === "restart") await terminal(copy.runId);
let admitted = mode === "restart";
clock(); const worker = coordinator(() => admitted, () => mode !== "standby");
try {
await agentInstructionWorkingCopyService(db).registerStoppedCollectionWork(worker, () => admitted, async () => true).ready;
if (mode !== "restart") {
await terminal(copy.runId); await vi.advanceTimersByTimeAsync(1000);
if (mode === "drain") await expect.poll(() => idleWorkSnapshot().active).toBe(0);
const reads = vi.spyOn(db, "select"); await vi.advanceTimersByTimeAsync(60_000);
expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
admitted = true; await vi.advanceTimersByTimeAsync(30_000);
}
await saved(copy.runId);
} finally { await worker.stop(); }
});
it("keeps terminalization inside the outer transaction until commit", async () => {
const copy = await prepared(); clock(); const worker = coordinator();
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
await expect(db.transaction(async tx => {
await terminal(copy.runId, tx as unknown as typeof db);
await vi.advanceTimersByTimeAsync(1000);
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
throw new Error("rollback terminal run");
})).rejects.toThrow("rollback terminal run");
await vi.advanceTimersByTimeAsync(1000);
expect((await copies.get(companyId, copy.runId))?.state).toBe("prepared");
await db.transaction(async tx => {
await terminal(copy.runId, tx as unknown as typeof db);
await vi.advanceTimersByTimeAsync(1000);
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
});
await saved(copy.runId);
} finally { await worker.stop(); }
});
it("keeps the saved future deadline instead of collecting on notification", async () => {
const copy = await prepared(); clock(); const due = new Date(Date.now() + 90_000);
await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: due }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
const worker = coordinator();
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
await terminal(copy.runId); await vi.advanceTimersByTimeAsync(1000);
await expect.poll(() => idleWorkSnapshot().active).toBe(0);
expect(worker.nextWakeAt()).toBe(due.getTime());
const reads = vi.spyOn(db, "select"); await vi.advanceTimersByTimeAsync(60_000);
expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
await vi.advanceTimersByTimeAsync(30_000); await saved(copy.runId);
} finally { await worker.stop(); }
});
it("never substitutes terminal status for stop proof and recovers later recorded evidence", async () => {
const copy = await prepared(false); await terminal(copy.runId);
await db.update(heartbeatRuns).set({ runtimeMode: "native" }).where(eq(heartbeatRuns.id, copy.runId));
clock(); const worker = coordinator();
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
expect(await copies.get(companyId, copy.runId)).toMatchObject({ state: "pending_collection", processStoppedAt: null });
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" });
await vi.advanceTimersByTimeAsync(30_000); await saved(copy.runId);
} finally { await worker.stop(); }
});
it("reconstructs more than one page without a recurring idle scan", async () => {
const copy = await run();
const ids = Array.from({ length: 103 }, () => randomUUID());
await db.insert(heartbeatRuns).values(ids.map(id => ({ id, companyId, agentId, status: "failed",
runtimeMode: "legacy", invocationSource: "on_demand", responsibleUserId: userId })));
await db.insert(agentInstructionWorkingCopies).values(ids.map(runId => ({ ...copy, runId,
processStoppedAt: new Date(), nextAttemptAt: null,
})));
clock(); const worker = coordinator();
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
const rows = () => db.select({ state: agentInstructionWorkingCopies.state }).from(agentInstructionWorkingCopies)
.where(eq(agentInstructionWorkingCopies.companyId, companyId));
expect((await rows()).filter(row => row.state === "unchanged").length).toBeLessThanOrEqual(20);
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(1000);
expect((await rows()).filter(row => row.state === "unchanged")).toHaveLength(103);
}, { timeout: 15_000 });
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
await vi.advanceTimersByTimeAsync(1000);
await expect.poll(() => idleWorkSnapshot().active).toBe(0);
const reads = vi.spyOn(db, "select"); await vi.advanceTimersByTimeAsync(120_000);
expect(reads).not.toHaveBeenCalled(); expect(worker.nextWakeAt()).toBeNull();
} finally { await worker.stop(); }
}, 20_000);
it("retries a failed collection claim without losing the preserved bytes", async () => {
const copy = await prepared(); await terminal(copy.runId); clock(); const worker = coordinator();
const transactions = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("collection claim unavailable"));
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
expect((await copies.get(companyId, copy.runId))?.state).toBe("prepared");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
transactions.mockRestore();
const reads = vi.spyOn(db, "select"); await vi.advanceTimersByTimeAsync(20_000);
expect(reads).not.toHaveBeenCalled(); reads.mockRestore();
await vi.advanceTimersByTimeAsync(10_000); await saved(copy.runId);
await vi.advanceTimersByTimeAsync(1000);
await expect.poll(() => idleWorkSnapshot().active).toBe(0);
expect(worker.nextWakeAt()).toBeNull();
} finally { transactions.mockRestore(); await worker.stop(); }
});
it("joins an in-flight collection before shutdown and restarts quiet", async () => {
const copy = await prepared(); await terminal(copy.runId); clock(); const worker = coordinator();
const rename = fs.rename.bind(fs);
let release!: () => void, entered!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const reached = new Promise<void>(resolve => { entered = resolve; });
const renameSpy = vi.spyOn(fs, "rename").mockImplementation(async (from, to) => {
if (to === path.join(root, entryFile)) { entered(); await held; }
return rename(from, to);
});
let stopped: Promise<void> | undefined;
try {
const registration = copies.registerStoppedCollectionWork(worker, () => true, async () => true);
await reached;
let complete = false;
stopped = worker.stop().then(() => { complete = true; });
await new Promise<void>(resolve => setImmediate(resolve));
expect(complete).toBe(false);
release(); await stopped; await registration.ready;
expect((await copies.get(companyId, copy.runId))?.state).toBe("saved");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("collected after stop");
const restarted = coordinator();
try {
await copies.registerStoppedCollectionWork(restarted, () => true, async () => true).ready;
expect(restarted.nextWakeAt()).toBeNull();
} finally { await restarted.stop(); }
} finally { release(); renameSpy.mockRestore(); await stopped; await worker.stop(); }
});
it("checks current run eligibility when a queued callback meets a resumed run", async () => {
const copy = await prepared(); await terminal(copy.runId); clock(); const worker = coordinator();
const transaction = db.transaction.bind(db); let resumed = false;
const transactions = vi.spyOn(db, "transaction").mockImplementation((async (callback, options) => {
if (!resumed) {
resumed = true;
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, copy.runId));
}
return transaction(callback, options);
}) as typeof db.transaction);
try {
await copies.registerStoppedCollectionWork(worker, () => true, async () => true).ready;
transactions.mockRestore();
expect(resumed).toBe(true);
expect((await copies.get(companyId, copy.runId))?.state).toBe("prepared");
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
await vi.advanceTimersByTimeAsync(1000); await expect.poll(() => idleWorkSnapshot().active).toBe(0);
expect(worker.nextWakeAt()).toBeNull();
await terminal(copy.runId); await saved(copy.runId);
} finally { transactions.mockRestore(); await worker.stop(); }
});
});
describe("captured instruction commit worker", () => {
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });
const clock = () => vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
@@ -28,6 +29,8 @@ import {
import { PaperclipControlPlanePort } from "../services/native-runtime/paperclip-control-plane-port.js";
import { assertRetainedNativeSourceArchiveSettled } from "../services/native-runtime/native-session-executor.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_FINALIZATION_TEST_DATABASE_URL");
describe("P6-16/P6-25/P6-28 native finalization recovery", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
@@ -41,8 +44,8 @@ describe("P6-16/P6-25/P6-28 native finalization recovery", () => {
const staleRunId = "72000000-0000-4000-8000-000000000015";
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-recovery-");
db = createDb(temporary.connectionString);
if (!external) temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-recovery-");
db = createDb(external ?? temporary.connectionString);
await db.insert(companies).values({ id: companyId, name: "Native recovery", issuePrefix: "NRC" });
await db.insert(agents).values({
id: agentId,
@@ -210,7 +213,8 @@ describe("P6-16/P6-25/P6-28 native finalization recovery", () => {
}, 30_000);
afterAll(async () => {
await temporary.cleanup();
await db?.$client.end({ timeout: 1 });
await temporary?.cleanup();
});
it("fails closed into bounded named recovery without consulting the live flag or falling back", async () => {
@@ -355,10 +359,10 @@ describe("retained native cleanup discovery", () => {
];
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase(
if (!external) temporary = await startEmbeddedPostgresTestDatabase(
"paperclip-native-cleanup-sweep-",
);
db = createDb(temporary.connectionString);
db = createDb(external ?? temporary.connectionString);
await db
.insert(companies)
.values({ id: companyId, name: "Cleanup sweep", issuePrefix: "NCS" });
@@ -382,7 +386,8 @@ describe("retained native cleanup discovery", () => {
});
});
afterAll(async () => {
await temporary.cleanup();
await db?.$client.end({ timeout: 1 });
await temporary?.cleanup();
});
async function candidate(
@@ -107,6 +107,7 @@ const {
reconcileHotRestartAdoption: vi.fn(async () => ({ mode: "none" })),
reapOrphanedRuns: vi.fn(async () => ({ reaped: 0, runIds: [] })),
registerScheduledRetryCancellationWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerStoppedInstructionCollectionWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerInstructionDirectoryCleanupWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
registerCapturedInstructionCommitWork: vi.fn((_coordinator: unknown, _canRun: () => boolean) => ({ ready: Promise.resolve() })),
promoteDueScheduledRetries: vi.fn(async () => ({ promoted: 0, runIds: [] })),
@@ -541,6 +542,13 @@ describe("startServer feedback export wiring", () => {
process.env.BETTER_AUTH_SECRET = "test-secret";
});
it.each([false, true])("registers stopped instruction collection only when maintenance is enabled (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
expect(heartbeatServiceMock.registerStoppedInstructionCollectionWork).toHaveBeenCalledTimes(enabled ? 1 : 0);
if (enabled) expect(heartbeatServiceMock.registerStoppedInstructionCollectionWork.mock.calls[0][1]()).toBe(true);
});
it.each([false, true])("registers directory cleanup recovery only when maintenance is enabled (%s)", async enabled => {
loadConfigMock.mockReturnValue(buildTestConfig({ heartbeatSchedulerEnabled: enabled }));
await startServer(); await settleStartupHousekeeping();
@@ -912,7 +920,7 @@ describe("startServer feedback export wiring", () => {
intervalCallback?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(heartbeatServiceMock.sweepStaleIssueLocks).toHaveBeenCalledTimes(2);
expect(heartbeatServiceMock.reapOrphanedRuns).toHaveBeenLastCalledWith({ staleThresholdMs: 300_000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true });
expect(heartbeatServiceMock.reapOrphanedRuns).toHaveBeenLastCalledWith({ staleThresholdMs: 300_000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true, skipStoppedInstructionCollection: true });
expect(heartbeatServiceMock.reconcileCostAccounting).toHaveBeenCalledTimes(1);
expect(retiredDetector).not.toHaveBeenCalled();
} finally {
+3 -1
View File
@@ -1777,6 +1777,8 @@ async function startServerWithDatabaseTeardown(
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
if (config.heartbeatSchedulerEnabled) await heartbeat.registerInstructionDirectoryCleanupWork(deliveryWork,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
if (config.heartbeatSchedulerEnabled) await heartbeat.registerStoppedInstructionCollectionWork(deliveryWork,
() => !isIdleTaskDrainActive() && !heartbeatSchedulerStopped && !isWarmStandby()).ready;
const runRetentionSweep = async () => {
const activeCompanies = await db.select({ id: companies.id }).from(companies).where(eq(companies.status, "active"));
@@ -1905,7 +1907,7 @@ async function startServerWithDatabaseTeardown(
// Periodically reap orphaned runs (5-min staleness threshold) and make sure
// persisted queued work is still being driven forward.
trackHeartbeatSchedulerWork(heartbeat
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true })
.reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000, skipScheduledRetryCancellation: true, skipCapturedInstructionCommit: true, skipInstructionDirectoryCleanup: true, skipStoppedInstructionCollection: true })
.then(() => heartbeat.promoteDueScheduledRetries())
.then(async (promotion) => {
await heartbeat.resumeQueuedRuns();
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../../../services/instruction-collection-notifications.js";
import { and, asc, desc, eq, gt, gte, inArray, ne, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -337,6 +338,7 @@ export function createPostgresWatchdogAdapter(db: Db): WatchdogRunReader & Watch
};
const transactionResult = await db.transaction(async (tx) => {
await notifyInstructionCollection(tx, { companyId, runId: input.run.id });
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../../../services/instruction-collection-notifications.js";
import { notifyScheduledRetryCancellation } from "../../../services/scheduled-retry-cancellation-notifications.js";
import { hasConversationContinuationPolicy } from "../../../services/conversation-continuation.js";
import { getExecutionBlocker } from "../../../services/execution-blocker.js";
@@ -667,6 +668,7 @@ export function createPostgresRunDispatchAdapter(
| { applied: true; run: HeartbeatRun; postCommitEffects: PostCommitEffect[] }
| { applied: false }
> {
await notifyInstructionCollection(tx, { companyId: input.companyId, runId: input.runId });
await notifyScheduledRetryCancellation(tx, { companyId: input.companyId, runId: input.runId });
await notifyChatRunMilestones(tx, { companyId: input.companyId, issueId: issueId ?? undefined });
const [row] = await tx
@@ -710,6 +712,7 @@ export function createPostgresRunDispatchAdapter(
tx: Db,
input: CancelSuppressedRetryInput,
): Promise<CancelSuppressedRetryResult> {
await notifyInstructionCollection(tx, { companyId: input.companyId, runId: input.runId });
await notifyScheduledRetryCancellation(tx, { companyId: input.companyId, runId: input.runId });
await notifyChatRunMilestones(tx, { companyId: input.companyId, issueId: input.issueId ?? undefined });
const [row] = await tx
@@ -917,6 +920,7 @@ export function createPostgresRunDispatchAdapter(
now: Date,
providerDispatchNotStarted = false,
): Promise<CancelStaleQueuedRunOutcome> {
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, run.companyId);
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId });
const [row] = await tx
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../../../services/instruction-collection-notifications.js";
import { notifyChatInboundWakeNotices } from "../../../services/chat-inbound-wake-notice-notifications.js";
import { notifyChatRunMilestones } from "../../../services/chat-run-milestone-notifications.js";
import { and, eq, inArray } from "drizzle-orm";
@@ -103,6 +104,7 @@ function buildTransaction(tx: Db, companyId: string, issueId: string, deps: Queu
},
async cancelQueueRun({ queueRunId, reason, now }) {
await notifyInstructionCollection(tx, { companyId, runId: queueRunId });
await notifyChatRunMilestones(tx, { companyId, issueId });
const row = await tx
.update(heartbeatRuns)
+2
View File
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
import { notifyInstructionCommit } from "./instruction-commit-notifications.js";
import { notifyScheduledRetryCancellation } from "./scheduled-retry-cancellation-notifications.js";
@@ -48,6 +49,7 @@ export async function deleteAgentDependencies(tx: Db, companyId: string, id: str
await notifyScheduledRetryCancellation(tx, { companyId: companyId });
await notifyInstructionCommit(tx, { companyId: companyId });
await notifyDirectoryCleanup(tx, { companyId: companyId });
await notifyInstructionCollection(tx, { companyId: companyId });
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
@@ -40,6 +41,12 @@ function actor(row: Copy): AuthorizationActor {
}
export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string, runId: string) => Promise<Copy | null>, patch: (row: Copy, values: Partial<typeof copies.$inferInsert>) => Promise<Copy>, environmentRuntime?: EnvironmentRuntimeService) {
const store = agentFileStore(db);
async function insertCopy(values: typeof copies.$inferInsert) {
return db.transaction(async tx => {
await notifyInstructionCollection(tx, { companyId: values.companyId, runId: values.runId });
return tx.insert(copies).values(values).returning();
});
}
const ownerFile = (row: Copy) => path.join(path.dirname(row.localRoot), "owner.json");
async function owns(row: Copy) {
if (row.receipt?.warm !== true) return true;
@@ -104,8 +111,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
const receipt = { ...current.receipt, cleanup: input.target?.kind === "remote" ? { leaseId: input.target.leaseId ?? null, remoteCwd: input.target.remoteCwd } : undefined };
// Insert before taking the agent-row lock: this foreign key needs an
// independent transaction, and must survive post-handoff failures.
const [successor] = await db.insert(copies).values({ runId: input.runId, companyId: input.companyId, agentId: input.agentId, responsibleUserId: bound.onBehalfOfUserId!,
entryFile: current.entryFile, baseHash: current.baseHash, localRoot: current.localRoot, executionRoot: current.executionRoot, location: current.location, state: "prepared", receipt }).returning();
const [successor] = await insertCopy({ runId: input.runId, companyId: input.companyId, agentId: input.agentId, responsibleUserId: bound.onBehalfOfUserId!,
entryFile: current.entryFile, baseHash: current.baseHash, localRoot: current.localRoot, executionRoot: current.executionRoot, location: current.location, state: "prepared", receipt });
let handedOff = false;
try { return await store.locked(input.companyId, input.agentId, bound, false, async (_tx, lockedAgent, canonical) => {
// The reservation's earlier check is only a hint. Serialize this last
@@ -167,7 +174,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
nextAttemptAt: null, errorCode: null, errorMessage: null };
if (row) row = await patch(row, preparing);
else {
await db.insert(copies).values({ runId: input.runId, companyId: input.companyId, agentId: input.agentId, responsibleUserId: bound.onBehalfOfUserId!, ...preparing });
await insertCopy({ runId: input.runId, companyId: input.companyId, agentId: input.agentId, responsibleUserId: bound.onBehalfOfUserId!, ...preparing });
row = (await get(input.companyId, input.runId))!;
}
const { snapshot, storageWarning } = await store.locked(input.companyId, input.agentId, bound, false, async (_tx, _agent, canonical) => {
@@ -291,6 +298,8 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
const current = await inspectAgentDirectory(canonical);
const canonicalChanged = agentDirectoryBaselineDigest(current.snapshot) !== agentDirectoryBaselineDigest(baseline(prior));
if (canonicalChanged && !input.allowRetirementHandoff) return null;
await notifyInstructionCollection(tx, { companyId: input.companyId, runId: prior.runId });
await notifyInstructionCollection(tx, { companyId: input.companyId, runId: input.runId });
const [claimed] = await tx.update(copies).set({ receipt: { ...prior.receipt, retainedByRunId: input.runId }, updatedAt: new Date() })
.where(and(eq(copies.companyId, input.companyId), eq(copies.runId, prior.runId), eq(copies.agentId, input.agentId), eq(copies.state, "unchanged_turn"),
eq(copies.updatedAt, prior.updatedAt), eq(copies.baseHash, prior.baseHash), eq(copies.localRoot, prior.localRoot),
@@ -1,3 +1,4 @@
import { notifyInstructionCollection, type InstructionCollectionScope } from "./instruction-collection-notifications.js";
import { notifyDirectoryCleanup, type DirectoryCleanupScope } from "./directory-cleanup-notifications.js";
import { notifyInstructionCommit, type InstructionCommitScope } from "./instruction-commit-notifications.js";
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
@@ -9,7 +10,7 @@ import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, gt, inArray, lte, or, isNull, sql } from "drizzle-orm";
import { and, asc, desc, eq, gt, inArray, lte, or, isNull, sql, type SQL } from "drizzle-orm";
import { agents, heartbeatRuns, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db";
import {
prepareAdapterExecutionTargetRuntime,
@@ -78,21 +79,23 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
const [row] = await db.select().from(copies).where(scope(companyId, runId));
return row ?? null;
}
async function tryPatch(row: Copy, values: Partial<typeof copies.$inferInsert>, requireDue = false) {
async function tryPatch(row: Copy, values: Partial<typeof copies.$inferInsert>, requireDue = false, condition?: SQL) {
const signalsCollection = collectionCandidate(row) || collectionCandidate({ ...row, ...values });
const signalsCleanup = directoryCleanupEntry(row) !== null || directoryCleanupEntry({ ...row, ...values }) !== null;
const signalsCommit = row.state === "pending_commit" || values.state === "pending_commit";
const write = async (tx: Db) => {
if (signalsCommit)
await notifyInstructionCommit(tx, { companyId: row.companyId, runId: row.runId });
if (signalsCleanup) await notifyDirectoryCleanup(tx, { companyId: row.companyId, runId: row.runId });
if (signalsCollection) await notifyInstructionCollection(tx, { companyId: row.companyId, runId: row.runId });
const [updated] = await tx.update(copies).set({ ...values, updatedAt: new Date() }).where(and(
scope(row.companyId, row.runId), eq(copies.state, row.state), eq(copies.attempts, row.attempts), eq(copies.baseHash, row.baseHash),
sql`${copies.receipt}->>'retainedByRunId' IS NOT DISTINCT FROM ${typeof row.receipt?.retainedByRunId === "string" ? row.receipt.retainedByRunId : null}`,
requireDue ? or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())) : undefined,
requireDue ? or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())) : undefined, condition,
)).returning();
return updated;
};
return signalsCommit || signalsCleanup ? db.transaction(tx => write(tx as unknown as Db)) : write(db);
return signalsCommit || signalsCleanup || signalsCollection ? db.transaction(tx => write(tx as unknown as Db)) : write(db);
}
async function patch(row: Copy, values: Partial<typeof copies.$inferInsert>) {
const updated = await tryPatch(row, values);
@@ -182,12 +185,15 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
? path.posix.join(target.remoteCwd, ".paperclip-runtime", `instruction-edits-${input.runId}`, "instructions")
: localRoot;
if (!existing) {
await db.insert(copies).values({
await db.transaction(async tx => {
await notifyInstructionCollection(tx, { companyId: input.companyId, runId: input.runId });
await tx.insert(copies).values({
runId: input.runId, companyId: input.companyId, agentId: input.agentId,
responsibleUserId: bound.onBehalfOfUserId!, entryFile,
baseRevisionId: baseline?.revision.id ?? null, baseHash: hash(content),
localRoot, executionRoot, location, state: "preparing",
});
});
}
const row = (await get(input.companyId, input.runId))!;
await assertInstructionPathSafe(localRoot, entryFile);
@@ -343,53 +349,114 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
/** Recover only the registered copy after independently recorded process-stop
* proof. Terminal run status is never a substitute for that proof. Remote
* receipts mean the environment is stopped; do not restart it just to read. */
async function recoverStopped() {
const pending = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies)
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
.where(and(or(and(or(inArray(copies.state, ["prepared", "pending_collection", "unchanged_turn", "warm_saved"]),
and(eq(copies.state, "preparing"), sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)),
lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1)),
and(eq(copies.state, "unavailable"), isNull(copies.processStoppedAt),
sql`${copies.location} like 'remote:%'`,
sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)),
sql`NOT (coalesce(${copies.receipt}, '{}'::jsonb) ? 'retainedByRunId')`,
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]),
or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())))).orderBy(asc(copies.updatedAt)).limit(20);
for (const { copy: row, runtimeMode } of pending) {
if (row.state === "unavailable" && isAgentDirectoryCopy(row)) {
await recoverDirectoryCleanup(row, () => directories.recoverUnavailable(row));
} else if (row.state === "preparing" && isAgentDirectoryCopy(row)) {
// A provider cannot launch until preparation records "prepared". With
// the owning run terminal, this is an interrupted staging copy only.
await directories.release(await patch(row, { state: "unavailable", processStoppedAt: new Date(),
errorCode: "AGENT_FILES_PREPARE_FAILED", errorMessage: "Agent-file preparation was interrupted. The temporary copy was discarded.", nextAttemptAt: null }));
} else if (row.location === "local" && (row.processStoppedAt || runtimeMode === "native" &&
await hasNativeLocalProcessStop(db, row.companyId, row.runId))) {
const result = await collectStopped({ companyId: row.companyId, runId: row.runId });
if (result && isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result);
} else if (row.location !== "local" && await remoteExecutionHasStopped(db, row.companyId, row.runId)) {
if (row.receipt?.warm === true) {
const unavailable = row.receipt?.warm === true
? await patch(row, { state: "unavailable", errorCode: "AGENT_FILES_FINAL_COLLECTION_UNAVAILABLE",
errorMessage: "The last completed checkpoint remains saved. Files changed afterwards could not be collected after remote termination.", nextAttemptAt: null })
: await reportUnavailable(row.companyId, row.runId);
if (unavailable && isAgentDirectoryCopy(unavailable)) {
await directories.release(await patch(unavailable, { processStoppedAt: unavailable.processStoppedAt ?? new Date() }));
}
} else {
if (isAgentDirectoryCopy(row)) await directories.recoverStoppedRemote(row);
else await reportUnavailable(row.companyId, row.runId);
}
} else if (row.state === "prepared") {
await patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED",
errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: new Date(Date.now() + 30_000) });
} else if (["warm_saved", "pending_collection", "unchanged_turn"].includes(row.state)) {
// Live retained sessions must not occupy every batch and starve stopped
// copies from other agents. This does not permit reads without stop proof.
await patch(row, { nextAttemptAt: new Date(Date.now() + 30_000) });
function collectionCandidate(row: Copy) {
return !row.receipt?.retainedByRunId && (
row.attempts < MAX_COLLECTION_ATTEMPTS && (["prepared", "pending_collection", "unchanged_turn", "warm_saved"].includes(row.state)
|| row.state === "preparing" && isAgentDirectoryCopy(row))
|| row.state === "unavailable" && !row.processStoppedAt && row.location.startsWith("remote:") && isAgentDirectoryCopy(row));
}
function collectionPredicate() {
return and(sql`NOT (coalesce(${copies.receipt}, '{}'::jsonb) ? 'retainedByRunId') AND (
(${copies.attempts} < 3 AND (${copies.state} IN ('prepared', 'pending_collection', 'unchanged_turn', 'warm_saved')
OR (${copies.state} = 'preparing' AND ${copies.receipt}->>'schema' = 'paperclip.agent-files.v1')))
OR (${copies.state} = 'unavailable' AND ${copies.processStoppedAt} IS NULL
AND ${copies.location} LIKE 'remote:%' AND ${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'))`,
inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]));
}
async function collectRecovered(row: Copy, runtimeMode: string | null) {
if (row.state === "unavailable" && isAgentDirectoryCopy(row)) {
await recoverDirectoryCleanup(row, () => directories.recoverUnavailable(row));
} else if (row.state === "preparing" && isAgentDirectoryCopy(row)) {
// A provider cannot launch until preparation records "prepared". With
// the owning run terminal, this is an interrupted staging copy only.
await directories.release(await patch(row, { state: "unavailable", processStoppedAt: new Date(),
errorCode: "AGENT_FILES_PREPARE_FAILED", errorMessage: "Agent-file preparation was interrupted. The temporary copy was discarded.", nextAttemptAt: null }));
} else if (row.location === "local" && (row.processStoppedAt || runtimeMode === "native" &&
await hasNativeLocalProcessStop(db, row.companyId, row.runId))) {
const result = await collectStopped({ companyId: row.companyId, runId: row.runId });
if (result && isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result);
} else if (row.location !== "local" && await remoteExecutionHasStopped(db, row.companyId, row.runId)) {
if (row.receipt?.warm === true) {
const unavailable = row.receipt?.warm === true
? await patch(row, { state: "unavailable", errorCode: "AGENT_FILES_FINAL_COLLECTION_UNAVAILABLE",
errorMessage: "The last completed checkpoint remains saved. Files changed afterwards could not be collected after remote termination.", nextAttemptAt: null })
: await reportUnavailable(row.companyId, row.runId);
if (unavailable && isAgentDirectoryCopy(unavailable)) {
await directories.release(await patch(unavailable, { processStoppedAt: unavailable.processStoppedAt ?? new Date() }));
}
} else {
if (isAgentDirectoryCopy(row)) await directories.recoverStoppedRemote(row);
else await reportUnavailable(row.companyId, row.runId);
}
} else if (row.state === "prepared") {
await patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED",
errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: new Date(Date.now() + 30_000) });
} else if (["warm_saved", "pending_collection", "unchanged_turn"].includes(row.state)) {
// Live retained sessions must not occupy every batch and starve stopped
// copies from other agents. This does not permit reads without stop proof.
await patch(row, { nextAttemptAt: new Date(Date.now() + 30_000) });
}
return pending.length;
}
type CollectionEntry = { id: string; companyId: string; dueAt: number };
async function loadCollection(scope: InstructionCollectionScope | undefined, admitted: () => boolean) {
const entries: CollectionEntry[] = [];
let cursor: string | undefined;
while (admitted()) {
const rows = await db.select({ id: copies.runId, companyId: copies.companyId, nextAttemptAt: copies.nextAttemptAt })
.from(copies).innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
.where(and(collectionPredicate(), scope ? eq(copies.companyId, scope.companyId) : undefined,
scope?.runId ? eq(copies.runId, scope.runId) : undefined, cursor ? gt(copies.runId, cursor) : undefined))
.orderBy(asc(copies.runId)).limit(100);
for (const row of rows) entries.push({ id: row.id, companyId: row.companyId, dueAt: row.nextAttemptAt?.getTime() ?? 0 });
if (rows.length < 100) return entries;
cursor = rows[rows.length - 1].id;
}
throw new Error("Instruction collection reconstruction stopped");
}
async function readCollection(entry: CollectionEntry) {
const [current] = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies)
.innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
.where(and(collectionPredicate(), scope(entry.companyId, entry.id)));
return current;
}
async function recoverCollection(entry: CollectionEntry, admitted: () => boolean) {
const current = await readCollection(entry);
if (!current) return null;
const dueAt = current.copy.nextAttemptAt?.getTime() ?? 0;
if (dueAt > Date.now()) return { ...entry, dueAt };
if (!admitted()) throw new Error("Instruction collection recovery stopped");
const claimed = await tryPatch(current.copy, { nextAttemptAt: new Date(Date.now() + 30_000) }, true,
sql`EXISTS (SELECT 1 FROM ${heartbeatRuns} WHERE ${heartbeatRuns.id} = ${copies.runId}
AND ${heartbeatRuns.companyId} = ${copies.companyId}
AND ${heartbeatRuns.status} IN ('succeeded', 'failed', 'cancelled', 'timed_out', 'interrupted'))`);
if (claimed) await collectRecovered(claimed, current.runtimeMode);
const remaining = await readCollection(entry);
if (!remaining) return null;
const next = { ...entry, dueAt: remaining.copy.nextAttemptAt?.getTime() ?? 0 };
if (next.dueAt <= Date.now()) throw new Error("Instruction collection remains unresolved without a future retry");
return next;
}
async function recoverStopped() {
const rows = await db.select({ id: copies.runId, companyId: copies.companyId, nextAttemptAt: copies.nextAttemptAt })
.from(copies).innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
.where(and(collectionPredicate(), or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date()))))
.orderBy(asc(copies.updatedAt)).limit(20);
for (const row of rows) {
try { await recoverCollection({ id: row.id, companyId: row.companyId, dueAt: row.nextAttemptAt?.getTime() ?? 0 }, () => true); }
catch (error) { logger.warn({ err: error, runId: row.id }, "Instruction collection recovery deferred"); }
}
return rows.length;
}
function registerStoppedCollectionWork(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
canRun: () => boolean, canRecover: () => Promise<boolean>) {
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.instructionCollection, batchSize: 20,
decodeScope: (hint: string): InstructionCollectionScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
matches: (entry: CollectionEntry, scope?: InstructionCollectionScope) => !scope ||
entry.companyId === scope.companyId && (!scope.runId || entry.id === scope.runId),
load: async (scope, signal) => await canRecover() ? loadCollection(scope, () => canRun() && !signal?.aborted) : [],
check: async (entry, signal) => await canRecover() ? recoverCollection(entry, () => canRun() && !signal?.aborted) : null,
}, canRun);
}
type DirectoryCleanupEntry = { id: string; companyId: string; dueAt: number };
@@ -543,5 +610,5 @@ export function agentInstructionWorkingCopyService(db: Db, options: { environmen
}
return row;
}
return { registerDirectoryCleanupWork, registerCapturedCommitWork, prepare, checkpointWarm, canReuseWarm: directories.canReuse, adopt, reportRetirementUnconfirmed, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release };
return { registerStoppedCollectionWork, registerDirectoryCleanupWork, registerCapturedCommitWork, prepare, checkpointWarm, canReuseWarm: directories.canReuse, adopt, reportRetirementUnconfirmed, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release };
}
+2
View File
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
import { notifyInstructionCommit } from "./instruction-commit-notifications.js";
import { notifyScheduledRetryCancellation } from "./scheduled-retry-cancellation-notifications.js";
@@ -100,6 +101,7 @@ export async function deleteCompany(db: Db, id: string) {
await notifyScheduledRetryCancellation(tx, { companyId: id });
await notifyInstructionCommit(tx, { companyId: id });
await notifyDirectoryCleanup(tx, { companyId: id });
await notifyInstructionCollection(tx, { companyId: id });
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
@@ -2,6 +2,7 @@ import { signalDatabaseWork, subscribeDatabaseWork } from "@paperclipai/db";
/** Topics for existing durable queues; register intent before their writes. */
export const DELIVERY_QUEUES = {
instructionCollection: "instruction-collection",
directoryCleanup: "directory-cleanup",
instructionCommit: "instruction-commit",
scheduledRetryCancellation: "scheduled-retry-cancellation",
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "../__tests__/helpers/external-test-database.js";
import { enableRunFailureReporting } from "../__tests__/helpers/run-failure-reporting.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
@@ -19,7 +20,8 @@ vi.mock("../sentry.js", async () => {
import { reconcileAbandonedExecutionControl } from "./execution-control-reconciliation.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_EXECUTION_CONTROL_TEST_DATABASE_URL");
const embeddedPostgresSupport = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine failed transition", () => {
@@ -27,11 +29,12 @@ describeEmbeddedPostgres("reconcileAbandonedExecutionControl reports a genuine f
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("execution-control-reconciliation-");
db = createDb(tempDb.connectionString);
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("execution-control-reconciliation-");
db = createDb(external ?? tempDb!.connectionString);
}, 30_000);
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -159,10 +162,10 @@ describeEmbeddedPostgres("native review execution reconciliation admission", ()
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("execution-control-review-");
db = createDb(tempDb.connectionString);
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("execution-control-review-");
db = createDb(external ?? tempDb!.connectionString);
}, 30_000);
afterAll(async () => { await tempDb?.cleanup(); });
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await tempDb?.cleanup(); });
it.each([true, false])("only releases the exact admitted review (admitted=%s)", async (admitted) => {
const companyId = randomUUID();
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
import { randomUUID } from "node:crypto";
import { logger } from "../middleware/logger.js";
@@ -182,6 +183,7 @@ export async function reconcileAbandonedExecutionControl(
updatedAt: now,
})
.where(eq(nativeRunFinalizations.runId, run.id));
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
await notifyExecutionRunWork(tx);
const [updatedRun] = await tx
.update(heartbeatRuns)
+8
View File
@@ -4062,6 +4062,14 @@ export function heartbeatService(
triggerIssueMonitor,
reportRunActivity: clearDetachedRunWarning,
registerStoppedInstructionCollectionWork: (
coordinator: Parameters<typeof instructionCopies.registerStoppedCollectionWork>[0], canRun: () => boolean,
) => instructionCopies.registerStoppedCollectionWork(coordinator,
() => canRun() && readTaskDrain(new Date()) === null,
async () => {
const state = await getSchedulingSuppression(true);
return !state.suppressed || state.reason === "task_drain";
}),
registerInstructionDirectoryCleanupWork: (
coordinator: Parameters<typeof instructionCopies.registerDirectoryCleanupWork>[0], canRun: () => boolean,
) => instructionCopies.registerDirectoryCleanupWork(coordinator,
+7
View File
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyScheduledRetryCancellation } from "../scheduled-retry-cancellation-notifications.js";
import { notifyChatResponseAvailability } from "../chat-response-availability.js";
import { notifyChatInboundWakeNotices } from "../chat-inbound-wake-notice-notifications.js";
@@ -848,6 +849,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
? "Automatic continuation stopped by the committed chat conversation close. Send a new request in chat or on the Board to start fresh work."
: "Automatic continuation source could not be verified before provider admission. Review the task and send a fresh request; this attempt will not automatically retry.";
await notifyChatRunMilestones(tx, { companyId: current.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: current.companyId, runId: current.id });
[terminal] = await tx
.update(heartbeatRuns)
.set({
@@ -969,6 +971,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
.for("update");
if (!currentRun) return null;
await notifyChatRunMilestones(tx, { companyId: run.companyId });
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
const [row] = await tx.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date(),
errorCode: "case_handoff_superseded", error: "This case handoff was cancelled or superseded.", updatedAt: new Date() })
.where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, run.companyId), eq(heartbeatRuns.status, "queued"))).returning();
@@ -1329,6 +1332,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
const error =
"Automatic continuation stopped by the committed chat conversation close. Send a fresh request in chat or on the Board.";
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
const [cancelled] = await tx
.update(heartbeatRuns)
.set({
@@ -1450,6 +1454,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
if (liveIds.length === 0) {
const reason = "Queued messages were discarded before dispatch";
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId });
await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
const [cancelled] = await tx
.update(heartbeatRuns)
.set({
@@ -3441,6 +3446,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
const reason = issueCancelled
? "Cancelled because the issue was cancelled before the scheduled retry became due"
: "Cancelled because the issue was reassigned before the scheduled retry became due";
await notifyInstructionCollection(tx, { companyId: scheduledRun.companyId, runId: scheduledRun.id });
await notifyScheduledRetryCancellation(tx, { companyId: scheduledRun.companyId, runId: scheduledRun.id });
await notifyChatRunMilestones(tx, { companyId: scheduledRun.companyId, issueId: issue.id });
const cancelled = await tx
@@ -3579,6 +3585,7 @@ export function createHeartbeatQueue(db: Db, dependencies: HeartbeatQueueDepende
issue.assigneeAgentId &&
activeExecutionRun.agentId !== issue.assigneeAgentId
) {
await notifyInstructionCollection(tx, { companyId: activeExecutionRun.companyId, runId: activeExecutionRun.id });
await notifyChatRunMilestones(tx, { companyId: activeExecutionRun.companyId, issueId: issue.id });
const cancelled = await tx
.update(heartbeatRuns)
+2 -2
View File
@@ -1915,14 +1915,14 @@ export function createHeartbeatRecovery(db: Db, dependencies: HeartbeatRecoveryD
let settledProcessesDiscovered = false;
async function reapOrphanedRuns(opts?: { staleThresholdMs?: number; skipScheduledRetryCancellation?: boolean; skipCapturedInstructionCommit?: boolean; skipInstructionDirectoryCleanup?: boolean }) {
async function reapOrphanedRuns(opts?: { staleThresholdMs?: number; skipScheduledRetryCancellation?: boolean; skipCapturedInstructionCommit?: boolean; skipInstructionDirectoryCleanup?: boolean; skipStoppedInstructionCollection?: boolean }) {
await preserveCancelledNativeWorkspaces(db);
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
const now = new Date();
if (!opts?.skipScheduledRetryCancellation) await scheduledRetryCancellation.sweep();
// Recovery never launches a provider or infers stopped ownership from
// terminal status. Uncaptured local copies require durable stop evidence.
await instructionCopies.recoverStopped().catch(error => {
if (!opts?.skipStoppedInstructionCollection) await instructionCopies.recoverStopped().catch(error => {
logger.warn({ err: error }, "failed to recover stopped instruction copies");
});
await instructionCopies.recoverCaptured({ skipCommit: opts?.skipCapturedInstructionCommit, skipDirectoryCleanup: opts?.skipInstructionDirectoryCleanup }).catch(error => {
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyScheduledRetryCancellation } from "../scheduled-retry-cancellation-notifications.js";
import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
@@ -1022,6 +1023,7 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
const tx = transaction as unknown as Db;
if (previous && (previous.status === "scheduled_retry" || status === "scheduled_retry"))
await notifyScheduledRetryCancellation(tx, { companyId: previous.companyId, runId: previous.id });
if (previous) await notifyInstructionCollection(tx, { companyId: previous.companyId, runId: previous.id });
if (isHeartbeatRunTerminalStatus(status)) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, companyId);
await notifyExecutionRunWork(tx);
if (companyId) await notifyChatRunMilestones(tx, { companyId });
+2
View File
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyDirectoryCleanup } from "./directory-cleanup-notifications.js";
import { notifyInstructionCommit } from "./instruction-commit-notifications.js";
import { notifyScheduledRetryCancellation } from "./scheduled-retry-cancellation-notifications.js";
@@ -622,6 +623,7 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService
if (patch.enableWorktreeRunExecution !== undefined) {
await notifyInstructionCommit(tx);
await notifyDirectoryCleanup(tx);
await notifyInstructionCollection(tx);
}
if (patch.enableChatConnectors !== undefined) await notifyVoiceChange(tx);
await notifyToolConnectionHealth(tx, "*");
@@ -0,0 +1,7 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export type InstructionCollectionScope = { companyId: string; runId?: string };
export async function notifyInstructionCollection(transaction: object, scope?: InstructionCollectionScope) {
await notifyDeliveryWork(transaction, DELIVERY_QUEUES.instructionCollection, scope ? JSON.stringify(scope) : "*");
}
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "./instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "./execution-control-notifications.js";
import { hasRequiredWorkspaceRecovery, preserveWorkspaceRestoreRecoveryMetadata, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "./workspace-restore-recovery-state.js";
import { hasUnrestoredRemoteWorkspace, preserveLegacyWorkspaceRestoreSources, type LegacyWorkspaceRestoreSource } from "./legacy-workspace-restore-recovery.js";
@@ -109,6 +110,7 @@ export async function terminalizeLegacyExecution(input: {
resultJson: preserveWorkspaceRestoreRecoveryMetadata(current.resultJson,
input.recordRestoreFailureOnly ? { ...current.resultJson, ...patch.resultJson } : patch.resultJson),
};
await notifyInstructionCollection(tx, { companyId: current.companyId, runId: current.id });
await notifyExecutionRunWork(tx);
let [updated] = await tx
.update(heartbeatRuns)
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import type { WorkspaceWorkers } from "../plugin-workspaces.js";
import { recoverLegacyUnsafeWorkspaceExports } from "./native-workspace-export-recovery.js";
import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js";
@@ -509,6 +510,7 @@ export async function claimNativeSessionResumptions(input: {
nextAttemptAt: null,
updatedAt: now,
}).where(eq(nativeRunFinalizations.runId, row.run.id));
await notifyInstructionCollection(tx, { companyId: row.run.companyId, runId: row.run.id });
const [updatedRun] = await tx.update(heartbeatRuns).set({
status: "failed",
finishedAt: now,
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
import { readNativeWorkspaceSyncReference } from "./native-workspace-sync.js";
import { activeNativeExecutionRunIds, NATIVE_PREPARATION_FENCING_VERSION } from "./native-active-runs.js";
@@ -889,6 +890,7 @@ export async function claimNativeRestartRecoveries(input: {
eq(nativeRunFinalizations.phase, row.coordinator.phase),
),
);
await notifyInstructionCollection(tx, { companyId: row.run.companyId, runId: row.run.id });
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -1,3 +1,4 @@
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { enableRunFailureReporting } from "../../__tests__/helpers/run-failure-reporting.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
@@ -64,7 +65,8 @@ import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION, type NativeStatusDecision } from "./status-arbiter.js";
import { DELIVERY_QUEUES, subscribeDeliveryWork } from "../delivery-work-notifications.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_FINALIZER_TEST_DATABASE_URL");
const embeddedPostgresSupport = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
@@ -82,8 +84,8 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
let agentId: string;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-native-finalizer-telemetry-");
db = createDb(tempDb.connectionString);
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-native-finalizer-telemetry-");
db = createDb(external ?? tempDb!.connectionString);
companyId = randomUUID();
agentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Telemetry", issuePrefix: "TLM" });
@@ -97,6 +99,7 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
}, 30_000);
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
@@ -948,6 +951,26 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
expect(agentTaskRunCalls(callsBefore)).toHaveLength(1);
});
it.each([true, false])("signals instruction collection only when native failure projects terminal status (%s)", async projectRunStatus => {
const fixture = await seedNativeRun();
await driveToCompleteResult(fixture);
const settled = vi.fn();
const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.instructionCollection, settled);
try {
await recordNativeFinalizationFailure({ db, runId: fixture.runId,
error: new Error("native_fixture_permanent_failure"), permanent: true, projectRunStatus });
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, fixture.runId)))[0].phase).toBe("terminal_failure");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
if (projectRunStatus) {
expect(run.status).not.toBe("running");
expect(settled).toHaveBeenCalled();
} else {
expect(run.status).toBe("running");
expect(settled).not.toHaveBeenCalled();
}
} finally { unsubscribe(); }
});
it("signals durable accounting when native commitment or permanent failure makes a receipt actionable", async () => {
const settled = vi.fn();
const unsubscribe = subscribeDeliveryWork(db, DELIVERY_QUEUES.accounting, settled);
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { pendingNativeGovernance } from "./native-governance.js";
import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js";
import { notifyExecutionRunWork } from "../execution-control-notifications.js";
@@ -408,6 +409,7 @@ async function recordRetryableFailure(input: {
if (projectsTerminalStatus && input.failureScope === "workspace") {
await preserveNativeWorkspaceExportLease(tx as unknown as Db, input.run, input.coordinator.resultId);
}
if (projectsTerminalStatus) await notifyInstructionCollection(tx, { companyId: input.run.companyId, runId: input.run.id });
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -665,6 +667,7 @@ async function projectCommittedRun(input: {
const now = new Date();
const [updatedRun] = await input.db.transaction(async transaction => {
const tx = transaction as unknown as Db;
await notifyInstructionCollection(tx, { companyId: input.run.companyId, runId: input.run.id });
await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, input.run.companyId);
await notifyExecutionRunWork(tx);
await notifyChatRunMilestones(tx, { companyId: input.run.companyId, issueId: input.coordinator.issueId });
@@ -1601,6 +1604,7 @@ export async function finalizeNativeRun(input: {
.returning();
const [updatedRun] = await input.db.transaction(async transaction => {
const tx = transaction as unknown as Db;
if (input.projectRunStatus) await notifyInstructionCollection(tx, { companyId: run.companyId, runId: run.id });
if (input.projectRunStatus) await notifyDeliveryWork(tx, DELIVERY_QUEUES.accounting, run.companyId);
await notifyExecutionRunWork(tx);
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId: coordinator.issueId });
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { writeEnvironmentCleanup } from "../environment-cleanup-notifications.js";
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
import { writeExecutionControl } from "../execution-control-notifications.js";
@@ -9594,6 +9595,7 @@ async function executePaperclipNativeSessionWithinScope(
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, input.execution.binding.runId))
.for("update");
if (!ownershipUnverified) await notifyInstructionCollection(tx, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId });
let [updatedRun] = await tx
.update(heartbeatRuns)
.set({
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js";
import { scheduledIssueMonitorWait } from "../issue-monitors.js";
import { hasPublishedGitHubReview } from "../chat-channels/github/review-wait.js";
@@ -1063,6 +1064,7 @@ async function materializeDecisionEffect(input: {
),
);
}
await notifyInstructionCollection(input.tx, { companyId: input.companyId, runId: input.runId });
await notifyDeliveryWork(input.tx, DELIVERY_QUEUES.accounting, input.companyId);
const [run] = await input.tx
.update(heartbeatRuns)
+2
View File
@@ -1,3 +1,4 @@
import { notifyInstructionCollection } from "../instruction-collection-notifications.js";
import { notifyChatRunMilestones } from "../chat-run-milestone-notifications.js";
import { boundRunIssueId } from "../run-task-binding.js";
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
@@ -6212,6 +6213,7 @@ export function recoveryService(
// to recheck finalization records committed after this lock was taken.
const [candidate] = await tx.select().from(heartbeatRuns).where(eligible).for("update");
if (!candidate) return null;
await notifyInstructionCollection(tx, { companyId: candidate.companyId, runId: candidate.id });
const boundIssueId = boundRunIssueId(candidate);
if (boundIssueId) {
await notifyChatRunMilestones(tx, { companyId: run.companyId, issueId: boundIssueId });