[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:
Dotta 2026-04-21 09:11:12 -05:00 committed by GitHub
parent 8d0c3d2fe6
commit 1954eb3048
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1171 additions and 2 deletions

View file

@ -0,0 +1,280 @@
import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
agentWakeupRequests,
companies,
createDb,
heartbeatRuns,
issueComments,
issueRelations,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const mockAdapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
errorMessage: null,
summary: "Acknowledged liveness escalation.",
provider: "test",
model: "test-model",
})),
);
vi.mock("../telemetry.ts", () => ({
getTelemetryClient: () => ({ track: vi.fn() }),
}));
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
);
return {
...actual,
trackAgentFirstHeartbeat: vi.fn(),
};
});
vi.mock("../adapters/index.ts", async () => {
const actual = await vi.importActual<typeof import("../adapters/index.ts")>("../adapters/index.ts");
return {
...actual,
getServerAdapter: vi.fn(() => ({
supportsLocalAgentJwt: false,
execute: mockAdapterExecute,
})),
};
});
import { heartbeatService } from "../services/heartbeat.ts";
import { runningProcesses } from "../adapters/index.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue liveness escalation tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-issue-liveness-");
db = createDb(tempDb.connectionString);
}, 30_000);
afterEach(async () => {
vi.clearAllMocks();
runningProcesses.clear();
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running");
if (!hasActiveRun) {
idlePolls += 1;
if (idlePolls >= 3) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
await new Promise((resolve) => setTimeout(resolve, 50));
await db.execute(sql.raw(`TRUNCATE TABLE "companies" CASCADE`));
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedBlockedChain() {
const companyId = randomUUID();
const managerId = randomUUID();
const coderId = randomUUID();
const blockedIssueId = randomUUID();
const blockerIssueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: managerId,
companyId,
name: "CTO",
role: "cto",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: coderId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(issues).values([
{
id: blockedIssueId,
companyId,
title: "Blocked parent",
status: "blocked",
priority: "medium",
assigneeAgentId: coderId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
},
{
id: blockerIssueId,
companyId,
title: "Missing unblock owner",
status: "todo",
priority: "medium",
issueNumber: 2,
identifier: `${issuePrefix}-2`,
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
return { companyId, managerId, blockedIssueId, blockerIssueId };
}
it("creates one manager escalation, preserves blockers, and wakes the assignee", async () => {
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(first.escalationsCreated).toBe(1);
expect(second.escalationsCreated).toBe(0);
expect(second.existingEscalations).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "harness_liveness_escalation"),
),
);
expect(escalations).toHaveLength(1);
expect(escalations[0]).toMatchObject({
parentId: blockedIssueId,
assigneeAgentId: managerId,
status: expect.stringMatching(/^(todo|in_progress|done)$/),
});
const blockers = await db
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
expect(blockers.map((row) => row.blockerIssueId).sort()).toEqual(
[blockerIssueId, escalations[0]!.id].sort(),
);
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockedIssueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("harness-level liveness incident");
expect(comments[0]?.body).toContain(escalations[0]?.identifier ?? escalations[0]!.id);
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, managerId));
expect(wakes.some((wake) => wake.reason === "issue_assigned")).toBe(true);
const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
expect(events.some((event) => event.action === "issue.harness_liveness_escalation_created")).toBe(true);
expect(events.some((event) => event.action === "issue.blockers.updated")).toBe(true);
});
it("creates a fresh escalation when the previous matching escalation is terminal", async () => {
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const incidentKey = [
"harness_liveness",
companyId,
blockedIssueId,
"blocked_by_unassigned_issue",
blockerIssueId,
].join(":");
const closedEscalationId = randomUUID();
await db.insert(issues).values({
id: closedEscalationId,
companyId,
title: "Closed escalation",
status: "done",
priority: "high",
parentId: blockedIssueId,
assigneeAgentId: managerId,
issueNumber: 3,
identifier: "CLOSED-3",
originKind: "harness_liveness_escalation",
originId: incidentKey,
});
const result = await heartbeat.reconcileIssueGraphLiveness();
expect(result.escalationsCreated).toBe(1);
expect(result.existingEscalations).toBe(0);
const openEscalations = await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "harness_liveness_escalation"),
eq(issues.originId, incidentKey),
),
);
expect(openEscalations).toHaveLength(2);
const freshEscalation = openEscalations.find((issue) => issue.status !== "done");
expect(freshEscalation).toMatchObject({
parentId: blockedIssueId,
assigneeAgentId: managerId,
status: expect.stringMatching(/^(todo|in_progress|done)$/),
});
const blockers = await db
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false);
expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true);
});
});

View 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`,
});
});
});