var test = require("node:test"); var assert = require("node:assert/strict"); var autonomousModule = require("../lib/project-autonomous-run"); var fullAccessModule = require("../lib/project-full-access"); var fs = require("fs"); var os = require("os"); var path = require("path"); var attachScheduler = require("../lib/durable-scheduler").attachDurableScheduler; var helper = require("./helpers/autonomous-run-fixture"); var fixture = helper.fixture; var tick = helper.tick; var timerTick = helper.timerTick; var arm = helper.arm; var startRun = helper.startRun; test("durable lifecycle storage failure blocks the first SDK turn", async function () { var scheduler = { registerHandler: function () { return function () {}; }, listJobs: function () { return []; }, enqueue: function () { throw new Error("lifecycle disk unavailable"); }, cancel: function () {}, replaceQueued: function () {}, }; var f = fixture(null, null, null, null, null, { durableScheduler: scheduler }); await arm(f, { maxMinutes: 5 }); assert.equal(f.controller.consume(f.session, { text: "Do not dispatch", autonomousRunToken: f.session.autonomousRun.armToken }), false); await tick(); await tick(); assert.equal(f.sdkCalls.length, 0); assert.equal(f.session.autonomousRun.state, "error"); f.controller.shutdown(); }); test("durable storage failure while resuming restores access and dispatches no SDK turn", async function () { var scheduler = { registerHandler: function () { return function () {}; }, listJobs: function () { return []; }, enqueue: function () { throw new Error("resume disk unavailable"); }, cancel: function () {}, replaceQueued: function () {}, }; var now = Date.now(); var persisted = { id: "resume-storage", state: "paused", objective: "Resume", successCriteria: ["Done"], continuationCount: 0, maxContinuations: 2, maxMinutes: 5, startedAt: now, deadlineAt: now + 60000, waitingReason: "Paused after restart.", pausedAfterRestart: true, cancellationEpoch: 1, permissionSnapshot: { permissionMode: "default", permissionModeBeforeFullAccess: null, enabled: false } }; var f = fixture(persisted, null, null, null, null, { durableScheduler: scheduler }); f.controller.handleMessage({}, { type: "autonomous_run_resume", sessionId: f.session.localId, runId: persisted.id, requestId: "resume-storage" }); await tick(); await tick(); await tick(); assert.equal(f.sdkCalls.length, 0); assert.equal(f.session.autonomousRun.state, "error"); assert.equal(f.session.permissionMode, "default"); assert.equal(f.messages.some(function (entry) { return entry.message.type === "autonomous_run_action_result" && entry.message.ok === false; }), true); f.controller.shutdown(); }); test("durable lifecycle restart pauses the run across a changed local id without auto-resume", async function (t) { var dir = fs.mkdtempSync(path.join(os.tmpdir(), "clay-autonomous-restart-")); t.after(function () { fs.rmSync(dir, { recursive: true, force: true }); }); function makeScheduler() { return attachScheduler({ storageDir: dir, namespace: "test", setTimer: function () { return { unref: function () {} }; }, clearTimer: function () {} }); } var firstScheduler = makeScheduler(); firstScheduler.start(); var first = fixture(null, null, null, null, null, { durableScheduler: firstScheduler, localId: 7 }); await arm(first, { maxMinutes: 5 }); assert.equal(first.controller.consume(first.session, { text: "Persist", autonomousRunToken: first.session.autonomousRun.armToken }), true); var storedRun = JSON.parse(JSON.stringify(first.session.autonomousRun)); var origin = first.session.sessionOriginId; first.controller.shutdown(); await firstScheduler.shutdown(); var secondScheduler = makeScheduler(); secondScheduler.start(); var second = fixture(storedRun, null, null, null, null, { durableScheduler: secondScheduler, localId: 99, sessionOriginId: origin }); await tick(); await tick(); assert.equal(second.session.autonomousRun.state, "paused"); assert.equal(second.session.autonomousRun.pausedAfterRestart, true); assert.equal(second.sdkCalls.length, 0); assert.equal(secondScheduler.listJobs().filter(function (job) { return job.state === "queued"; }).length, 0); second.controller.shutdown(); await secondScheduler.shutdown(); }); test("a late continuation SDK rejection cannot terminate a newer run or query", async function () { var rejectOld; var oldStart = new Promise(function (resolve, reject) { rejectOld = reject; }); var f = fixture(null, null, null, oldStart); await arm(f, { maxContinuations: 3, maxMinutes: 5 }); assert.equal(f.controller.consume(f.session, { text: "Start old run", autonomousRunToken: f.session.autonomousRun.armToken }), true); f.controller.onTurnDone(f.session); await timerTick(); var oldGeneration = f.session._queryGeneration; var newer = Object.assign({}, f.session.autonomousRun, { id: "newer-run", state: "running", continuationPending: false }); f.session.autonomousRun = newer; f.session._queryGeneration = oldGeneration + 1; f.session.isProcessing = true; rejectOld(new Error("old continuation failed")); await tick(); await tick(); assert.equal(f.session.autonomousRun, newer); assert.equal(newer.state, "running"); assert.equal(f.session.isProcessing, true); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("arming sends no acknowledgement until the permission transition succeeds", async function () { var release; var gate = new Promise(function (resolve) { release = resolve; }); var f = fixture(null, gate); f.controller.handleMessage({}, { type: "autonomous_run_arm", sessionId: f.session.localId }); await tick(); assert.equal(f.messages.some(function (entry) { return entry.message.type === "autonomous_run_arm_result"; }), false); release(); await tick(); await tick(); assert.equal(f.messages.filter(function (entry) { return entry.message.type === "autonomous_run_arm_result"; }).length, 1); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("approved brief releases the controller before a long SDK turn so Stop remains usable", async function () { var releasePermission; var permissionGate = new Promise(function (resolve) { releasePermission = resolve; }); var releaseSdk; var sdkGate = new Promise(function (resolve) { releaseSdk = resolve; }); var f = fixture(null, permissionGate, null, sdkGate); var proposal = { id: "proposal-1", version: 1, objective: "Ship the reviewed task", successCriteria: ["Tests pass"], maxContinuations: 2, maxMinutes: 5 }; var startPromise = f.controller.startApprovedBrief({}, f.session, proposal, "handoff-1", function () { return true; }); await tick(); assert.equal(f.sdkCalls.length, 0); releasePermission(); await tick(); await tick(); await startPromise; assert.equal(f.sdkCalls[0].kind, "start"); assert.equal(f.session.autonomousRun.state, "running"); assert.equal(f.messages.some(function (entry) { return entry.message === undefined; }), false); assert.equal(f.messages.some(function (entry) { return entry.message && entry.message.source === "loop_interview_run"; }), true); f.controller.handleMessage({}, { type: "autonomous_run_stop", sessionId: f.session.localId, runId: f.session.autonomousRun.id, requestId: "stop-1" }); await tick(); await tick(); assert.equal(f.session.autonomousRun.state, "stopped"); assert.deepEqual(f.permissionCalls[f.permissionCalls.length - 1], ["restore", false]); releaseSdk(); }); test("approved brief rechecks processing after permission enable", async function () { var releasePermission; var permissionGate = new Promise(function (resolve) { releasePermission = resolve; }); var f = fixture(null, permissionGate, null, null, function (session) { session.isProcessing = true; }); var proposal = { id: "proposal-race", version: 1, objective: "Race", successCriteria: ["Evidence"], maxContinuations: 1, maxMinutes: 1 }; var handoff = f.controller.startApprovedBrief({}, f.session, proposal, "race-1", function () { return true; }); releasePermission(); assert.equal(await handoff, false); assert.equal(f.sdkCalls.length, 0); f.session.isProcessing = false; }); test("synchronous approved-start failure clears processing and status", async function () { var f = fixture(null, null, null, { throw: new Error("startup failed") }); var proposal = { id: "proposal-sync-failure", version: 1, objective: "Fail", successCriteria: ["Error is visible"], maxContinuations: 1, maxMinutes: 1 }; assert.equal(await f.controller.startApprovedBrief({}, f.session, proposal, "sync-failure", function () { return true; }), false); assert.equal(f.session.isProcessing, false); assert.equal(f.session.autonomousRun.state, "error"); assert.equal(f.messages.some(function (entry) { return entry.message && entry.message.type === "status" && entry.message.status === "idle"; }), true); }); test("late SDK rejection cannot finish a newer run", async function () { var rejectSdk; var sdkGate = new Promise(function (resolve, reject) { rejectSdk = reject; }); var f = fixture(null, null, null, sdkGate); var proposal = { id: "proposal-late", version: 1, objective: "Late", successCriteria: ["Evidence"], maxContinuations: 1, maxMinutes: 1 }; assert.equal(await f.controller.startApprovedBrief({}, f.session, proposal, "late-1", function () { return true; }), true); var oldId = f.session.autonomousRun.id; await f.controller.finish(f.session, "stopped", "replaced"); f.session.isProcessing = true; rejectSdk(new Error("late failure")); await tick(); await tick(); assert.equal(f.session.isProcessing, true); f.session.isProcessing = false; f.session.autonomousRun = { id: "new-run", state: "running", permissionSnapshot: {}, continuationPending: false }; await tick(); assert.equal(f.session.autonomousRun.id, "new-run"); assert.notEqual(f.session.autonomousRun.id, oldId); }); test("arming acknowledges permission first and one turn completion schedules only one correlated continuation", async function () { var f = fixture(); var run = await startRun(f); assert.deepEqual(f.permissionCalls[0], ["set", true]); f.session.isProcessing = false; f.controller.onTurnDone(f.session); f.controller.onTurnDone(f.session); await timerTick(); assert.equal(f.sdkCalls.filter(function (call) { return call.kind === "start"; }).length, 1); assert.equal(f.session.autonomousRun.continuationCount, 1); assert.equal(f.sdkCalls[0].meta.autonomousRunId, run.id); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("completion requires concrete criterion evidence and restores permission only after turn completion", async function () { var f = fixture(); var run = await startRun(f, ["Tests pass"]); f.session.isProcessing = true; var tool = f.controller.getToolDefs(f.session)[0]; var rejected = JSON.parse((await tool.handler({ runId: run.id, status: "completed", summary: "Done", evidence: ["node --test passed"], criteria: [] })).content[0].text); assert.equal(rejected.status, "rejected"); var recorded = JSON.parse((await tool.handler({ runId: run.id, status: "completed", summary: "Done", evidence: ["node --test passed"], criteria: [{ criterion: "Tests pass", met: true, evidence: "All focused tests passed" }] })).content[0].text); assert.equal(recorded.status, "recorded"); assert.equal(f.session.autonomousRun.state, "reviewing"); assert.equal(f.permissionCalls.length, 1, "permission remains enabled through the active turn"); f.session.isProcessing = false; f.controller.onTurnDone(f.session); await tick(); await tick(); assert.equal(f.session.autonomousRun.state, "completed"); assert.equal(f.permissionCalls[1][0], "restore"); assert.equal(f.pairStops(), 1); }); test("Stop preserves unrelated pending messages and blocks an already scheduled continuation", async function () { var f = fixture(); var run = await startRun(f); f.session.pendingPush = [{ text: "owned", autonomousRunId: run.id }, { text: "human steering" }]; f.session.isProcessing = false; f.controller.onTurnDone(f.session); await f.controller.finish(f.session, "stopped", "Stopped by test"); await tick(); assert.deepEqual(f.session.pendingPush, [{ text: "human steering" }]); assert.equal(f.sdkCalls.length, 0); assert.equal(f.session.autonomousRun.state, "stopped"); }); test("arm authorization and token consumption are exact and replay-safe", async function () { var f = fixture(); f.controller.handleMessage({}, { type: "autonomous_run_arm", sessionId: 999 }); await tick(); await tick(); var denied = f.messages.map(function (entry) { return entry.message; }).filter(function (entry) { return entry.type === "autonomous_run_arm_result"; }).pop(); assert.equal(denied.ok, false); var result = await arm(f); var submission = { text: "Once", autonomousRunToken: result.armToken, autonomousSuccessCriteria: ["Done"] }; assert.equal(f.controller.consume(f.session, submission), true); assert.equal(f.controller.consume(f.session, submission), false); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("armed criteria are server-owned, persisted in state, and cannot be replaced during consume", async function () { var f = fixture(); var result = await arm(f, { requestId: "arm-criteria", successCriteria: ["Server criterion"] }); assert.equal(result.requestId, "arm-criteria"); assert.equal(result.runId, result.run.id); assert.deepEqual(result.run.successCriteria, ["Server criterion"]); assert.equal(f.controller.consume(f.session, { text: "Ship", autonomousRunToken: result.armToken, autonomousSuccessCriteria: ["Injected criterion"] }), true); assert.deepEqual(f.session.autonomousRun.successCriteria, ["Server criterion"]); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("consume is rejected while a delayed disarm is restoring permissions", async function () { var releaseRestore; var restoreGate = new Promise(function (resolve) { releaseRestore = resolve; }); var f = fixture(null, null, restoreGate); var result = await arm(f); f.controller.handleMessage({}, { type: "autonomous_run_disarm", sessionId: f.session.localId, requestId: "disarm-1", runId: result.run.id }); assert.equal(f.controller.consume(f.session, { text: "Must not start", autonomousRunToken: result.armToken }), false); releaseRestore(); await tick(); await tick(); assert.equal(f.session.autonomousRun, null); var action = f.messages.map(function (entry) { return entry.message; }).filter(function (entry) { return entry.type === "autonomous_run_action_result"; }).pop(); assert.equal(action.ok, true); assert.equal(action.requestId, "disarm-1"); }); test("continuation and wall-clock limits end a run instead of scheduling extra work", async function () { var f = fixture(); await arm(f, { maxContinuations: 1 }); var token = f.session.autonomousRun.armToken; f.controller.consume(f.session, { text: "Bounded", autonomousRunToken: token }); f.session.autonomousRun.continuationCount = 1; f.controller.onTurnDone(f.session); await tick(); await tick(); assert.equal(f.session.autonomousRun.state, "limit"); assert.equal(f.sdkCalls.length, 0); var g = fixture(); await startRun(g); g.session.autonomousRun.deadlineAt = Date.now() - 1; g.controller.onTurnDone(g.session); await tick(); await tick(); assert.equal(g.session.autonomousRun.state, "limit"); assert.equal(g.sdkCalls.length, 0); }); test("rate-limit rejection cancels an immediate continuation and waits for its reset", async function () { var f = fixture(); await startRun(f); f.session.isProcessing = false; f.controller.onTurnDone(f.session); f.controller.onRateLimit(f.session, Date.now() + 20); await timerTick(); assert.equal(f.sdkCalls.length, 0); await new Promise(function (resolve) { setTimeout(resolve, 25); }); assert.equal(f.sdkCalls.filter(function (call) { return call.kind === "start"; }).length, 1); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("waiting states wake only from their matching Worker result or AskUser answer", async function () { var f = fixture(); var run = await startRun(f); var tool = f.controller.getToolDefs(f.session)[0]; await tool.handler({ runId: run.id, status: "waiting_worker", summary: "Worker is running", evidence: [], criteria: [] }); f.session.isProcessing = false; f.controller.onTurnDone(f.session); await tick(); assert.equal(f.sdkCalls.length, 0, "waiting for a Worker does not spin"); f.controller.onPartnerResult(f.session); assert.equal(f.session.autonomousRun.state, "running"); await tool.handler({ runId: run.id, status: "waiting_user", summary: "Need an answer", evidence: [], criteria: [] }); f.controller.onHumanMessage(f.session); assert.equal(f.session.autonomousRun.state, "running", "ordinary input can answer a human wait"); await tool.handler({ runId: run.id, status: "waiting_user", summary: "Need tool answer", evidence: [], criteria: [] }); f.controller.onUserAnswer(f.session); assert.equal(f.session.autonomousRun.state, "running", "AskUser tool answers wake the run"); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("a newer assessment or human steering invalidates a pending completion", async function () { var f = fixture(); var run = await startRun(f, ["Tests pass"]); var tool = f.controller.getToolDefs(f.session)[0]; var complete = { runId: run.id, status: "completed", summary: "Done", evidence: ["Focused test passed"], criteria: [{ criterion: "Tests pass", met: true, evidence: "Focused test passed" }] }; await tool.handler(complete); await tool.handler({ runId: run.id, status: "continue", summary: "More verification needed", evidence: [], criteria: [] }); f.session.isProcessing = false; f.controller.onTurnDone(f.session); await timerTick(); assert.notEqual(f.session.autonomousRun.state, "completed"); assert.equal(f.session.autonomousRun.completionRequested, false); f.session.isProcessing = true; await tool.handler(complete); await tool.handler({ runId: run.id, status: "waiting_user", summary: "Need confirmation", evidence: [], criteria: [] }); f.session.isProcessing = false; f.controller.onTurnDone(f.session); await tick(); assert.equal(f.session.autonomousRun.state, "waiting-user"); assert.equal(f.session.autonomousRun.completionRequested, false); f.controller.onHumanMessage(f.session); assert.equal(f.session.autonomousRun.outcome, null); f.controller.onTurnDone(f.session); await timerTick(); assert.notEqual(f.session.autonomousRun.state, "completed"); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("outcome reporting rejects armed, paused, and stale captured sessions", async function () { var f = fixture(); var run = await startRun(f); var tool = f.controller.getToolDefs(f.session)[0]; f.session.autonomousRun.state = "paused"; var paused = JSON.parse((await tool.handler({ runId: run.id, status: "continue", summary: "x", evidence: [], criteria: [] })).content[0].text); assert.equal(paused.status, "rejected"); f.session.autonomousRun.state = "armed"; var armed = JSON.parse((await tool.handler({ runId: run.id, status: "continue", summary: "x", evidence: [], criteria: [] })).content[0].text); assert.equal(armed.status, "rejected"); f.session.autonomousRun.state = "running"; f.controller.onHumanMessage(f.session); f.sm.sessions.set(f.session.localId, Object.assign({}, f.session)); var stale = JSON.parse((await tool.handler({ runId: run.id, status: "continue", summary: "x", evidence: [], criteria: [] })).content[0].text); assert.equal(stale.status, "rejected"); f.sm.sessions.set(f.session.localId, f.session); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("restart restores permissions, remains paused on ordinary text, and resumes only explicitly", async function () { var now = Date.now(); var persisted = { id: "run-restart", state: "running", objective: "Recover", successCriteria: ["Recovered"], continuationCount: 0, maxContinuations: 10, maxMinutes: 60, startedAt: now, deadlineAt: now + 60000, permissionSnapshot: { permissionMode: "default", permissionModeBeforeFullAccess: null, enabled: false }, cancellationEpoch: 0 }; var f = fixture(persisted); f.session.permissionMode = "bypassPermissions"; await tick(); await tick(); assert.equal(f.session.autonomousRun.state, "paused"); assert.equal(f.session.permissionMode, "default"); f.controller.onHumanMessage(f.session); assert.equal(f.session.autonomousRun.state, "paused"); f.controller.handleMessage({}, { type: "autonomous_run_resume", sessionId: f.session.localId, runId: f.session.autonomousRun.id }); await tick(); await tick(); assert.deepEqual(f.permissionCalls.slice(-1)[0], ["set", true]); assert.notEqual(f.session.autonomousRun.state, "paused"); await f.controller.finish(f.session, "stopped", "test cleanup"); }); test("durable Worker provenance cannot arm Until complete after pair dissolution", async function () { var f = fixture(); f.session.sessionProvenance = { kind: "worker", generation: 1, parentSessionOriginId: "driver-origin" }; f.controller.handleMessage({}, { type: "autonomous_run_arm", sessionId: f.session.localId }); await tick(); await tick(); var result = f.messages.map(function (entry) { return entry.message; }).filter(function (entry) { return entry.type === "autonomous_run_arm_result"; }).pop(); assert.equal(result.ok, false); assert.match(result.error, /project GUI Driver/); }); test("a GUI session currently running as TUI cannot arm Until complete", async function () { var f = fixture(); f.session.runtimeMode = "tui"; f.controller.handleMessage({}, { type: "autonomous_run_arm", sessionId: f.session.localId }); await tick(); await tick(); var result = f.messages.map(function (entry) { return entry.message; }).filter(function (entry) { return entry.type === "autonomous_run_arm_result"; }).pop(); assert.equal(result.ok, false); }); test("persisted run restoration bounds server-owned text, arrays, and limits", function () { var restored = autonomousModule.restoreStoredRun({ state: "running", id: "run", objective: "x".repeat(25000), successCriteria: new Array(20).fill("criterion"), continuationCount: 999, maxContinuations: 999, maxMinutes: 9999, permissionSnapshot: { permissionMode: "default", enabled: false }, outcome: { assessment: "continue", summary: "y".repeat(5000), evidence: new Array(30).fill("evidence") } }); assert.equal(restored.objective.length, 20000); assert.equal(restored.successCriteria.length, 10); assert.equal(restored.continuationCount, 100); assert.equal(restored.maxContinuations, 100); assert.equal(restored.maxMinutes, 1440); assert.equal(restored.outcome.summary.length, 4000); assert.equal(restored.outcome.evidence.length, 20); }); test("full-access transitions serialize and restore the original user state", async function () { var release; var first = new Promise(function (resolve) { release = resolve; }); var modes = []; var session = { localId: 1, permissionMode: "default", permissionModeBeforeFullAccess: null, queryInstance: { setPermissionMode: function (mode) { modes.push(mode); return modes.length === 1 ? first : Promise.resolve(); } } }; var service = fullAccessModule.createFullAccessService({ sm: { currentPermissionMode: "default", saveSessionFile: function () {}, broadcastSessionList: function () {} } }); var prior = service.snapshot(session); var enabling = service.setEnabled(session, true); var restoring = service.restore(session, prior); await tick(); assert.deepEqual(modes, ["default"]); release(); await enabling; await restoring; assert.equal(session.permissionMode, "default"); assert.equal(session.permissionModeBeforeFullAccess, null); assert.deepEqual(modes, ["default", "default"]); }); test("a failed full-access transition rolls the session back", async function () { var session = { localId: 2, permissionMode: "default", permissionModeBeforeFullAccess: null, queryInstance: { setPermissionMode: function () { return Promise.reject(new Error("adapter refused")); } } }; var service = fullAccessModule.createFullAccessService({ sm: { currentPermissionMode: "default", saveSessionFile: function () {}, broadcastSessionList: function () {} } }); await assert.rejects(service.setEnabled(session, true), /adapter refused/); assert.equal(session.permissionMode, "default"); assert.equal(session.permissionModeBeforeFullAccess, null); });