mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
[codex] Detect issue graph liveness deadlocks (#4209)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The heartbeat harness is responsible for waking agents, reconciling issue state, and keeping execution moving. > - Some dependency graphs can become live-locks when a blocked issue depends on an unassigned, cancelled, or otherwise uninvokable issue. > - Review and approval stages can also stall when the recorded participant can no longer be resolved. > - This pull request adds issue graph liveness classification plus heartbeat reconciliation that creates durable escalation work for those cases. > - The benefit is that harness-level deadlocks become visible, assigned, logged, and recoverable instead of silently leaving task sequences blocked. ## What Changed - Added an issue graph liveness classifier for blocked dependency and invalid review participant states. - Added heartbeat reconciliation that creates one stable escalation issue per liveness incident, links it as a blocker, comments on the affected issue, wakes the recommended owner, and logs activity. - Wired startup and periodic server reconciliation for issue graph liveness incidents. - Added focused tests for classifier behavior, heartbeat escalation creation/deduplication, and queued dependency wake promotion. - Fixed queued issue wakes so a coalesced wake re-runs queue selection, allowing dependency-unblocked work to start immediately. ## Verification - `pnpm exec vitest run server/src/__tests__/heartbeat-dependency-scheduling.test.ts server/src/__tests__/issue-liveness.test.ts server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts` - Passed locally: `server/src/__tests__/issue-liveness.test.ts` (5 tests) - Skipped locally: embedded Postgres suites because optional package `@embedded-postgres/darwin-x64` is not installed on this host - `pnpm --filter @paperclipai/server typecheck` - `git diff --check` - Greptile review loop: ran 3 times as requested; the final Greptile-reviewed head `0a864eab` had 0 comments and all Greptile threads were resolved. Later commits are CI/test-stability fixes after the requested max Greptile pass count. - GitHub PR checks on head `87493ed4`: `policy`, `verify`, `e2e`, and `security/snyk (cryppadotta)` all passed. ## Risks - Moderate operational risk: the reconciler creates escalation issues automatically, so incorrect classification could create noise. Stable incident keys and deduplication limit repeated escalation. - Low schema risk: this uses existing issue, relation, comment, wake, and activity log tables with no migration. - No UI screenshots included because this change is server-side harness behavior only. > 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. Exact runtime model ID and context window were not exposed in this session. Used tool execution for git, tests, typecheck, Greptile review handling, and GitHub CLI operations. ## 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 - [x] 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
8d0c3d2fe6
commit
1954eb3048
6 changed files with 1171 additions and 2 deletions
185
server/src/__tests__/issue-liveness.test.ts
Normal file
185
server/src/__tests__/issue-liveness.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { classifyIssueGraphLiveness } from "../services/issue-liveness.ts";
|
||||
|
||||
const companyId = "company-1";
|
||||
const managerId = "manager-1";
|
||||
const coderId = "coder-1";
|
||||
const blockerId = "blocker-1";
|
||||
const blockedId = "blocked-1";
|
||||
|
||||
function issue(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: blockedId,
|
||||
companyId,
|
||||
identifier: "PAP-1703",
|
||||
title: "Parent work",
|
||||
status: "blocked",
|
||||
assigneeAgentId: coderId,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionState: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function agent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: coderId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = agent({
|
||||
id: managerId,
|
||||
name: "CTO",
|
||||
role: "cto",
|
||||
reportsTo: null,
|
||||
});
|
||||
|
||||
const blocks = [{ companyId, blockerIssueId: blockerId, blockedIssueId: blockedId }];
|
||||
|
||||
describe("issue graph liveness classifier", () => {
|
||||
it("detects a PAP-1703-style blocked chain with an unassigned blocker and stable incident key", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Missing unblock work",
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [agent(), manager],
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
issueId: blockedId,
|
||||
identifier: "PAP-1703",
|
||||
state: "blocked_by_unassigned_issue",
|
||||
recommendedOwnerAgentId: managerId,
|
||||
dependencyPath: [
|
||||
expect.objectContaining({ issueId: blockedId }),
|
||||
expect.objectContaining({ issueId: blockerId }),
|
||||
],
|
||||
incidentKey: `harness_liveness:${companyId}:${blockedId}:blocked_by_unassigned_issue:${blockerId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not flag a live blocked chain with an active assignee and wake path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Live unblock work",
|
||||
status: "todo",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [
|
||||
agent(),
|
||||
manager,
|
||||
agent({ id: "blocker-agent", name: "Blocker Agent", reportsTo: managerId }),
|
||||
],
|
||||
queuedWakeRequests: [{ companyId, issueId: blockerId, agentId: "blocker-agent", status: "queued" }],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag an unassigned blocker that already has an active execution path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Unassigned but already running",
|
||||
status: "todo",
|
||||
assigneeAgentId: null,
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [agent(), manager],
|
||||
activeRuns: [{ companyId, issueId: blockerId, agentId: coderId, status: "running" }],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects cancelled blockers and uninvokable blocker assignees deterministically", () => {
|
||||
const cancelled = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Cancelled unblock work",
|
||||
status: "cancelled",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })],
|
||||
});
|
||||
expect(cancelled[0]?.state).toBe("blocked_by_cancelled_issue");
|
||||
|
||||
const paused = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue(),
|
||||
issue({
|
||||
id: blockerId,
|
||||
identifier: "PAP-1704",
|
||||
title: "Paused unblock work",
|
||||
status: "todo",
|
||||
assigneeAgentId: "blocker-agent",
|
||||
}),
|
||||
],
|
||||
relations: blocks,
|
||||
agents: [agent(), manager, agent({ id: "blocker-agent", name: "Paused", status: "paused" })],
|
||||
});
|
||||
expect(paused[0]?.state).toBe("blocked_by_uninvokable_assignee");
|
||||
});
|
||||
|
||||
it("detects invalid in_review execution participant", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
issue({
|
||||
status: "in_review",
|
||||
executionState: {
|
||||
status: "pending",
|
||||
currentStageId: "stage-1",
|
||||
currentStageIndex: 0,
|
||||
currentStageType: "review",
|
||||
currentParticipant: { type: "agent", agentId: "missing-agent" },
|
||||
returnAssignee: { type: "agent", agentId: coderId },
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
relations: [],
|
||||
agents: [agent(), manager],
|
||||
});
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]).toMatchObject({
|
||||
state: "invalid_review_participant",
|
||||
incidentKey: `harness_liveness:${companyId}:${blockedId}:invalid_review_participant:missing-agent`,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue