mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
[codex] Harden heartbeat scheduling and runtime controls (#4223)
## Thinking Path > - Paperclip orchestrates AI agents through issue checkout, heartbeat runs, routines, and auditable control-plane state > - The runtime path has to recover from lost local processes, transient adapter failures, blocked dependencies, and routine coalescing without stranding work > - The existing branch carried several reliability fixes across heartbeat scheduling, issue runtime controls, routine dispatch, and operator-facing run state > - These changes belong together because they share backend contracts, migrations, and runtime status semantics > - This pull request groups the control-plane/runtime slice so it can merge independently from board UI polish and adapter sandbox work > - The benefit is safer heartbeat recovery, clearer runtime controls, and more predictable recurring execution behavior ## What Changed - Adds bounded heartbeat retry scheduling, scheduled retry state, and Codex transient failure recovery handling. - Tightens heartbeat process recovery, blocker wake behavior, issue comment wake handling, routine dispatch coalescing, and activity/dashboard bounds. - Adds runtime-control MCP tools and Paperclip skill docs for issue workspace runtime management. - Adds migrations `0061_lively_thor_girl.sql` and `0062_routine_run_dispatch_fingerprint.sql`. - Surfaces retry state in run ledger/agent UI and keeps related shared types synchronized. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-retry-scheduling.test.ts server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/routines-service.test.ts` - `pnpm exec vitest run src/tools.test.ts` from `packages/mcp-server` ## Risks - Medium risk: this touches heartbeat recovery and routine dispatch, which are central execution paths. - Migration order matters if split branches land out of order: merge this PR before branches that assume the new runtime/routine fields. - Runtime retry behavior should be watched in CI and in local operator smoke tests because it changes how transient failures are resumed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent runtime, shell/git tool use enabled. Exact hosted model build and context window are not exposed in this Paperclip heartbeat environment. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
ab9051b595
commit
09d0678840
61 changed files with 17622 additions and 456 deletions
|
|
@ -132,7 +132,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("keeps blocked descendants queued until their blockers resolve", async () => {
|
||||
it("keeps blocked descendants idle until their blockers resolve", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const blockerId = randomUUID();
|
||||
|
|
@ -200,15 +200,72 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
payload: { issueId: blockedIssueId },
|
||||
contextSnapshot: { issueId: blockedIssueId, wakeReason: "issue_assigned" },
|
||||
});
|
||||
expect(blockedWake).not.toBeNull();
|
||||
expect(blockedWake).toBeNull();
|
||||
|
||||
const blockedWakeRequest = await waitForCondition(async () => {
|
||||
const wakeup = await db
|
||||
.select({
|
||||
status: agentWakeupRequests.status,
|
||||
reason: agentWakeupRequests.reason,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.agentId, agentId),
|
||||
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${blockedIssueId}`,
|
||||
),
|
||||
)
|
||||
.orderBy(agentWakeupRequests.requestedAt)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Boolean(
|
||||
wakeup &&
|
||||
wakeup.status === "skipped" &&
|
||||
wakeup.reason === "issue_dependencies_blocked",
|
||||
);
|
||||
});
|
||||
expect(blockedWakeRequest).toBe(true);
|
||||
|
||||
const blockedRunsBeforeResolution = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${blockedIssueId}`)
|
||||
.then((rows) => rows[0]?.count ?? 0);
|
||||
expect(blockedRunsBeforeResolution).toBe(0);
|
||||
|
||||
const interactionWake = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: blockedIssueId, commentId: randomUUID() },
|
||||
contextSnapshot: {
|
||||
issueId: blockedIssueId,
|
||||
wakeReason: "issue_commented",
|
||||
},
|
||||
});
|
||||
expect(interactionWake).not.toBeNull();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, blockedWake!.id))
|
||||
.where(eq(heartbeatRuns.id, interactionWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "queued";
|
||||
return run?.status === "succeeded";
|
||||
});
|
||||
|
||||
const interactionRun = await db
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, interactionWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(interactionRun?.status).toBe("succeeded");
|
||||
expect(interactionRun?.contextSnapshot).toMatchObject({
|
||||
dependencyBlockedInteraction: true,
|
||||
unresolvedBlockerIssueIds: [blockerId],
|
||||
});
|
||||
|
||||
const readyWake = await heartbeat.wakeup(agentId, {
|
||||
|
|
@ -229,12 +286,12 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
return run?.status === "succeeded";
|
||||
});
|
||||
|
||||
const [blockedRun, readyRun] = await Promise.all([
|
||||
db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, blockedWake!.id)).then((rows) => rows[0] ?? null),
|
||||
db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, readyWake!.id)).then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
const readyRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, readyWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
expect(blockedRun?.status).toBe("queued");
|
||||
expect(readyRun?.status).toBe("succeeded");
|
||||
|
||||
await db
|
||||
|
|
@ -242,7 +299,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
.set({ status: "done", updatedAt: new Date() })
|
||||
.where(eq(issues.id, blockerId));
|
||||
|
||||
await heartbeat.wakeup(agentId, {
|
||||
const promotedWake = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_blockers_resolved",
|
||||
|
|
@ -253,12 +310,13 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
resolvedBlockerIssueId: blockerId,
|
||||
},
|
||||
});
|
||||
expect(promotedWake).not.toBeNull();
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, blockedWake!.id))
|
||||
.where(eq(heartbeatRuns.id, promotedWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return run?.status === "succeeded";
|
||||
});
|
||||
|
|
@ -269,7 +327,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
|||
status: heartbeatRuns.status,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, blockedWake!.id))
|
||||
.where(eq(heartbeatRuns.id, promotedWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const blockedWakeRequestCount = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue