feat(FN-3548): add agent action routes and approval pause/resume lifecycle

Merges the FN-3548 approval pause/resume system: agents now stall at workflow gates pending approval, with a full lifecycle spanning gate context lookup, action-gate pause/retry, executor and heartbeat pause callbacks, and dedicated approval decision routes. Also lands FN-3744 agent action routes an

Fusion-Task-Id: FN-3548
This commit is contained in:
Fusion
2026-05-08 20:19:15 -07:00
committed by gsxdsm
parent c27fd0a1f9
commit 92ca3a238f
17 changed files with 833 additions and 69 deletions

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -193,6 +193,98 @@ describe("ApprovalRequestStore", () => {
expect(byTask.map((r) => r.id)).toEqual([first.id]);
});
it("findLatestByDedupeKey returns newest match across statuses", () => {
vi.useFakeTimers();
const dedupeKey = "agent-1|FN-100|write|file_write_delete|file|a.ts|write";
vi.setSystemTime(new Date("2026-05-08T00:00:00.000Z"));
const first = store.create({
requester: REQUESTER,
targetAction: {
category: "file_write_delete",
action: "write",
summary: "write a.ts",
resourceType: "file",
resourceId: "a.ts",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-100",
});
store.decide(first.id, "approved", { actor: APPROVER });
vi.setSystemTime(new Date("2026-05-08T00:00:01.000Z"));
const second = store.create({
requester: REQUESTER,
targetAction: {
category: "file_write_delete",
action: "write",
summary: "write a.ts again",
resourceType: "file",
resourceId: "a.ts",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-100",
});
const latest = store.findLatestByDedupeKey({ requesterActorId: REQUESTER.actorId, taskId: "FN-100", dedupeKey });
expect(latest?.id).toBe(second.id);
expect(latest?.status).toBe("pending");
vi.useRealTimers();
});
it("findLatestByDedupeKey scopes by requester and task", () => {
const dedupeKey = "shared-key";
const mine = store.create({
requester: REQUESTER,
targetAction: {
category: "command_execution",
action: "bash",
summary: "run command",
resourceType: "command",
resourceId: "pnpm test",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-200",
});
store.create({
requester: { ...REQUESTER, actorId: "agent-2" },
targetAction: {
category: "command_execution",
action: "bash",
summary: "other requester",
resourceType: "command",
resourceId: "pnpm lint",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-200",
});
store.create({
requester: REQUESTER,
targetAction: {
category: "command_execution",
action: "bash",
summary: "other task",
resourceType: "command",
resourceId: "pnpm build",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-201",
});
const scoped = store.findLatestByDedupeKey({ requesterActorId: REQUESTER.actorId, taskId: "FN-200", dedupeKey });
expect(scoped?.id).toBe(mine.id);
});
it("findLatestByDedupeKey returns null when no dedupe key matches", () => {
createSampleRequest();
const latest = store.findLatestByDedupeKey({ requesterActorId: REQUESTER.actorId, taskId: "FN-3546", dedupeKey: "missing" });
expect(latest).toBeNull();
});
it("persists requests and audit history across restart/migration", () => {
db.close();

View File

@@ -221,6 +221,31 @@ export class ApprovalRequestStore {
return rows.map((row) => this.rowToRequest(row));
}
findLatestByDedupeKey(input: { requesterActorId: string; taskId?: string; dedupeKey: string }): ApprovalRequest | null {
const where = ["requesterActorId = ?"];
const params: Array<string> = [input.requesterActorId];
if (input.taskId !== undefined) {
where.push("taskId = ?");
params.push(input.taskId);
}
const rows = this.db.prepare(`
SELECT * FROM approval_requests
WHERE ${where.join(" AND ")}
ORDER BY createdAt DESC, id DESC
`).all(...params) as ApprovalRequestRow[];
for (const row of rows) {
const context = fromJson<Record<string, unknown>>(row.targetContext);
if (context?.approvalDedupeKey === input.dedupeKey) {
return this.rowToRequest(row);
}
}
return null;
}
decide(requestId: string, status: "approved" | "denied", input: ApprovalRequestDecisionInput): ApprovalRequest {
const existing = this.get(requestId);
if (!existing) {