Files
PaperClipAI/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts
zach-hermesandPaperclip 8db826d18a fix(issues): cycle-aware issue_blockers_resolved after terminal reset (#11979)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents resume blocked work through the `issue_blockers_resolved`
wake when every durable blocker is `done`
> - That wake is level-triggered: one ready state produces one wake,
shared by the issue update route, workspace-finalize backstop, and
periodic liveness backstop
> - The ready-state key hashed only the dependent id and blocker set, so
it ignored a later reset from a terminal status back into `blocked`
> - After that reset, completing the same blockers found the previous
cycle's completed wake and suppressed the new continuation
> - This pull request folds the dependent's `blockedTransitionAt` into
the ready-state key, with compatibility for old no-cycle keys
> - The benefit is that a reset blocked issue receives exactly one new
wake without watchdog status repair or a change to blocker edges

## Linked Issues or Issue Description

Refs: https://github.com/paperclipai/paperclip/issues/5985
Refs: https://github.com/paperclipai/paperclip/issues/6555
Related: https://github.com/paperclipai/paperclip/pull/8009
Related: https://github.com/paperclipai/paperclip/pull/11570

This change does not auto-flip `blocked` to `todo`. The wake is the
continuation. It also does not treat cancelled blockers as resolved.

**What happened?**
A blocked assigned issue that was previously `done` or `cancelled`, then
reset to `blocked` on the same blocker set, did not receive
`issue_blockers_resolved` when those blockers later returned to `done`.
A completed wake from the previous cycle reused the same level-triggered
state key and suppressed the new wake. Route-time emit,
workspace-finalize backstop, and periodic liveness backstop all used
that helper.

**Expected behavior**
When every durable blocker is `done`, a currently `blocked` assigned
issue must receive exactly one valid `issue_blockers_resolved`
continuation for the current blocked cycle. A completed wake from an
earlier cycle must not suppress it. Watchdog `blocked` → `todo` repair
must not be required.

**Steps to reproduce**
1. Assign issue B, block it on issue A, mark A `done`, and let B receive
`issue_blockers_resolved`.
2. Mark B `done`.
3. Reset A to `todo` and reset B from `done` to `blocked` on the same A
id. This refreshes `blockedTransitionAt`.
4. Mark A `done` again.
5. Observe that B stays `blocked` with no new `issue_blockers_resolved`
wake.

**Paperclip version or commit**
`master` at `cc42a67e7e9e8eb183097afc8ff4ebfa694fb3e0`

**Deployment mode**
Self-hosted server

## What Changed

- Extend `buildIssueBlockersResolvedWakeStateKey` so the digest includes
the dependent's `blockedTransitionAt` as UTC ISO-8601, or `none`
- Thread `blockedTransitionAt` through `listWakeableBlockedDependents`,
both route emit sites, and both backstop candidate selects
- Keep compatibility: new cycle-aware keys suppress in idempotent
statuses; old no-cycle state keys suppress when in-flight, or when
completed and `requestedAt >= blockedTransitionAt` (or the cycle is
null); legacy per-edge keys stay in-flight-only
- Do not rewrite `blockedByIssueIds`, auto-flip `blocked` → `todo`, or
delete historical wake rows
- Add helper, route, restore, chained dependent, and backstop tests for
the reset cycle

## Verification

```
pnpm --filter @paperclipai/server exec vitest run \
  src/__tests__/issue-dependency-wakeups-routes.test.ts \
  src/__tests__/heartbeat-issue-liveness-escalation.test.ts \
  src/services/issue-dependency-wakeups.ts \
  src/services/issue-dependency-wakeups.test.ts
```

Local result: all named tests passed (helper 9, routes 8, liveness 26).

## Risks

- Deploy overlap: in-flight and same-cycle completed wakes still exist
under the old no-cycle key. The lookup keeps those as suppressors so
this change does not enqueue a duplicate in the current cycle.
- A completed old-key wake from before the current `blockedTransitionAt`
no longer suppresses. That is the intended fix.
- No schema migration. Rollback is revert of this PR.
- This does not change cancelled-blocker semantics or watchdog `blocked`
→ `todo` repair.

> 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

- Provider: xAI
- Model: Grok 4.6
- Tool use and code execution: yes
- Human-authored: no

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-23 08:50:38 -07:00

1612 lines
56 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agents,
agentWakeupRequests,
agentRuntimeState,
budgetPolicies,
companies,
companyMemberships,
companySkills,
costEvents,
createDb,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueRelations,
issueTreeHoldMembers,
issueTreeHolds,
issues,
projects,
projectWorkspaces,
workspaceOperations,
} 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 { attentionService } from "../services/attention.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { issueService } from "../services/issues.ts";
import { runningProcesses } from "../adapters/index.ts";
import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts";
import {
buildIssueBlockersResolvedWakeStateKey,
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
} from "../services/issue-dependency-wakeups.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();
// reconcileIssueGraphLiveness heals dependency wakes by enqueuing an
// on-demand wake, which dispatches a heartbeat run fire-and-forget (see
// startNextQueuedRunForAgent → executeRun in the heartbeat service). That
// background run keeps writing rows (workspace_operations, heartbeat_run_events)
// after the awaited call resolves. Deterministically await those in-flight
// executions before clearing tables — otherwise an escaping heartbeat_run_events
// insert can land between the events delete and the heartbeat_runs delete and
// trip the run_events → runs foreign key.
await heartbeatService(db).drainActiveRunExecutions();
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
await db.delete(costEvents);
await db.delete(workspaceOperations);
await db.delete(issueComments);
await db.delete(issueTreeHoldMembers);
await db.delete(issueTreeHolds);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(budgetPolicies);
await db.delete(agents);
await db.delete(companyMemberships);
await db.delete(companySkills);
await db.delete(companies);
await instanceSettingsService(db).updateExperimental({
enableIssueGraphLivenessAutoRecovery: false,
enableIsolatedWorkspaces: false,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
});
afterAll(async () => {
await tempDb?.cleanup();
}, 30_000);
async function enableAutoRecovery() {
await instanceSettingsService(db).updateExperimental({
enableIssueGraphLivenessAutoRecovery: true,
});
}
async function seedBlockedChain(opts: {
outsideLookback?: boolean;
blockerStatus?: string;
blockerAssigneeAgentId?: "coder" | "manager" | null;
} = {}) {
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: { heartbeat: { wakeOnDemand: false } },
permissions: {},
},
{
id: coderId,
companyId,
name: "Coder",
role: "engineer",
status: "idle",
reportsTo: managerId,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: false } },
permissions: {},
},
]);
const issueTimestamp = opts.outsideLookback === true
? new Date(Date.now() - 25 * 60 * 60 * 1000)
: new Date(Date.now() - 60 * 60 * 1000);
await db.insert(issues).values([
{
id: blockedIssueId,
companyId,
title: "Blocked parent",
status: "blocked",
priority: "medium",
assigneeAgentId: coderId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
createdAt: issueTimestamp,
updatedAt: issueTimestamp,
},
{
id: blockerIssueId,
companyId,
title: "Missing unblock owner",
status: opts.blockerStatus ?? "todo",
priority: "medium",
assigneeAgentId: opts.blockerAssigneeAgentId === "coder"
? coderId
: opts.blockerAssigneeAgentId === "manager"
? managerId
: null,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
createdAt: issueTimestamp,
updatedAt: issueTimestamp,
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
return { companyId, managerId, coderId, blockedIssueId, blockerIssueId };
}
async function seedResolvedDependencyBackstopFixture(opts: {
workspaceState?: "none" | "not_finalized" | "finalized";
assignee?: "agent" | null;
} = {}) {
const workspaceState = opts.workspaceState ?? "none";
const companyId = randomUUID();
const agentId = randomUUID();
const ownerUserId = randomUUID();
const blockedIssueId = randomUUID();
const blockerIssueId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const executionWorkspaceId = randomUUID();
const issuePrefix = `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(companyMemberships).values({
companyId,
principalType: "user",
principalId: ownerUserId,
membershipRole: "owner",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Priya",
role: "engineer",
status: "idle",
adapterType: "test_adapter",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
permissions: {},
});
if (workspaceState !== "none") {
await db.insert(projects).values({
id: projectId,
companyId,
name: "Synthetic dependency project",
status: "in_progress",
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Synthetic workspace",
sourceType: "git_worktree",
});
await db.insert(executionWorkspaces).values({
id: executionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Synthetic execution workspace",
providerType: "git_worktree",
});
}
await db.insert(issues).values([
{
id: blockedIssueId,
companyId,
projectId: workspaceState === "none" ? null : projectId,
title: "Synthetic blocked dependent",
status: "blocked",
priority: "medium",
assigneeAgentId: opts.assignee === null ? null : agentId,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
},
{
id: blockerIssueId,
companyId,
projectId: workspaceState === "none" ? null : projectId,
title: "Synthetic completed blocker",
status: "done",
priority: "medium",
executionWorkspaceId: workspaceState === "none" ? null : executionWorkspaceId,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
if (workspaceState === "not_finalized") {
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerIssueId,
phase: "adapter_execute",
status: "succeeded",
startedAt: new Date(Date.now() - 60_000),
});
} else if (workspaceState === "finalized") {
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerIssueId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date(),
});
}
return { companyId, agentId, blockedIssueId, blockerIssueId, executionWorkspaceId };
}
it("keeps liveness findings advisory when auto recovery is disabled", async () => {
await instanceSettingsService(db).updateExperimental({
enableIssueGraphLivenessAutoRecovery: false,
});
const { companyId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileIssueGraphLiveness();
expect(result.findings).toBe(1);
expect(result.autoRecoveryEnabled).toBe(false);
expect(result.escalationsCreated).toBe(0);
expect(result.skippedAutoRecoveryDisabled).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(0);
});
it("runs exactly one bounded review-path recovery before surfacing a stalled decision", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const issuePrefix = `R${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
await db.insert(companies).values({
id: companyId,
name: "Review Recovery Co",
issuePrefix,
defaultResponsibleUserId: "responsible-user",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Review Agent",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } },
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "PAP-14994 fingerprint",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
responsibleUserId: "responsible-user",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
});
const heartbeat = heartbeatService(db);
const followUpRun = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: {
issueId,
interactionId: "superseded-confirmation",
reviewPathLost: true,
reviewPathConsumedRef: "superseded-confirmation",
},
requestedByActorType: "user",
requestedByActorId: "responsible-user",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
interactionId: "superseded-confirmation",
reviewPathLost: true,
reviewPathConsumedRef: "superseded-confirmation",
},
});
expect(followUpRun).not.toBeNull();
await heartbeat.drainActiveRunExecutions();
const recoveryWakes = await db
.select()
.from(agentWakeupRequests)
.where(and(
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.reason, "issue_review_path_lost"),
));
expect(recoveryWakes).toHaveLength(1);
expect(recoveryWakes[0]).toMatchObject({
status: "completed",
payload: expect.objectContaining({
issueId,
reviewPathConsumedRef: "superseded-confirmation",
reviewPathRecoveryAttempt: 1,
maxReviewPathRecoveryAttempts: 1,
}),
});
const attention = await issueService(db)
.listReviewAttention(companyId, [{ id: issueId, companyId, status: "in_review" }]);
expect(attention.get(issueId)).toMatchObject({ state: "stalled", paths: [] });
const feed = await attentionService(db).list(companyId, { userId: "responsible-user" });
expect(feed.items.find((item) => item.subject.id === issueId)).toMatchObject({
sourceKind: "review",
decisionVerbs: expect.arrayContaining([
expect.objectContaining({ id: "choose_review_path", label: "Choose review path" }),
]),
});
});
it("keeps resolved dependency wake reconciliation active when liveness auto recovery is disabled", async () => {
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.autoRecoveryEnabled).toBe(false);
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
expect(result.escalationsCreated).toBe(0);
const wake = await db
.select({
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.orderBy(agentWakeupRequests.requestedAt)
.then((rows) => rows[0] ?? null);
expect(wake?.reason).toBe("issue_blockers_resolved");
expect(wake?.idempotencyKey).toBe(
buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
);
expect(["queued", "claimed", "completed"]).toContain(wake?.status);
const events = await db
.select({ action: activityLog.action, entityId: activityLog.entityId, details: activityLog.details })
.from(activityLog)
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "issue.blockers_resolved_wake_emitted")));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
entityId: blockedIssueId,
details: expect.objectContaining({ source: "issue_graph_liveness.backstop" }),
});
});
it("heals a blocked dependent whose done blocker has no workspace finalize obligation", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
expect(result.escalationsCreated).toBe(0);
const wake = await db
.select({
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.orderBy(agentWakeupRequests.requestedAt)
.then((rows) => rows[0] ?? null);
expect(wake?.reason).toBe("issue_blockers_resolved");
expect(wake?.idempotencyKey).toBe(
buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
);
expect(["queued", "claimed", "completed"]).toContain(wake?.status);
const events = await db
.select({ action: activityLog.action, entityId: activityLog.entityId, details: activityLog.details })
.from(activityLog)
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "issue.blockers_resolved_wake_emitted")));
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ entityId: blockedIssueId });
});
it("reconciles a resolved blocked dependency after the assignee-null window closes", async () => {
const { agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none", assignee: null });
const heartbeat = heartbeatService(db);
const beforeAssignment = await heartbeat.reconcileIssueGraphLiveness();
expect(beforeAssignment.dependencyWakesHealed).toBe(0);
expect(beforeAssignment.dependencyWakeBackstopChecked).toBe(0);
await db
.update(issues)
.set({ assigneeAgentId: agentId, updatedAt: new Date() })
.where(eq(issues.id, blockedIssueId));
const afterAssignment = await heartbeat.reconcileIssueGraphLiveness();
expect(afterAssignment.dependencyWakesHealed).toBe(1);
expect(afterAssignment.dependencyWakeIssueIds).toEqual([blockedIssueId]);
const wake = await db
.select({
reason: agentWakeupRequests.reason,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.orderBy(agentWakeupRequests.requestedAt)
.then((rows) => rows[0] ?? null);
expect(wake).toMatchObject({
reason: "issue_blockers_resolved",
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
});
});
it("retries a resolved dependency wake when the prior wake was skipped as stale", async () => {
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
// The route-time wake writes the level-triggered state key. A skip records a
// `skipped` row with that key. `skipped` is not an in-flight status, so the
// backstop must still re-emit for the same ready state.
const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
});
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: blockerIssueId,
blockerIssueIds: [blockerIssueId],
},
status: "skipped",
finishedAt: new Date(),
error: "Cancelled because issue assignee changed before the queued run could start",
idempotencyKey,
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeExistingSkipped).toBe(0);
const wakes = await db
.select({
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "issue_blockers_resolved")))
.orderBy(agentWakeupRequests.requestedAt);
expect(wakes).toHaveLength(2);
expect(wakes.map((wake) => wake.status)).toContain("skipped");
expect(wakes.every((wake) => wake.idempotencyKey === idempotencyKey)).toBe(true);
expect(wakes.some((wake) => ["queued", "claimed", "completed"].includes(wake.status))).toBe(true);
});
it("waits for workspace finalize before healing a resolved blocked dependent", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId, executionWorkspaceId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "not_finalized" });
const heartbeat = heartbeatService(db);
const beforeFinalize = await heartbeat.reconcileIssueGraphLiveness();
expect(beforeFinalize.findings).toBe(0);
expect(beforeFinalize.dependencyWakesHealed).toBe(0);
expect(beforeFinalize.dependencyWakeNotReadySkipped).toBe(1);
const wakesBeforeFinalize = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
expect(wakesBeforeFinalize).toHaveLength(0);
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerIssueId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date(),
});
const afterFinalize = await heartbeat.reconcileIssueGraphLiveness();
expect(afterFinalize.dependencyWakesHealed).toBe(1);
expect(afterFinalize.dependencyWakeIssueIds).toEqual([blockedIssueId]);
const wake = await db
.select({
reason: agentWakeupRequests.reason,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId))
.orderBy(agentWakeupRequests.requestedAt)
.then((rows) => rows[0] ?? null);
expect(wake).toMatchObject({
reason: "issue_blockers_resolved",
idempotencyKey: buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
});
});
it("does not duplicate an existing dependency wake keyed to any resolved blocker", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const secondBlockerIssueId = randomUUID();
await db.insert(issues).values({
id: secondBlockerIssueId,
companyId,
title: "Second completed blocker",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "R-MULTI-3",
});
await db.insert(issueRelations).values({
companyId,
issueId: secondBlockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
const readiness = await issueService(db).getDependencyReadiness(blockedIssueId);
const blockerIdNotUsedByBackstop = readiness.blockerIssueIds.find((id) => id !== blockerIssueId);
if (!blockerIdNotUsedByBackstop) {
throw new Error("Expected a second blocker id in dependency readiness");
}
expect(blockerIdNotUsedByBackstop).toBe(secondBlockerIssueId);
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: blockerIdNotUsedByBackstop,
},
status: "queued",
idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIdNotUsedByBackstop}`,
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(0);
expect(result.dependencyWakeExistingSkipped).toBe(1);
const wakes = await db
.select({
id: agentWakeupRequests.id,
idempotencyKey: agentWakeupRequests.idempotencyKey,
})
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.reason, "issue_blockers_resolved")));
expect(wakes).toHaveLength(1);
expect(wakes[0]?.idempotencyKey).toBe(
`issue_blockers_resolved:${blockedIssueId}:${blockerIdNotUsedByBackstop}`,
);
});
it("heals a multi-blocker dependent when only a completed wake for an earlier blocker exists", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const secondBlockerIssueId = randomUUID();
await db.insert(issues).values({
id: secondBlockerIssueId,
companyId,
title: "Earlier completed blocker",
status: "done",
priority: "medium",
issueNumber: 3,
identifier: "R-MULTI-3",
});
await db.insert(issueRelations).values({
companyId,
issueId: secondBlockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
// An earlier partial resolution left a `completed` per-edge wake. The bug was
// that this stale wake suppressed the wake for the current ready state. The
// level-triggered dedup keys on the full blocker set, so this completed wake
// no longer strands the dependent.
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: secondBlockerIssueId,
},
status: "completed",
finishedAt: new Date(),
idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${secondBlockerIssueId}`,
});
const readiness = await issueService(db).getDependencyReadiness(blockedIssueId);
expect(readiness.isDependencyReady).toBe(true);
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
expect(result.dependencyWakeExistingSkipped).toBe(0);
const stateKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: readiness.blockerIssueIds,
});
const healedWake = await db
.select({ status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey })
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, stateKey)))
.then((rows) => rows[0] ?? null);
expect(healedWake).not.toBeNull();
expect(["queued", "claimed", "completed"]).toContain(healedWake?.status);
// A second reconciliation pass finds the state-key wake and stays bounded:
// it heals nothing more and never enqueues a second wake for the same state.
const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(secondPass.dependencyWakesHealed).toBe(0);
const stateKeyWakes = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, stateKey)));
expect(stateKeyWakes).toHaveLength(1);
});
it("heals a blocked dependent after a terminal reset when a previous-cycle old-key wake exists", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const previousCycleWakeAt = new Date("2026-07-01T12:00:00.000Z");
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
await db
.update(issues)
.set({ blockedTransitionAt, updatedAt: blockedTransitionAt })
.where(eq(issues.id, blockedIssueId));
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: blockerIssueId,
blockerIssueIds: [blockerIssueId],
},
status: "completed",
finishedAt: previousCycleWakeAt,
requestedAt: previousCycleWakeAt,
idempotencyKey: buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(1);
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
expect(result.dependencyWakeExistingSkipped).toBe(0);
const cycleKey = buildIssueBlockersResolvedWakeStateKey({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
blockedTransitionAt,
});
const healedWake = await db
.select({ status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey })
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, cycleKey)))
.then((rows) => rows[0] ?? null);
expect(healedWake).not.toBeNull();
expect(["queued", "claimed", "completed"]).toContain(healedWake?.status);
const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(secondPass.dependencyWakesHealed).toBe(0);
const cycleKeyWakes = await db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, cycleKey)));
expect(cycleKeyWakes).toHaveLength(1);
});
it("does not re-heal when a completed old-key wake is from the current blocked cycle", async () => {
await enableAutoRecovery();
const { companyId, agentId, blockedIssueId, blockerIssueId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
const sameCycleWakeAt = new Date("2026-08-01T12:00:01.000Z");
await db
.update(issues)
.set({ blockedTransitionAt, updatedAt: blockedTransitionAt })
.where(eq(issues.id, blockedIssueId));
await db.insert(agentWakeupRequests).values({
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: "issue_blockers_resolved",
payload: {
issueId: blockedIssueId,
resolvedBlockerIssueId: blockerIssueId,
blockerIssueIds: [blockerIssueId],
},
status: "completed",
finishedAt: sameCycleWakeAt,
requestedAt: sameCycleWakeAt,
idempotencyKey: buildIssueBlockersResolvedWakeStateKeyWithoutCycle({
dependentIssueId: blockedIssueId,
blockerIssueIds: [blockerIssueId],
}),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(0);
expect(result.dependencyWakeExistingSkipped).toBe(1);
});
it("counts null dependency wake returns as deferred instead of enqueue failures", async () => {
await enableAutoRecovery();
const { companyId, agentId } =
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
await db
.update(agents)
.set({
runtimeConfig: { heartbeat: { wakeOnDemand: false, maxConcurrentRuns: 1 } },
})
.where(eq(agents.id, agentId));
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.dependencyWakesHealed).toBe(0);
expect(result.dependencyWakeDeferredOrFailed).toBe(1);
expect(result.dependencyWakeEnqueueFailed).toBe(0);
const skippedWake = await db
.select({
status: agentWakeupRequests.status,
reason: agentWakeupRequests.reason,
})
.from(agentWakeupRequests)
.where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.agentId, agentId)))
.then((rows) => rows[0] ?? null);
expect(skippedWake).toMatchObject({
status: "skipped",
reason: "heartbeat.wakeOnDemand.disabled",
});
});
it("does not create recovery issues outside the configured lookback window", async () => {
await enableAutoRecovery();
const { companyId } = await seedBlockedChain({ outsideLookback: true });
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileIssueGraphLiveness();
expect(result.findings).toBe(1);
expect(result.escalationsCreated).toBe(0);
expect(result.skippedOutsideLookback).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(0);
});
it("suppresses liveness escalation when the source issue is under an active pause hold", async () => {
await enableAutoRecovery();
const { companyId, blockedIssueId } = await seedBlockedChain();
await db.insert(issueTreeHolds).values({
companyId,
rootIssueId: blockedIssueId,
mode: "pause",
status: "active",
reason: "pause liveness recovery subtree",
releasePolicy: { strategy: "manual" },
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(1);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
expect(result.skipped).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(0);
});
it("treats an active executionRunId on the leaf blocker as a live execution path", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const runId = randomUUID();
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId: managerId,
status: "running",
contextSnapshot: { issueId: blockedIssueId },
});
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, blockerIssueId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
});
it("creates one bounded escalation for an assigned backlog blocker leaf", async () => {
await enableAutoRecovery();
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
blockerStatus: "backlog",
blockerAssigneeAgentId: "coder",
});
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(first.findings).toBe(1);
expect(first.escalationsCreated).toBe(1);
expect(second.findings).toBe(0);
expect(second.escalationsCreated).toBe(0);
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: blockerIssueId,
assigneeAgentId: coderId,
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"blocked_by_assigned_backlog_issue",
blockerIssueId,
].join(":"),
originFingerprint: [
"harness_liveness_leaf",
companyId,
"blocked_by_assigned_backlog_issue",
blockerIssueId,
].join(":"),
});
});
it("treats open recovery issues as active waiting paths for non-assigned-backlog states", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const existingEscalationId = randomUUID();
await db.insert(issues).values({
id: existingEscalationId,
companyId,
title: "Existing liveness unblock work",
status: "todo",
priority: "high",
parentId: blockerIssueId,
assigneeAgentId: managerId,
issueNumber: 5,
identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`,
originKind: "harness_liveness_escalation",
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"in_review_without_action_path",
blockerIssueId,
].join(":"),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
});
it("keeps active invalid_review_participant recoveries from being retired", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const existingEscalationId = randomUUID();
await db.insert(issues).values({
id: existingEscalationId,
companyId,
title: "Existing invalid review participant unblock work",
status: "todo",
priority: "high",
parentId: blockedIssueId,
assigneeAgentId: managerId,
issueNumber: 5,
identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`,
originKind: "harness_liveness_escalation",
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"invalid_review_participant",
blockerIssueId,
].join(":"),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
});
it("creates one manager escalation, preserves blockers, and records owner selection", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
expect(first.escalationsCreated).toBe(1);
const [sourceAfterFirst] = await db
.select({ updatedAt: issues.updatedAt })
.from(issues)
.where(eq(issues.id, blockedIssueId));
const eventsAfterFirst = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
expect(eventsAfterFirst.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1);
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(second.escalationsCreated).toBe(0);
const [sourceAfterSecond] = await db
.select({ updatedAt: issues.updatedAt })
.from(issues)
.where(eq(issues.id, blockedIssueId));
expect(sourceAfterSecond?.updatedAt.getTime()).toBe(sourceAfterFirst?.updatedAt.getTime());
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: blockerIssueId,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
status: expect.stringMatching(/^(todo|in_progress|done)$/),
originFingerprint: [
"harness_liveness_leaf",
companyId,
"blocked_by_unassigned_issue",
blockerIssueId,
].join(":"),
});
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 events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created");
expect(createdEvent).toBeTruthy();
expect(createdEvent?.details).toMatchObject({
recoveryIssueId: blockerIssueId,
ownerSelection: {
selectedAgentId: managerId,
selectedReason: "root_agent",
selectedSourceIssueId: blockerIssueId,
},
workspaceSelection: {
reuseRecoveryExecutionWorkspace: false,
inheritedExecutionWorkspaceFromIssueId: null,
projectWorkspaceSourceIssueId: blockerIssueId,
},
});
expect(events.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1);
});
it("skips budget-blocked direct owners and assigns recovery to the manager fallback", async () => {
await enableAutoRecovery();
const { companyId, managerId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const issueTimestamp = new Date(Date.now() - 25 * 60 * 60 * 1000);
await db
.update(issues)
.set({
status: "in_review",
assigneeAgentId: coderId,
updatedAt: issueTimestamp,
})
.where(eq(issues.id, blockerIssueId));
await db.insert(budgetPolicies).values({
companyId,
scopeType: "agent",
scopeId: coderId,
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 1,
hardStopEnabled: true,
isActive: true,
});
await db.insert(costEvents).values({
companyId,
agentId: coderId,
issueId: blockerIssueId,
provider: "test",
biller: "test",
billingType: "tokens",
model: "test-model",
costCents: 1,
occurredAt: new Date(),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.escalationsCreated).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: blockerIssueId,
assigneeAgentId: managerId,
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"in_review_without_action_path",
blockerIssueId,
].join(":"),
});
const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created");
expect(createdEvent?.details).toMatchObject({
ownerSelection: {
selectedAgentId: managerId,
selectedReason: "assignee_reporting_chain",
budgetBlockedCandidateAgentIds: [coderId],
},
});
});
it("parents recovery under the leaf blocker without inheriting dependent or blocker execution state for manager-owned recovery", async () => {
await enableAutoRecovery();
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
const companyId = randomUUID();
const managerId = randomUUID();
const blockedIssueId = randomUUID();
const blockerIssueId = randomUUID();
const dependentProjectId = randomUUID();
const blockerProjectId = randomUUID();
const dependentProjectWorkspaceId = randomUUID();
const blockerProjectWorkspaceId = randomUUID();
const dependentExecutionWorkspaceId = randomUUID();
const blockerExecutionWorkspaceId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const issueTimestamp = new Date(Date.now() - 60 * 60 * 1000);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: managerId,
companyId,
name: "Root Operator",
role: "operator",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: { heartbeat: { wakeOnDemand: false } },
permissions: {},
});
await db.insert(projects).values([
{
id: dependentProjectId,
companyId,
name: "Dependent workspace project",
status: "in_progress",
},
{
id: blockerProjectId,
companyId,
name: "Blocker workspace project",
status: "in_progress",
},
]);
await db.insert(projectWorkspaces).values([
{
id: dependentProjectWorkspaceId,
companyId,
projectId: dependentProjectId,
name: "Dependent primary",
},
{
id: blockerProjectWorkspaceId,
companyId,
projectId: blockerProjectId,
name: "Blocker primary",
},
]);
await db.insert(executionWorkspaces).values([
{
id: dependentExecutionWorkspaceId,
companyId,
projectId: dependentProjectId,
projectWorkspaceId: dependentProjectWorkspaceId,
mode: "operator_branch",
strategyType: "git_worktree",
name: "Dependent branch",
status: "active",
providerType: "git_worktree",
},
{
id: blockerExecutionWorkspaceId,
companyId,
projectId: blockerProjectId,
projectWorkspaceId: blockerProjectWorkspaceId,
mode: "operator_branch",
strategyType: "git_worktree",
name: "Blocker branch",
status: "active",
providerType: "git_worktree",
},
]);
await db.insert(issues).values([
{
id: blockedIssueId,
companyId,
projectId: dependentProjectId,
projectWorkspaceId: dependentProjectWorkspaceId,
executionWorkspaceId: dependentExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "operator_branch" },
title: "Blocked dependent",
status: "blocked",
priority: "medium",
issueNumber: 1,
identifier: `${issuePrefix}-1`,
createdAt: issueTimestamp,
updatedAt: issueTimestamp,
},
{
id: blockerIssueId,
companyId,
projectId: blockerProjectId,
projectWorkspaceId: blockerProjectWorkspaceId,
executionWorkspaceId: blockerExecutionWorkspaceId,
executionWorkspacePreference: "reuse_existing",
executionWorkspaceSettings: { mode: "operator_branch" },
title: "Unassigned leaf blocker",
status: "todo",
priority: "medium",
issueNumber: 2,
identifier: `${issuePrefix}-2`,
createdAt: issueTimestamp,
updatedAt: issueTimestamp,
},
]);
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: blockedIssueId,
type: "blocks",
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.escalationsCreated).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: blockerIssueId,
projectId: blockerProjectId,
projectWorkspaceId: blockerProjectWorkspaceId,
executionWorkspaceId: null,
executionWorkspacePreference: null,
assigneeAgentId: managerId,
assigneeAdapterOverrides: { modelProfile: "cheap" },
});
});
it("reuses one open recovery issue for multiple dependents with the same leaf blocker", async () => {
await enableAutoRecovery();
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const secondBlockedIssueId = randomUUID();
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const issueTimestamp = new Date(Date.now() - 60 * 60 * 1000);
await db.insert(issues).values({
id: secondBlockedIssueId,
companyId,
title: "Second blocked parent",
status: "blocked",
priority: "medium",
issueNumber: 3,
identifier: `${issuePrefix}-3`,
createdAt: issueTimestamp,
updatedAt: issueTimestamp,
});
await db.insert(issueRelations).values({
companyId,
issueId: blockerIssueId,
relatedIssueId: secondBlockedIssueId,
type: "blocks",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileIssueGraphLiveness();
expect(result.findings).toBe(2);
expect(result.escalationsCreated).toBe(1);
expect(result.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);
const blockers = await db
.select({ blockedIssueId: issueRelations.relatedIssueId })
.from(issueRelations)
.where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.issueId, escalations[0]!.id)));
expect(blockers.map((row) => row.blockedIssueId).sort()).toEqual(
[blockedIssueId, secondBlockedIssueId].sort(),
);
});
it("holds a recently closed matching escalation, then re-escalates after the cooldown", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const now = new Date();
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,
createdAt: new Date(now.getTime() - 30 * 60 * 1000),
updatedAt: now,
});
const held = await heartbeat.reconcileIssueGraphLiveness({ now });
expect(held.escalationsCreated).toBe(0);
expect(held.skippedReescalationCooldown).toBe(1);
const result = await heartbeat.reconcileIssueGraphLiveness({
now: new Date(now.getTime() + DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS + 1),
});
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: blockerIssueId,
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);
});
it("re-escalates immediately after a matching escalation is cancelled", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const now = new Date();
const incidentKey = [
"harness_liveness",
companyId,
blockedIssueId,
"blocked_by_unassigned_issue",
blockerIssueId,
].join(":");
await db.insert(issues).values({
id: randomUUID(),
companyId,
title: "Cancelled escalation",
status: "cancelled",
priority: "high",
parentId: blockedIssueId,
assigneeAgentId: managerId,
issueNumber: 3,
identifier: "CANCELLED-3",
originKind: "harness_liveness_escalation",
originId: incidentKey,
createdAt: new Date(now.getTime() - 30 * 60 * 1000),
updatedAt: now,
});
const result = await heartbeat.reconcileIssueGraphLiveness({ now });
expect(result.escalationsCreated).toBe(1);
expect(result.skippedReescalationCooldown).toBe(0);
});
it("removes closed liveness escalations from blocker relations during reconciliation", async () => {
await enableAutoRecovery();
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
expect(first.escalationsCreated).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);
await db
.update(issues)
.set({ status: "done", blockedByIssueIds: [] })
.where(eq(issues.id, escalations[0]!.id));
await db
.update(issues)
.set({ status: "done", blockedByIssueIds: [] })
.where(eq(issues.id, blockerIssueId));
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0);
expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1);
const blockers = await db
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false);
});
it("handles an armed cutoff when no liveness findings exist", async () => {
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileIssueGraphLiveness({
issueCreatedAtGte: new Date(),
});
expect(result.findings).toBe(0);
});
});