mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:03:27 +01:00
## 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>
1612 lines
56 KiB
TypeScript
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);
|
|
});
|
|
});
|