From 7c7a81f2e7746311b3ec086eb19a0e7db9173b2c Mon Sep 17 00:00:00 2001 From: willytop8 Date: Sun, 2 Aug 2026 15:56:52 -0500 Subject: [PATCH] feat(ux): surface goal lifecycle state Add explicit state and audit visibility, bounded transition notices, and race-safe terminal recovery.\n\nRefs #49 --- CHANGELOG.md | 6 + README.md | 22 +- index.d.ts | 17 + scripts/mutation-contract.mjs | 11 +- scripts/smoke-command-hook.mjs | 8 +- scripts/type-contract.mjs | 6 + scripts/verify.mjs | 10 +- src/goal-plugin.js | 854 ++++++++++++++++++++++++++++++--- test/goal-plugin.test.js | 732 +++++++++++++++++++++++++++- test/host-lifecycle.test.js | 8 +- 10 files changed, 1596 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f82b97c..dab5a3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Make `/goal status` add explicit `State:` and `Completion audit:` lines without changing its existing `Active goal:` header; make `/goal list` report `active`, `paused`, or `blocked` and preserve the reason for stopped focused goals. Completion audit reporting distinguishes the evidence gate, built-in independent verifier, and custom completion auditor. +- Add bounded, transition-only lifecycle notices through OpenCode's structured log and TUI toast, with independent `lifecycleMessages` and `lifecycleMessenger` controls. Delivery is advisory, does not create model turns, and does not announce routine idle/checkpoint activity. Completion/block uses the audit-result message when `auditMessages` is enabled and one lifecycle fallback only when it is disabled. +- Harden lifecycle persistence around the new feedback path: failed completion writes cannot resurrect an older goal over newer session state, blocked ledger events repair a lagging snapshot with their concrete reason, and clear operations disclose when neither snapshot nor ledger recorded the deletion durably. + ## 0.6.8 — 2026-08-02 - Serialize fresh-namespace migration-marker publication across concurrent processes so Windows does not reject competing first-start renames with `EPERM`. diff --git a/README.md b/README.md index ed654df..6408fd4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Compatibility: this plugin relies on experimental OpenCode hooks. Re-test agains - Guarded auto-continuation with turn, duration, token, no-progress, and no-tool-call limits. - Project-local restart recovery backed by persisted state and a bounded lifecycle ledger. - Evidence-gated completion with an optional independent, fail-closed verifier. +- Explicit `active`, `paused`, and `blocked` status plus transition-only lifecycle notices. - Canonical agent tools, collision-safe goal/verifier agents, multiple goals, and ordered goal sequences. This project is independently implemented for OpenCode. Product names used elsewhere identify their respective owners; no feature-parity or endorsement claim is implied. @@ -100,6 +101,8 @@ Check status: /goal status ``` +`/goal status` keeps its existing `Active goal:` heading and adds an explicit `State:` line: `active` while the goal can continue, `blocked` when the assistant recorded a concrete blocker, and `paused` for other retained stops such as user intervention, a safety limit, or an audit rejection. A `Completion audit:` line distinguishes the always-on evidence gate from an optional built-in independent verifier or custom completion auditor. + View lifecycle history and the latest checkpoint: ``` @@ -156,7 +159,7 @@ A session can hold more than one goal. `/goal ` replaces the focused /goal focus 1 ``` -`/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus ` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart. +`/goal list` shows numbered live goals (focused and backgrounded) plus achieved goals retained in the per-session archive. Each live entry includes its explicit `active`, `paused`, or `blocked` state; a stopped focused goal keeps its bounded stop or blocker reason visible. `/goal clear` intentionally removes live goals and saved status from these views; its terminal ledger entries remain available for crash-safe recovery decisions. `/goal focus ` switches the active goal, backgrounding the previous one. Focus is tracked per session and survives a restart. #### Ordered sequences @@ -336,6 +339,8 @@ Additional plugin-level options: - `ledgerMaxBytes` / `ledgerRetentionFiles` — bound the lifecycle ledger to 2 MiB per generation and three rotated generations by default. Set retention to `0` to discard the active ledger when it reaches the size ceiling. - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory. - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted. +- `lifecycleMessages` — announce applied goal-state transitions (default `true`). Set to `false` to disable lifecycle notices without disabling audit messages or persistence. +- `lifecycleMessenger(sessionID, text)` — route lifecycle notices to a custom sink instead of the default structured-log/TUI-toast path. ## Agent tools @@ -352,12 +357,23 @@ These operate on the same per-session multi-goal state as the command path: a to > Integration note: the tool execute-context shape (`ctx.sessionID`) and Zod argument definitions follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but live registration should still be confirmed against the exact OpenCode host used in production (see the smoke-test checklist). +## Lifecycle messages + +The plugin announces meaningful, applied state transitions such as goal creation, focus changes, pause/resume, recovery, ordered-goal promotion, and clearing. It does not emit a notice for every idle event, checkpoint, or continuation attempt. Messages are bounded and avoid dumping the full objective, evidence, or filesystem paths. + +By default, lifecycle notices go to OpenCode's structured log and to a TUI toast when that host capability is available. Provide a `lifecycleMessenger(sessionID, text)` plugin option to route them elsewhere, or set `lifecycleMessages: false` to disable them. Delivery is advisory: notices do not start an assistant turn or make any extra model call, and a log, toast, or custom-messenger failure does not undo the recorded state transition. + +Lifecycle notices and audit messages are separate controls. Lifecycle notices describe applied goal state; audit messages describe completion/block validation. When `auditMessages` is `true`, its audit-result message is the sole completion/block announcement. When `auditMessages` is `false` and `lifecycleMessages` is `true`, the lifecycle channel emits one terminal fallback instead. Other transitions follow `lifecycleMessages`; disabling one control does not disable the other. + ## Audit messages -When the assistant marks a goal complete or blocked, the plugin announces the audit instead of doing it silently: an audit-start message ("Auditing goal completion…") and an audit-result message ("completion accepted — goal archived" / "paused as blocked — …"). By default these are written to OpenCode's structured log and shown as a TUI toast when that client capability is available. Provide an `auditMessenger(sessionID, text)` plugin option to route them elsewhere, or set `auditMessages: false` to disable them. +When the assistant marks a goal complete or blocked, the plugin announces the audit instead of doing it silently: an audit-start message ("Auditing goal completion…") and an audit-result message ("completion accepted — goal archived" / "paused as blocked — …"). By default these are written to OpenCode's structured log and shown as a TUI toast when that client capability is available. Provide an `auditMessenger(sessionID, text)` plugin option to route them elsewhere, or set `auditMessages: false` to disable them. The audit-result message owns the terminal completion/block announcement while `auditMessages` is enabled, so the lifecycle channel does not duplicate it. + +Audit messages are visibility only; enabling them does not turn on the independent completion auditor. The evidence gate always applies. Independent verification is enabled only with `completionAudit: true` or a custom `auditor`. + ## Completion auditor (optional) -By default a `[goal:complete]` is accepted on the assistant's word. You can require an independent audit before a goal is archived: +Every `[goal:complete]` claim must first pass the local evidence gate described above. By default, that evidence gate is the only verifier. You can additionally require an independent audit before a goal is archived: - `completionAudit: true` — the plugin spawns an independent OpenCode child session to verify the completion against the goal and workspace. The auditor replies with `[audit:approved]` or `[audit:rejected]` (with a reason). - `auditor: async ({ goal, sessionID, latestText }) => ({ approved, reason })` — supply your own auditor function (takes precedence over `completionAudit`). diff --git a/index.d.ts b/index.d.ts index 05cad20..c15bfb7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -324,6 +324,23 @@ export interface GoalPluginOptions { */ auditorOptions?: CompletionAuditorOptions + /** + * Whether the plugin announces applied goal-state transitions such as + * creation, pause/resume, recovery, promotion, and clearing. Routine + * idle/checkpoint activity is not announced. Completion/block uses the + * audit-result channel when enabled and this lifecycle channel only as its + * disabled fallback. + * @default true + */ + lifecycleMessages?: boolean + + /** + * Custom sink for bounded lifecycle notices. Defaults to routing through + * OpenCode's structured log (`client.app.log`) and TUI toast when those host + * APIs are available. Delivery is advisory and does not make model calls. + */ + lifecycleMessenger?: (sessionID: string, text: string) => Promise | void + /** * Whether the plugin announces completion/blocked audits (an * audit-start and an audit-result message) instead of running silently. diff --git a/scripts/mutation-contract.mjs b/scripts/mutation-contract.mjs index bdcecef..290dfed 100644 --- a/scripts/mutation-contract.mjs +++ b/scripts/mutation-contract.mjs @@ -38,8 +38,15 @@ const mutants = [ { name: "terminal completion requires durable storage", file: "src/goal-plugin.js", - from: "if (durable === false) {\n restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })", - to: "if (false) {\n restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })", + from: 'const durable = await persistFinal(sessionID, "completion", ledgerDurable)\n if (durable === false) {', + to: 'const durable = await persistFinal(sessionID, "completion", ledgerDurable)\n if (false) {', + test: "test/goal-plugin.test.js", + }, + { + name: "terminal rollback detects same-session mutation without cross-session coupling", + file: "src/goal-plugin.js", + from: "if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false", + to: "if (false) return false", test: "test/goal-plugin.test.js", }, { diff --git a/scripts/smoke-command-hook.mjs b/scripts/smoke-command-hook.mjs index 8779036..897aba3 100644 --- a/scripts/smoke-command-hook.mjs +++ b/scripts/smoke-command-hook.mjs @@ -87,10 +87,14 @@ assert.match(await runGoalCommand("status"), /No active goal/) assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/) const activeStatus = await runGoalCommand("status") assert.match(activeStatus, /Active goal: ship a smoke test/) -assert.doesNotMatch(activeStatus, /State: Paused/) +assert.match(activeStatus, /State: active/) +assert.match(activeStatus, /Completion audit: evidence gate only \(independent verifier off\)/) assert.match(await runGoalCommand("clear"), /Goal cleared/) assert.match(await runGoalCommand("status"), /No active goal/) assert.equal(promptCalls.length, 0) -assert.equal(logCalls.length, 0) +assert.deepEqual(logCalls.map((entry) => entry.body.extra.kind), ["goal-lifecycle", "goal-lifecycle"]) +assert.match(logCalls[0].body.message, /Goal (?:active|started)/i) +assert.match(logCalls[1].body.message, /Goal cleared/i) +assert.ok(logCalls.every((entry) => !entry.body.message.includes("ship a smoke test"))) console.log("opencode-goal-plugin command hook smoke passed") diff --git a/scripts/type-contract.mjs b/scripts/type-contract.mjs index 740a419..a5ba291 100644 --- a/scripts/type-contract.mjs +++ b/scripts/type-contract.mjs @@ -65,6 +65,8 @@ const options = { auditorOptions: { timeoutMs: 5_000, failurePolicy: "reject" }, auditMessages: true, auditMessenger: (_sessionID, _text) => {}, + lifecycleMessages: true, + lifecycleMessenger: (_sessionID, _text) => {}, auditor: async ({ goal, sessionID, latestText }: CompletionAuditContext) => { const mode: "normal" | "ordered" = goal.mode return { @@ -74,6 +76,10 @@ const options = { }, } satisfies GoalPluginOptions +// @ts-expect-error lifecycleMessages must be boolean +const invalidLifecycleMessages: GoalPluginOptions = { lifecycleMessages: "yes" } +void invalidLifecycleMessages + const hooks: GoalPluginHooks = await GoalPlugin({ client: {}, directory: "/tmp" }, options) hooks.config({}) hooks.event({}) diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 20c1afd..e8e746c 100755 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -146,7 +146,8 @@ await check("/goal set works", async () => { assert.match(text, /New active goal: verify the installation/) const statusText = await runGoalCommand("status") assert.match(statusText, /Active goal: verify the installation/) - assert.doesNotMatch(statusText, /State: Paused/) + assert.match(statusText, /State: active/) + assert.match(statusText, /Completion audit: evidence gate only \(independent verifier off\)/) }) await check("no model calls were made during verification", () => { @@ -156,6 +157,13 @@ await check("no model calls were made during verification", () => { // Clean up the goal created above so this script has no side effects. await runGoalCommand("clear") +await check("lifecycle transitions are visible without leaking objective text", () => { + assert.deepEqual(logCalls.map((entry) => entry.body.extra.kind), ["goal-lifecycle", "goal-lifecycle"]) + assert.match(logCalls[0].body.message, /Goal (?:active|started)/i) + assert.match(logCalls[1].body.message, /Goal cleared/i) + assert.ok(logCalls.every((entry) => !entry.body.message.includes("verify the installation"))) +}) + console.log() const failed = results.filter((r) => !r.ok) diff --git a/src/goal-plugin.js b/src/goal-plugin.js index 0e1cec5..d8d3f6b 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -95,6 +95,7 @@ function createRuntimeState() { sessionArchive: new Map(), sessionOrdered: new Set(), lastGoalResults: new Map(), + sessionMutationVersions: new Map(), seenTokens: new Map(), seenUsage: new Map(), seenOutputTokens: new Map(), @@ -165,6 +166,7 @@ const sessionArchive = runtimeCollection("sessionArchive") const sessionOrdered = runtimeCollection("sessionOrdered") const MAX_ARCHIVED_PER_SESSION = 10 const lastGoalResults = runtimeCollection("lastGoalResults") +const sessionMutationVersions = runtimeCollection("sessionMutationVersions") const seenTokens = runtimeCollection("seenTokens") const seenUsage = runtimeCollection("seenUsage") const seenOutputTokens = runtimeCollection("seenOutputTokens") @@ -490,6 +492,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) { options: goal.options, stopped: goal.stopped, stopReason: goal.stopReason, + blockedReason: goal.blockedReason, ordered: sessionOrdered.has(goal.sessionID), }, type, @@ -504,6 +507,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) { function pushHistory(goal, type, detail, timestamp = Date.now()) { const entry = makeHistoryEntry(type, detail, timestamp) goal.history = [...(goal.history || []), entry].slice(-MAX_HISTORY_ENTRIES) + markSessionMutation(goal.sessionID) return emitLedgerEvent(goal, entry.type, entry.detail, entry.timestamp) } @@ -638,6 +642,7 @@ function reconstructGoalsFromLedger(entries) { const condition = [...events].reverse().find((event) => typeof event.condition === "string" && event.condition.trim())?.condition?.trim() if (!condition) continue const snapshot = [...events].reverse().find((event) => isPlainObject(event.snapshot))?.snapshot || {} + const latestBlocked = [...events].reverse().find((event) => event.type === "blocked") const history = events .map((event) => @@ -659,6 +664,12 @@ function reconstructGoalsFromLedger(entries) { options: isPlainObject(snapshot.options) ? snapshot.options : {}, stopped: snapshot.stopped === true, stopReason: typeof snapshot.stopReason === "string" ? snapshot.stopReason : "", + blockedReason: + typeof snapshot.blockedReason === "string" + ? snapshot.blockedReason + : snapshot.stopReason === "blocked" && typeof latestBlocked?.detail === "string" + ? latestBlocked.detail + : "", ordered: snapshot.ordered === true || events.some((event) => /ordered goal/i.test(String(event.detail || ""))), startedAt: normalizeTimestamp(events[0]?.ts), history, @@ -675,9 +686,19 @@ function recordCheckpoint(goal, text, timestamp = Date.now()) { const checkpoint = { summary, timestamp } goal.lastCheckpoint = checkpoint goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS) + markSessionMutation(goal.sessionID) } -function formatStatus(goal, commandName = "goal") { +function goalDisplayState(goal) { + if (!goal?.stopped) return "active" + return goal.stopReason === "blocked" ? "blocked" : "paused" +} + +function formatStatus( + goal, + commandName = "goal", + completionAuditLabel = "evidence gate only (independent verifier off)", +) { const elapsed = Math.round((Date.now() - goal.startedAt) / 1000) const lastProgress = goal.lastProgressAt > 0 @@ -688,6 +709,8 @@ function formatStatus(goal, commandName = "goal") { : "none yet" const lines = [ `Active goal: ${goal.condition}`, + `State: ${goalDisplayState(goal)}`, + `Completion audit: ${completionAuditLabel}`, ] if (goal.successCriteria) lines.push(`Success criteria: ${goal.successCriteria}`) if (goal.constraints) lines.push(`Constraints: ${goal.constraints}`) @@ -772,8 +795,16 @@ function sessionGoalMap(sessionID) { return map } +function markSessionMutation(sessionID) { + if (!sessionID) return 0 + const next = (sessionMutationVersions.get(sessionID) || 0) + 1 + sessionMutationVersions.set(sessionID, next) + return next +} + function registerSessionGoal(goal) { sessionGoalMap(goal.sessionID).set(goal.goalId, goal) + markSessionMutation(goal.sessionID) } function listSessionGoals(sessionID) { @@ -796,12 +827,13 @@ function setBoundedMessageValue(map, messageID, value) { function removeSessionGoal(sessionID, goalId) { const map = sessionGoals.get(sessionID) if (!map) return - map.delete(goalId) + if (map.delete(goalId)) markSessionMutation(sessionID) if (map.size === 0) sessionGoals.delete(sessionID) } function focusGoal(sessionID, goal) { goalStates.set(sessionID, goal) + markSessionMutation(sessionID) } function pauseGoalClock(goal, timestamp = Date.now()) { @@ -858,6 +890,10 @@ function cleanupGoal(sessionID) { } goalStates.delete(sessionID) activeContinues.delete(sessionID) + // Increment even when no focused goal remains. A concurrent clear of a + // provisional completion is otherwise indistinguishable from unrelated + // global result-retention pruning while its terminal write is in flight. + markSessionMutation(sessionID) } function clearRuntimeState() { @@ -868,6 +904,7 @@ function clearRuntimeState() { sessionArchive.clear() sessionOrdered.clear() lastGoalResults.clear() + sessionMutationVersions.clear() seenTokens.clear() seenUsage.clear() seenOutputTokens.clear() @@ -908,6 +945,7 @@ function clearSessionRuntimeState( runtime.sessionStatuses.delete(sessionID) if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID) runtime.passiveSessions.delete(sessionID) + markSessionMutation(sessionID) if (!preserveCommandSecurity) { runtime.pendingCommandTurns.delete(sessionID) runtime.activeCommandTurns.delete(sessionID) @@ -966,16 +1004,60 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "") lastGoalResults.delete(sessionID) lastGoalResults.set(sessionID, result) // Keep a per-session archive so completed goals stay readable via /goal list. - archiveSessionResult(sessionID, { ...result }) + const archivedResult = { ...result } + archiveSessionResult(sessionID, archivedResult) pruneGoalResults(goal.options) + markSessionMutation(sessionID) + return { lastResult: result, archivedResult } } -function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = false } = {}) { - lastGoalResults.delete(sessionID) +function captureFocusedGoalSnapshot(sessionID) { + const goal = goalStates.get(sessionID) || null + return { + goal, + serialized: goal ? JSON.stringify(serializeGoal(goal)) : "", + mutationVersion: sessionMutationVersions.get(sessionID) || 0, + } +} + +function focusedGoalSnapshotIsCurrent(sessionID, snapshot) { + const current = goalStates.get(sessionID) || null + if (current !== snapshot?.goal) return false + if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false + return !current || JSON.stringify(serializeGoal(current)) === snapshot.serialized +} + +function restoreAfterTerminalPersistenceFailure( + sessionID, + goal, + { ordered = false, expectedCurrentSnapshot, expectedResult } = {}, +) { + // A terminal write can yield while another command replaces, edits, pauses, + // resumes, clears, or advances the session. Never roll the old goal back over + // that newer state. The per-session mutation version catches a concurrent + // clear even when both the expected and current focused goal are null, while + // remaining unaffected by result-retention pruning in a different session. + const expectedLastResult = expectedResult?.lastResult || expectedResult + const expectedArchivedResult = expectedResult?.archivedResult + const canRestore = + !expectedCurrentSnapshot || + focusedGoalSnapshotIsCurrent(sessionID, expectedCurrentSnapshot) + + // Remove only this failed provisional completion record. A newer concurrent + // result/archive entry belongs to the newer operation and must survive. + if (expectedLastResult && lastGoalResults.get(sessionID) === expectedLastResult) { + lastGoalResults.delete(sessionID) + } const archived = sessionArchive.get(sessionID) || [] - if (archived.length) { + if (expectedArchivedResult) { + const retained = archived.filter((entry) => entry !== expectedArchivedResult) + if (retained.length) sessionArchive.set(sessionID, retained) + else sessionArchive.delete(sessionID) + } else if (archived.length) { sessionArchive.set(sessionID, archived.slice(0, -1)) } + + if (!canRestore) return false const prematurelyPromoted = goalStates.get(sessionID) if (prematurelyPromoted && prematurelyPromoted.goalId !== goal.goalId) { prematurelyPromoted.stopped = true @@ -990,6 +1072,7 @@ function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = fal goal.lastStatus = "Terminal state could not be persisted. Goal kept paused; fix storage and retry." registerSessionGoal(goal) focusGoal(sessionID, goal) + return true } function resetGoalBudget(goal) { @@ -1543,15 +1626,15 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) { } // After applyParsedStateFile loads goals into goalStates, check the ledger for -// terminal events. If a goal has a "completed" or "cleared" entry in the ledger -// but still appears active in the state file (because the state write failed -// after the terminal ledger write), remove it so it is not re-driven. +// state transitions that landed after the snapshot. Completed/cleared goals are +// removed so they cannot be re-driven, while a newer blocked event is overlaid +// so its state and concrete reason survive a failed snapshot write. async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) { const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, { maxBytes: persistenceOptions.ledgerMaxBytes, retentionFiles: persistenceOptions.ledgerRetentionFiles, }) - if (!entries.length) return + if (!entries.length) return { removed: 0, blocked: 0 } const terminalGoals = new Set() for (const entry of entries) { @@ -1564,16 +1647,69 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`) } } - if (!terminalGoals.size) return - let removed = 0 + let blocked = 0 for (const [sessionID, goals] of sessionGoals.entries()) { if (onlySessionID && sessionID !== onlySessionID) continue for (const goal of [...goals.values()]) { - if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue - removeSessionGoal(sessionID, goal.goalId) - if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID) - removed += 1 + const key = `${sessionID}\0${goal.goalId}` + if (terminalGoals.has(key)) { + removeSessionGoal(sessionID, goal.goalId) + if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID) + removed += 1 + continue + } + + const persistedHistory = (goal.history || []).filter((event) => event.type !== "recovered") + const latestPersistedTimestamp = persistedHistory.reduce( + (latest, event) => Math.max(latest, normalizeTimestamp(event.timestamp, 0)), + 0, + ) + let latestLedgerState = null + let latestLedgerTimestamp = -1 + for (const entry of entries) { + if (entry.sessionID !== sessionID || entry.goalId !== goal.goalId || entry.type === "recovered") continue + const timestamp = normalizeTimestamp(entry.ts, 0) + if (timestamp < latestPersistedTimestamp) continue + const detail = summarizeText(entry.detail, 400) + const alreadyApplied = persistedHistory.some( + (event) => + event.type === entry.type && + normalizeTimestamp(event.timestamp, 0) === timestamp && + event.detail === detail, + ) + if (timestamp >= latestLedgerTimestamp) { + latestLedgerState = { entry, alreadyApplied } + latestLedgerTimestamp = timestamp + } + } + if ( + latestLedgerState?.alreadyApplied || + latestLedgerState?.entry?.type !== "blocked" || + latestLedgerState.entry.snapshot?.stopped !== true || + latestLedgerState.entry.snapshot?.stopReason !== "blocked" + ) continue + + const reason = summarizeText( + latestLedgerState.entry.snapshot?.blockedReason || latestLedgerState.entry.detail, + MAX_GOAL_BLOCKER_LENGTH, + ) + if (!reason) continue + goal.stopped = true + goal.stopReason = "blocked" + goal.blockedReason = reason + goal.lastStatus = "Recovered blocked goal state from the lifecycle ledger after the saved snapshot lagged behind." + goal.continuationClaim = null + goal.history = [ + ...(goal.history || []), + makeHistoryEntry( + "blocked", + reason, + normalizeTimestamp(latestLedgerState.entry.ts), + ), + ].slice(-MAX_HISTORY_ENTRIES) + pauseGoalClock(goal) + blocked += 1 } if (!goalStates.has(sessionID) && sessionOrdered.has(sessionID) && goals.size > 0) { promoteNextOrderedGoal(sessionID) @@ -1585,6 +1721,13 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe `Ledger cross-check: removed ${removed} goal(s) whose terminal state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`, ) } + if (blocked > 0) { + await logPluginError( + client, + `Ledger cross-check: restored ${blocked} blocked goal(s) whose blocked state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`, + ) + } + return { removed, blocked } } async function pathExists(path) { @@ -1810,8 +1953,8 @@ async function loadPersistedSessionState(persistence, client, sessionID) { const state = await readPersistedStateFile(persistence.stateFilePath, client) if (state.status === "loaded") { await applyParsedStateFile(state.raw, client, sessionID) - await reconcileLoadedStateWithLedger(persistence, client, sessionID) - return "loaded" + const reconciliation = await reconcileLoadedStateWithLedger(persistence, client, sessionID) + return reconciliation.blocked > 0 ? "reconciled-blocked" : "loaded" } const recovered = await reconstructFromLedger(persistence, client, sessionID) if (state.status === "invalid" && recovered === "reconstructed") { @@ -2802,6 +2945,8 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = " } const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"]) +const AGENT_COMPLETE_SUCCESS = "Goal marked complete and archived." +const AGENT_BLOCK_SUCCESS = "Goal marked blocked." // Programmatic equivalents of the /goal command, exposed to the agent as tools // Each handler operates on a session id and mutates @@ -2810,14 +2955,24 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed // result. Goal creation/replacement routes through the multi-goal registry // (buildGoalState + registerSessionGoal + focusGoal) exactly like the command // path, so tool-created goals persist and are driven by the idle handler. -function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState = null, completionAuditor = null, commandName = "goal" }) { +function buildAgentToolHandlers({ + defaultGoalOptions, + persist, + persistTerminalState = null, + completionAuditor = null, + completionAuditLabel = "evidence gate only (independent verifier off)", + announceAudit = async () => {}, + auditMessagesEnabled = false, + announceLifecycle = () => {}, + commandName = "goal", +}) { // Use persistTerminalState (which logs on failure) for terminal operations when // available; fall back to plain persist for callers that don't wire it up (e.g. // tests using buildAgentToolHandlers directly). const persistFinal = persistTerminalState || persist async function getGoal(sessionID) { const goal = goalStates.get(sessionID) - if (goal) return formatStatus(goal) + if (goal) return formatStatus(goal, commandName, completionAuditLabel) const lastResult = lastGoalResults.get(sessionID) if (lastResult) return formatGoalResult(lastResult) return "No active goal." @@ -2887,12 +3042,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt // Mirror the `/goal ` replace path: discard the focused goal and // its saved result, drop any ordered sequence, then register + focus the new // goal so it persists and the idle handler drives it. + const replacedGoal = goalStates.get(sessionID) sessionOrdered.delete(sessionID) cleanupGoal(sessionID) lastGoalResults.delete(sessionID) registerSessionGoal(goal) focusGoal(sessionID, goal) await persist(sessionID) + announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", { + goal, + transition: replacedGoal ? "replaced-active" : "active", + expectedState: "active", + }) // Escape in the tool result only: goal.condition is stored raw so callers // that build XML (buildGoalBlock, buildContinueMessage) can apply escaping // themselves. Escaping here prevents XML metacharacters in user-supplied @@ -2920,6 +3081,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt } const messages = [] + let lifecycleNotice = null if (typeof args.objective === "string" && args.objective.trim()) { if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) { @@ -2938,6 +3100,13 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt goal.lastStatus = "Goal objective updated." pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`) messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`) + lifecycleNotice = { + text: `Goal updated; state remains ${goalDisplayState(goal)}.`, + transition: "updated", + reason: goalDisplayState(goal), + expectedState: goalDisplayState(goal), + expectedStopReason: goal.stopped ? goal.stopReason : "", + } } if (args.status !== undefined) { @@ -2950,13 +3119,24 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt if (!evidence) return "Completion evidence is required before a goal can be archived." if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH) return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.` + const auditedGoalID = goal.goalId + const auditedRunID = goal.runId + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + "Auditing goal completion: checking submitted evidence before archiving.", + ) + const goalAfterAnnouncement = activeGoal(sessionID, auditedGoalID, auditedRunID) + if (!goalAfterAnnouncement) { + return "Completion audit finished after the goal changed; completion was not recorded." + } + goal = goalAfterAnnouncement + } // If a completion auditor is configured, run it before archiving so the // agent tool path has the same integrity gate as the [goal:complete] marker // path. Without this, an autonomous agent could bypass the auditor by // calling update_goal({status:"complete"}) instead of using the marker. if (completionAuditor) { - const auditedGoalID = goal.goalId - const auditedRunID = goal.runId let verdict try { verdict = await completionAuditor({ goal, sessionID, latestText: evidence }) @@ -2975,6 +3155,25 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.` pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`) await persist(sessionID) + const rejectedGoalAfterPersist = currentGoal(sessionID, auditedGoalID, auditedRunID) + if ( + rejectedGoalAfterPersist !== goal || + !goal.stopped || + goal.stopReason !== "audit rejected" + ) { + return "Completion audit was rejected, but the goal changed while that state was persisted; current state was left untouched." + } + if (auditMessagesEnabled) { + await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`) + } else { + announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", { + goal, + transition: "audit-rejected", + reason, + expectedState: "paused", + expectedStopReason: "audit rejected", + }) + } return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.` } } @@ -2985,16 +3184,72 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.", ) const ordered = sessionOrdered.has(sessionID) - rememberGoalResult(sessionID, goal, "achieved", "", evidence) + const completedResult = rememberGoalResult(sessionID, goal, "achieved", "", evidence) cleanupGoal(sessionID) // Advance an ordered sequence just like the marker path does. - if (ordered) promoteNextOrderedGoal(sessionID) + const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null + const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID) const durable = await persistFinal(sessionID, "completion", ledgerDurable) if (durable === false) { - restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered }) - return "Completion verified, but terminal state could not be persisted. Goal remains paused." + const restored = restoreAfterTerminalPersistenceFailure(sessionID, goal, { + ordered, + expectedCurrentSnapshot: postCompletionSnapshot, + expectedResult: completedResult, + }) + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + restored + ? "Audit result: completion verified, but storage failed; goal remains paused and was not archived." + : "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.", + ) + } else { + announceLifecycle( + sessionID, + restored + ? "Goal paused — completion could not be recorded durably." + : "Previous goal completion could not be confirmed durably after goal state changed.", + restored + ? { + goal, + transition: "terminal-persistence-failed", + reason: goal.stopReason, + expectedState: "paused", + expectedStopReason: "terminal persistence failed", + } + : { + transition: "terminal-persistence-raced", + requireCurrent: false, + }, + ) + } + return restored + ? "Completion verified, but terminal state could not be persisted. Goal remains paused." + : "Completion verified, but its terminal state could not be persisted before the goal changed. Current state was left untouched." } - return "Goal marked complete and archived." + const activePromoted = promoted + ? activeGoal(sessionID, promoted.goalId, promoted.runId) + : null + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + activePromoted + ? "Audit result: completion accepted — goal archived as achieved; next ordered goal active." + : "Audit result: completion accepted — goal archived as achieved.", + ) + } else { + announceLifecycle( + sessionID, + activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.", + { + goal: activePromoted || goal, + transition: activePromoted ? "achieved-promoted" : "achieved", + requireCurrent: Boolean(activePromoted), + expectedState: activePromoted ? "active" : "", + }, + ) + } + return AGENT_COMPLETE_SUCCESS } if (status === "blocked") { const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : "" @@ -3002,18 +3257,80 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed." if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH) return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.` + const blockedGoalID = goal.goalId + const blockedRunID = goal.runId + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + "Auditing goal blocker: checking the submitted blocker before pausing.", + ) + const goalAfterAnnouncement = activeGoal(sessionID, blockedGoalID, blockedRunID) + if (!goalAfterAnnouncement) { + return "Blocker audit finished after the goal changed; blocked state was not recorded." + } + goal = goalAfterAnnouncement + } goal.blockedReason = blockerText goal.stopped = true goal.stopReason = "blocked" goal.lastStatus = "Assistant reported blocked." - pushHistory(goal, "blocked", goal.blockedReason) - messages.push("Goal marked blocked.") + const ledgerDurable = pushHistory(goal, "blocked", goal.blockedReason) + messages.push(AGENT_BLOCK_SUCCESS) + const durable = await persistFinal(sessionID, "blocked", ledgerDurable) + const blockedGoalAfterPersist = currentGoal(sessionID, blockedGoalID, blockedRunID) + if (blockedGoalAfterPersist !== goal || goal.stopReason !== "blocked") { + return "Blocked state changed while persistence completed; blocked state was not reported." + } + if (durable === false) { + goal.stopReason = "terminal persistence failed" + goal.lastStatus = "Blocked state could not be persisted; goal remains paused." + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + "Audit result: blocker recognized, but storage failed; goal remains paused.", + ) + } else { + announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", { + goal, + transition: "terminal-persistence-failed", + expectedState: "paused", + expectedStopReason: "terminal persistence failed", + }) + } + return "Blocker recognized, but terminal state could not be persisted. Goal remains paused." + } + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + `Audit result: goal paused as blocked — ${summarizeText(blockerText, 160)}. Run /${commandName} resume after addressing it.`, + ) + } else { + announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, { + goal, + transition: "blocked", + expectedState: "blocked", + expectedStopReason: "blocked", + }) + } + return messages.join(" ") } else if (status === "paused") { - goal.stopped = true - goal.stopReason = "paused" - goal.lastStatus = "Goal paused." - pushHistory(goal, "paused", "Paused via agent tool.") - messages.push("Goal paused.") + if (goal.stopped && goal.stopReason === "paused") { + if (!messages.length) return "Goal is already paused." + messages.push("Goal is already paused.") + } else { + goal.stopped = true + goal.stopReason = "paused" + goal.lastStatus = "Goal paused." + pushHistory(goal, "paused", "Paused via agent tool.") + messages.push("Goal paused.") + lifecycleNotice = { + text: "Goal paused.", + transition: "paused", + reason: goal.stopReason, + expectedState: "paused", + expectedStopReason: "paused", + } + } } else if (status === "resumed") { if (!goal.stopped) return "Goal is already running. Pause or stop it first if you want to reset the budget window." @@ -3027,6 +3344,11 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt goal.lastStatus = "Goal resumed with a fresh local budget." pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.") messages.push("Goal resumed with fresh limits.") + lifecycleNotice = { + text: "Goal resumed with fresh limits.", + transition: "resumed", + expectedState: "active", + } } } @@ -3034,6 +3356,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt return "Nothing to update. Provide `objective` and/or `status`." } await persist(sessionID) + if (lifecycleNotice) { + announceLifecycle(sessionID, lifecycleNotice.text, { + goal, + transition: lifecycleNotice.transition, + reason: lifecycleNotice.reason, + expectedState: lifecycleNotice.expectedState, + expectedStopReason: lifecycleNotice.expectedStopReason, + }) + } return messages.join(" ") } @@ -3042,15 +3373,33 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt // focused goal + result. Without sessionGoals.delete, background goals added via // `/goal add` survive clear and resurrect as the focused goal on restart. // Record the clear in the ledger before cleanupGoal removes the goal object. - for (const goal of listSessionGoals(sessionID)) { - pushHistory(goal, "cleared", "Cleared via agent tool.") - } + const goals = listSessionGoals(sessionID) + const clearedGoal = goalStates.get(sessionID) || goals[0] || null + const hadState = goals.length > 0 || lastGoalResults.has(sessionID) + const ledgerDurable = + goals.length > 0 && + goals.map((goal) => pushHistory(goal, "cleared", "Cleared via agent tool.")).every(Boolean) sessionOrdered.delete(sessionID) sessionGoals.delete(sessionID) cleanupGoal(sessionID) lastGoalResults.delete(sessionID) - await persistFinal(sessionID, "clear") - return "Goal cleared." + const durable = await persistFinal(sessionID, "clear", ledgerDurable) + const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0 + if (hadState && clearStillCurrent) { + announceLifecycle(sessionID, durable === false + ? "Goal cleared in memory, but storage failed; it may reappear after restart." + : "Goal cleared.", { + goal: clearedGoal, + transition: durable === false ? "clear-persistence-failed" : "cleared", + requireCurrent: false, + }) + } + if (!clearStillCurrent) { + return "Clear persistence finished after goal state changed; current state was left untouched." + } + return durable === false + ? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart." + : "Goal cleared." } return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal } @@ -3175,8 +3524,22 @@ function buildAgentTools( return goalToolFailure("already_running", "Goal is already running.") } const message = await handlers.updateGoal(sessionID, args) - if (args.status === "complete" && currentGoal(sessionID)) { - return goalToolFailure("completion_rejected", message) + if (args.status === "complete") { + if ( + message !== AGENT_COMPLETE_SUCCESS || + currentGoal(sessionID, before.goalId, before.runId) + ) { + return goalToolFailure("completion_rejected", message) + } + } + if (args.status === "blocked") { + const after = currentGoal(sessionID, before.goalId, before.runId) + if (after !== before) { + return goalToolFailure("goal_changed", message) + } + if (message !== AGENT_BLOCK_SUCCESS || !after.stopped || after.stopReason !== "blocked") { + return goalToolFailure("block_rejected", message) + } } return goalToolSuccess(message) }, @@ -3296,8 +3659,14 @@ function formatGoalList(sessionID, commandName = "goal") { lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`) goals.forEach((goal, index) => { const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle" - const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : "" - lines.push(`${index + 1}. [${marker}] ${goal.condition}${state}`) + const state = goalDisplayState(goal) + const reason = state === "blocked" + ? goal.blockedReason || goal.stopReason + : goal.stopped + ? goal.stopReason + : "" + const reasonText = reason ? ` (${summarizeText(reason, 160)})` : "" + lines.push(`${index + 1}. [${marker}] ${goal.condition} — state: ${state}${reasonText}`) }) lines.push(`Switch with \`/${commandName} focus \`.`) } else { @@ -3343,6 +3712,37 @@ async function defaultAuditMessenger(client, sessionID, text) { } } +// High-signal lifecycle feedback uses the same non-blocking host surfaces as +// audit notices, but remains a separate channel so callers can configure each +// independently. Messages are normalized and bounded before they reach either +// host API; goal objectives, evidence, and workspace paths are deliberately +// excluded by transition call sites. +async function defaultLifecycleMessenger(client, sessionID, text) { + const message = summarizeText(text, 500) + const warning = /\b(?:paused|blocked|recovered|failed|passive)\b/i.test(message) + const success = /\b(?:achieved|completed)\b/i.test(message) + if (client?.app?.log) { + dispatchAdvisoryHostCall(() => client.app.log({ + body: { + service: "opencode-goal-plugin", + level: warning ? "warn" : "info", + message, + extra: { sessionID, kind: "goal-lifecycle" }, + }, + })) + } + if (client?.tui?.showToast) { + dispatchAdvisoryHostCall(() => client.tui.showToast({ + body: { + title: "Goal workflow", + message, + variant: warning ? "warning" : success ? "success" : "info", + duration: 6000, + }, + })) + } +} + // Completion auditor. When an auditor is configured, a [goal:complete] // is verified before the goal is archived: an approved verdict archives it, a // rejected verdict restores the goal (pauses it with the reason) instead of @@ -3499,6 +3899,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) return persistence.persistChain } + const lifecycleMessagesEnabled = pluginOptions.lifecycleMessages !== false + const lifecycleMessenger = + typeof pluginOptions.lifecycleMessenger === "function" + ? pluginOptions.lifecycleMessenger + : (sessionID, text) => defaultLifecycleMessenger(client, sessionID, text) + const announceLifecycle = ( + sessionID, + text, + { + goal, + transition = "state", + reason = "", + requireCurrent = true, + expectedState = "", + expectedStopReason = "", + } = {}, + ) => { + if (!lifecycleMessagesEnabled || !sessionID) return false + if (requireCurrent && goal) { + const current = goalStates.get(sessionID) + if (current !== goal) return false + if (expectedState && goalDisplayState(current) !== expectedState) return false + if (expectedStopReason && current.stopReason !== expectedStopReason) return false + } + const message = summarizeText(text, 500) + if (!message) return false + dispatchAdvisoryHostCall( + () => lifecycleMessenger(sessionID, message), + (error) => { + void logPluginError(client, "Failed to deliver goal lifecycle message", error).catch(() => {}) + }, + ) + return true + } + const passiveLoadResult = (entry) => ({ kind: "passive", code: SESSION_OWNED_ELSEWHERE, @@ -3596,7 +4031,40 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const status = await loadPersistedSessionState(persistence, client, sessionID) if (runtime.disposed) return releaseDisposedSession() pruneGoalResults(defaultGoalOptions) - if (status === "loaded" || status === "missing" || status === "reconstructed") await persist(sessionID) + if ( + status === "loaded" || + status === "missing" || + status === "reconstructed" || + status === "reconciled-blocked" + ) await persist(sessionID) + const recoveredGoal = goalStates.get(sessionID) + if (recoveredGoal?.stopped && recoveredGoal.stopReason === "recovered after restart") { + announceLifecycle(sessionID, `Goal recovered and paused. Run /${commandName} status, then /${commandName} resume when ready.`, { + goal: recoveredGoal, + transition: "recovered-paused", + reason: recoveredGoal.stopReason, + expectedState: "paused", + expectedStopReason: "recovered after restart", + }) + } else if ( + status === "reconciled-blocked" && + recoveredGoal?.stopped && + recoveredGoal.stopReason === "blocked" + ) { + announceLifecycle(sessionID, `Goal recovered as blocked. Run /${commandName} status for the reason.`, { + goal: recoveredGoal, + transition: "recovered-blocked", + reason: recoveredGoal.blockedReason, + expectedState: "blocked", + expectedStopReason: "blocked", + }) + } else if (recoveredGoal?.lastStatus === "Promoted as the next ordered goal.") { + announceLifecycle(sessionID, "Goal state recovered; the next ordered goal is active.", { + goal: recoveredGoal, + transition: "recovered-promoted", + expectedState: "active", + }) + } if (runtime.disposed) return releaseDisposedSession() return ACTIVE_PERSISTENCE_OWNED } catch (error) { @@ -3681,6 +4149,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) reason: "owned verifier agent registration was not confirmed", }) : null + const completionAuditLabel = + typeof pluginOptions.auditor === "function" + ? "custom completion auditor" + : pluginOptions.completionAudit + ? "built-in independent verifier" + : "evidence gate only (independent verifier off)" clearRuntimeState() @@ -3689,6 +4163,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) persist, persistTerminalState, completionAuditor, + completionAuditLabel, + announceAudit, + auditMessagesEnabled, + announceLifecycle, commandName, }) @@ -3714,6 +4192,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) ) => { const goal = goalStates.get(sessionID) if (!goal) return false + if (goal.stopped && goal.stopReason === reason) return false currentRuntime().continuationControllers.get(sessionID)?.abort() goal.stopped = true goal.stopReason = reason @@ -3722,6 +4201,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) pushHistory(goal, "paused", history) activeContinues.delete(sessionID) await persist(sessionID) + announceLifecycle(sessionID, `Goal paused — ${summarizeText(reason, 160)}.`, { + goal, + transition: "paused", + reason, + expectedState: "paused", + expectedStopReason: reason, + }) if (abortAccepted) await abortAcceptedContinuation(sessionID) return true } @@ -3798,6 +4284,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) goal.stopReason = "continuation claim persistence failed" goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.` pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.") + announceLifecycle(sessionID, "Goal paused — continuation state could not be persisted.", { + goal, + transition: "continuation-persistence-failed", + reason: goal.stopReason, + expectedState: "paused", + expectedStopReason: "continuation claim persistence failed", + }) return null } return goal @@ -3992,7 +4485,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) replaceCommandOutputText( output, goal - ? formatStatus(goal, commandName) + ? formatStatus(goal, commandName, completionAuditLabel) : lastResult ? formatGoalResult(lastResult) : `No active goal. Set one with \`/${commandName} \`.`, @@ -4033,15 +4526,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // sessionGoals.delete clears ALL backgrounded goals so they do not // resurrect as the focused goal on restart (cleanupGoal only removes the // focused one; background goals from `/goal add` would survive otherwise). - for (const goal of listSessionGoals(sessionID)) { - pushHistory(goal, "cleared", "User cleared the goal.") - } + const goals = listSessionGoals(sessionID) + const clearedGoal = goalStates.get(sessionID) || goals[0] || null + const hadState = goals.length > 0 || lastGoalResults.has(sessionID) + const ledgerDurable = + goals.length > 0 && + goals.map((goal) => pushHistory(goal, "cleared", "User cleared the goal.")).every(Boolean) sessionOrdered.delete(sessionID) sessionGoals.delete(sessionID) cleanupGoal(sessionID) lastGoalResults.delete(sessionID) - await persist(sessionID) - replaceCommandOutputText(output, "Goal cleared.") + const durable = await persistTerminalState(sessionID, "clear", ledgerDurable) + const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0 + if (hadState && clearStillCurrent) { + announceLifecycle(sessionID, durable === false + ? "Goal cleared in memory, but storage failed; it may reappear after restart." + : "Goal cleared.", { + goal: clearedGoal, + transition: durable === false ? "clear-persistence-failed" : "cleared", + requireCurrent: false, + }) + } + replaceCommandOutputText( + output, + !clearStillCurrent + ? "Clear persistence finished after goal state changed; current state was left untouched." + : durable === false + ? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart." + : "Goal cleared.", + ) return } @@ -4051,6 +4564,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) replaceCommandOutputText(output, `No active goal. Set one with \`/${commandName} \`.`) return } + if (goal.stopped && goal.stopReason === "paused") { + replaceCommandOutputText(output, "Goal is already paused.") + return + } currentRuntime().continuationControllers.get(sessionID)?.abort() goal.stopped = true goal.stopReason = "paused" @@ -4059,6 +4576,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) activeContinues.delete(sessionID) pushHistory(goal, "paused", "User paused the active goal.") await persist(sessionID) + announceLifecycle(sessionID, "Goal paused.", { + goal, + transition: "paused", + reason: goal.stopReason, + expectedState: "paused", + expectedStopReason: "paused", + }) await abortAcceptedContinuation(sessionID) replaceCommandOutputText(output, `Goal paused: ${goal.condition}`) return @@ -4085,6 +4609,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) goal.lastStatus = "Goal resumed with a fresh local budget." pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.") await persist(sessionID) + announceLifecycle(sessionID, "Goal resumed with fresh limits.", { + goal, + transition: "resumed", + expectedState: "active", + }) replaceCommandOutputText(output, `Goal resumed with fresh limits: ${goal.condition}`, { startsWork: true, }) @@ -4132,6 +4661,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) goal.lastStatus = "Goal objective updated." pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`) await persist(sessionID) + announceLifecycle(sessionID, "Goal updated and active.", { + goal, + transition: "updated-active", + expectedState: "active", + }) replaceCommandOutputText( output, [ @@ -4212,6 +4746,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) focusGoal(sessionID, firstGoal) sessionOrdered.add(sessionID) await persist(sessionID) + announceLifecycle(sessionID, `Ordered goal sequence active (${objectives.length} goals).`, { + goal: firstGoal, + transition: "sequence-active", + reason: String(objectives.length), + expectedState: "active", + }) replaceCommandOutputText( output, [ @@ -4277,6 +4817,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) pushHistory(target, "focused", "Brought into focus as the session's active goal.") focusGoal(sessionID, target) await persist(sessionID) + announceLifecycle(sessionID, "Goal focus changed; selected goal active.", { + goal: target, + transition: "focused-active", + expectedState: "active", + }) replaceCommandOutputText( output, [ @@ -4335,6 +4880,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) registerSessionGoal(added) focusGoal(sessionID, added) await persist(sessionID) + announceLifecycle(sessionID, current + ? "Goal added and active; previous goal backgrounded." + : "Goal added and active.", { + goal: added, + transition: current ? "added-active-backgrounded" : "added-active", + expectedState: "active", + }) const total = listSessionGoals(sessionID).length replaceCommandOutputText( output, @@ -4373,6 +4925,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) registerSessionGoal(goal) focusGoal(sessionID, goal) await persist(sessionID) + announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", { + goal, + transition: replacedGoal ? "replaced-active" : "active", + expectedState: "active", + }) replaceCommandOutputText( output, [ @@ -4740,7 +5297,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.` pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`) await persist(sessionID) - await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`) + const rejectedGoalAfterPersist = currentGoal(sessionID, goalID, runID) + if ( + rejectedGoalAfterPersist !== auditedGoal || + !auditedGoal.stopped || + auditedGoal.stopReason !== "audit rejected" + ) return + if (auditMessagesEnabled) { + await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`) + } else { + announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", { + goal: auditedGoal, + transition: "audit-rejected", + reason, + expectedState: "paused", + expectedStopReason: "audit rejected", + }) + } return } pushHistory( @@ -4760,23 +5333,80 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) `Assistant marked the goal complete with evidence: ${summarizeText(evidence, 400)}`, ) const ordered = sessionOrdered.has(sessionID) - rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence) + const completedResult = rememberGoalResult( + sessionID, + activeGoalAfterMessages, + "achieved", + "", + evidence, + ) cleanupGoal(sessionID) // Ordered sequence: auto-promote the next goal so the // session keeps working through the sequence without manual /goal focus. - if (ordered) { - promoteNextOrderedGoal(sessionID) - } + const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null + const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID) const durable = await persistTerminalState(sessionID, "completion", ledgerDurable) if (durable === false) { - restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered }) - await announceAudit( + const restored = restoreAfterTerminalPersistenceFailure( sessionID, - "Audit result: completion verified, but storage failed; goal remains paused and was not archived.", + activeGoalAfterMessages, + { + ordered, + expectedCurrentSnapshot: postCompletionSnapshot, + expectedResult: completedResult, + }, ) + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + restored + ? "Audit result: completion verified, but storage failed; goal remains paused and was not archived." + : "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.", + ) + } else { + announceLifecycle( + sessionID, + restored + ? "Goal paused — completion could not be recorded durably." + : "Previous goal completion could not be confirmed durably after goal state changed.", + restored + ? { + goal: activeGoalAfterMessages, + transition: "terminal-persistence-failed", + reason: activeGoalAfterMessages.stopReason, + expectedState: "paused", + expectedStopReason: "terminal persistence failed", + } + : { + transition: "terminal-persistence-raced", + requireCurrent: false, + }, + ) + } return } - await announceAudit(sessionID, "Audit result: completion accepted — goal archived as achieved.") + const activePromoted = promoted + ? activeGoal(sessionID, promoted.goalId, promoted.runId) + : null + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + activePromoted + ? "Audit result: completion accepted — goal archived as achieved; next ordered goal active." + : "Audit result: completion accepted — goal archived as achieved.", + ) + } else { + announceLifecycle( + sessionID, + activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.", + { + goal: activePromoted || activeGoalAfterMessages, + transition: activePromoted ? "achieved-promoted" : "achieved", + requireCurrent: Boolean(activePromoted), + expectedState: activePromoted ? "active" : "", + }, + ) + } return } completionUnverified = true @@ -4802,16 +5432,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) blockedGoal.stopReason = "blocked" const ledgerDurable = pushHistory(blockedGoal, "blocked", reason) const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable) + const blockedGoalAfterPersist = currentGoal(sessionID, goalID, runID) + if ( + blockedGoalAfterPersist !== blockedGoal || + !blockedGoal.stopped || + blockedGoal.stopReason !== "blocked" + ) return if (durable === false) { blockedGoal.stopReason = "terminal persistence failed" blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused." - await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.") + if (auditMessagesEnabled) { + await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.") + } else { + announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", { + goal: blockedGoal, + transition: "terminal-persistence-failed", + reason: blockedGoal.stopReason, + expectedState: "paused", + expectedStopReason: "terminal persistence failed", + }) + } return } - await announceAudit( - sessionID, - `Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`, - ) + if (auditMessagesEnabled) { + await announceAudit( + sessionID, + `Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`, + ) + } else { + announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, { + goal: blockedGoal, + transition: "blocked", + expectedState: "blocked", + expectedStopReason: "blocked", + }) + } return } blockerUnstated = true @@ -4826,6 +5481,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const limitReason = stopReason(activeGoalAfterMessages) if (limitReason) { + let lifecycleAnnounced = false if (!activeGoalAfterMessages.budgetWrapupSent) { const claimedGoal = await claimContinuationSource( sessionID, @@ -4842,6 +5498,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) claimedGoal.lastStatus = `${limitReason}; requested final handoff.` pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`) await persist(sessionID) + lifecycleAnnounced = announceLifecycle( + sessionID, + `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, + { + goal: claimedGoal, + transition: "limit-paused", + reason: limitReason, + expectedState: "paused", + expectedStopReason: limitReason, + }, + ) currentRuntime().promptInFlightSessions.add(sessionID) let response try { @@ -4868,6 +5535,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) pushHistory(activeGoalAfterMessages, "limit", limitReason) } await persist(sessionID) + if (!lifecycleAnnounced) { + announceLifecycle(sessionID, `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, { + goal: activeGoalAfterMessages, + transition: "limit-paused", + reason: limitReason, + expectedState: "paused", + expectedStopReason: limitReason, + }) + } return } @@ -4923,6 +5599,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) `Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`, ) await persist(sessionID) + announceLifecycle(sessionID, "Goal paused — no progress threshold reached.", { + goal: activeGoalAfterMessages, + transition: "no-progress-paused", + reason: activeGoalAfterMessages.stopReason, + expectedState: "paused", + expectedStopReason: "no progress", + }) return } @@ -4968,6 +5651,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) `Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`, ) await persist(sessionID) + announceLifecycle(sessionID, "Goal paused — no-tool-call threshold reached.", { + goal: activeGoalAfterMessages, + transition: "no-tool-calls-paused", + reason: activeGoalAfterMessages.stopReason, + expectedState: "paused", + expectedStopReason: "no tool calls", + }) return } @@ -5017,6 +5707,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // the hard-limit path which also persists before its promptAsync call. pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.") await persist(sessionID) + announceLifecycle(sessionID, "Goal paused — budget threshold reached; final handoff requested.", { + goal: activeGoalBeforePrompt, + transition: "budget-wrapup-paused", + reason: activeGoalBeforePrompt.stopReason, + expectedState: "paused", + expectedStopReason: "budget wrap-up requested", + }) } activeGoalBeforePrompt.turnCount += 1 @@ -5057,6 +5754,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`, ) await persist(sessionID) + announceLifecycle(sessionID, "Goal paused — repeated completion/blocker format failures.", { + goal: activeGoalBeforePrompt, + transition: "format-failures-paused", + reason: activeGoalBeforePrompt.stopReason, + expectedState: "paused", + expectedStopReason: "format validation failures", + }) return } } @@ -5081,6 +5785,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) currentRuntime().promptInFlightSessions.delete(sessionID) } + let promptFailurePausedGoal = null if (response.error) { const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID) const message = `Auto-continue failed: ${response.error.name || "unknown error"}` @@ -5096,6 +5801,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) activeGoalAfterPrompt.stopped = true activeGoalAfterPrompt.stopReason = "auto-continue failures" activeGoalAfterPrompt.lastStatus = `${message}; paused after ${activeGoalAfterPrompt.promptFailures} failure(s). Run /${commandName} resume to retry.` + promptFailurePausedGoal = activeGoalAfterPrompt } } await logPluginError(client, message, response.error) @@ -5119,6 +5825,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } } await persist(sessionID) + if (promptFailurePausedGoal) { + announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", { + goal: promptFailurePausedGoal, + transition: "prompt-failures-paused", + reason: promptFailurePausedGoal.stopReason, + expectedState: "paused", + expectedStopReason: "auto-continue failures", + }) + } } catch (error) { const activeGoalAfterError = currentGoal(sessionID, goalID, runID) if (activeGoalAfterError) { @@ -5139,6 +5854,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.` } await persist(sessionID) + if (activeGoalAfterError.stopped && activeGoalAfterError.stopReason === "auto-continue failures") { + announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", { + goal: activeGoalAfterError, + transition: "prompt-failures-paused", + reason: activeGoalAfterError.stopReason, + expectedState: "paused", + expectedStopReason: "auto-continue failures", + }) + } } await logPluginError(client, "Auto-continue failed", error) } finally { @@ -5357,6 +6081,7 @@ export const testInternals = { ledgerPathFor, setLedgerSink, defaultAuditMessenger, + defaultLifecycleMessenger, buildAuditPrompt, parseAuditVerdict, createChildSessionAuditor, @@ -5375,6 +6100,7 @@ export const testInternals = { extractCompletionEvidence, findLatestAssistantMessage, formatArgumentErrors, + goalDisplayState, formatStatus, getSessionID, goalIsBlocked, diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 3351316..2824534 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -23,11 +23,14 @@ const { budgetWrapupNeeded, currentGoal, defaultAuditMessenger, + defaultLifecycleMessenger, escapeGoalText, extractBlockedReason, extractCompletionEvidence, + formatGoalList, formatStatus, getSessionID, + goalDisplayState, goalIsBlocked, goalIsComplete, isIdleEvent, @@ -2631,6 +2634,53 @@ test("resume after a limit stop starts a fresh local budget", async () => { assert.equal(calls.length, 3) }) +test("hard-limit lifecycle notice is visible before the final-handoff prompt resolves", async () => { + let promptCount = 0 + let signalFinalPrompt + let releaseFinalPrompt + const finalPromptStarted = new Promise((resolve) => { signalFinalPrompt = resolve }) + const finalPromptGate = new Promise((resolve) => { releaseFinalPrompt = resolve }) + const lifecycle = [] + const sessionID = "hard-limit-notice-order" + const { hooks } = await createHooks({ + messages: async () => ({ + data: [message("still working", undefined, `msg-hard-limit-${promptCount}`, sessionID)], + }), + promptAsync: async () => { + promptCount += 1 + if (promptCount === 2) { + signalFinalPrompt() + await finalPromptGate + } + return {} + }, + options: { + minDelayMs: 1, + maxTurns: 1, + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, + }) + + await runGoal(hooks, sessionID, "ship it") + lifecycle.length = 0 + await hooks.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + const finalIdle = hooks.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + try { + await finalPromptStarted + assert.equal(currentGoal(sessionID).stopped, true) + assert.match(currentGoal(sessionID).stopReason, /max turns/) + assert.equal(lifecycle.filter((text) => /max turns.*final handoff/i.test(text)).length, 1) + } finally { + releaseFinalPrompt() + await finalIdle + } + assert.equal(lifecycle.filter((text) => /max turns.*final handoff/i.test(text)).length, 1) +}) + test("/goal pause stops auto-continue until resumed", async () => { const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) await hooks["command.execute.before"]( @@ -3167,6 +3217,8 @@ test("formatStatus includes all key fields", () => { } const status = formatStatus(goal) assert.match(status, /Active goal: ship it/) + assert.match(status, /State: blocked/) + assert.match(status, /Completion audit: evidence gate only \(independent verifier off\)/) assert.match(status, /Auto-continues sent: 3\/10/) assert.match(status, /Context tokens:/) assert.match(status, /Elapsed:/) @@ -3176,6 +3228,56 @@ test("formatStatus includes all key fields", () => { assert.match(status, /Suggested action: address the blocker, then run \/goal resume/) }) +test("goalDisplayState and formatStatus distinguish active, paused, and blocked goals", () => { + const base = { + condition: "ship it", + turnCount: 0, + options: normalizeOptions(), + totalTokens: 0, + startedAt: Date.now(), + lastProgressAt: Date.now(), + noProgressTurns: 0, + lastStatus: "Goal started.", + stopped: false, + stopReason: "", + blockedReason: "", + } + const paused = { ...base, stopped: true, stopReason: "paused by user" } + const blocked = { + ...base, + stopped: true, + stopReason: "blocked", + blockedReason: "Need approval", + } + + assert.equal(goalDisplayState(base), "active") + assert.equal(goalDisplayState(paused), "paused") + assert.equal(goalDisplayState(blocked), "blocked") + assert.match(formatStatus(base), /State: active/) + assert.match(formatStatus(paused), /State: paused/) + assert.match(formatStatus(blocked), /State: blocked/) + assert.match(formatStatus(base, "goal", "built-in independent verifier"), /Completion audit: built-in independent verifier/) + assert.match(formatStatus(base, "goal", "custom completion auditor"), /Completion audit: custom completion auditor/) +}) + +test("/goal status reports the configured completion-audit mechanism", async () => { + const builtIn = await createHooks({ options: { completionAudit: true } }) + await runGoal(builtIn.hooks, "status-audit-built-in", "ship it") + assert.match( + await runGoal(builtIn.hooks, "status-audit-built-in", "status"), + /Completion audit: built-in independent verifier/, + ) + + const custom = await createHooks({ + options: { auditor: async () => ({ approved: true }) }, + }) + await runGoal(custom.hooks, "status-audit-custom", "ship it") + assert.match( + await runGoal(custom.hooks, "status-audit-custom", "status"), + /Completion audit: custom completion auditor/, + ) +}) + test("/goal history shows lifecycle events and the latest checkpoint", async () => { const { hooks } = await createHooks({ messages: async () => ({ @@ -3209,6 +3311,7 @@ test("/goal history shows lifecycle events and the latest checkpoint", async () test("persisted running goals are recovered in paused state after restart", async () => { const dir = await mkdtemp(join(tmpdir(), "goal-plugin-test-")) const stateFilePath = join(dir, "state.json") + const lifecycle = [] try { const client = { @@ -3221,7 +3324,12 @@ test("persisted running goals are recovered in paused state after restart", asyn const hooks = await GoalPlugin( { client }, - { persistState: true, stateFilePath, minDelayMs: 1 }, + { + persistState: true, + stateFilePath, + minDelayMs: 1, + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, ) await hooks["command.execute.before"]( { command: "goal", sessionID: "session-persist", arguments: "ship it" }, @@ -3236,10 +3344,16 @@ test("persisted running goals are recovered in paused state after restart", asyn true, ) await hooks.dispose() + lifecycle.length = 0 const recoveredHooks = await GoalPlugin( { client }, - { persistState: true, stateFilePath, minDelayMs: 1 }, + { + persistState: true, + stateFilePath, + minDelayMs: 1, + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, ) await recoveredHooks["command.execute.before"]( { command: "goal", sessionID: "session-persist", arguments: "status" }, @@ -3255,6 +3369,8 @@ test("persisted running goals are recovered in paused state after restart", asyn statusOutput, ) assert.match(statusOutput.parts[0].text, /Recovered persisted goal state/) + assert.equal(lifecycle.length, 1) + assert.match(lifecycle[0], /Goal recovered and paused/i) } finally { await rm(dir, { recursive: true, force: true }) } @@ -5134,8 +5250,8 @@ test("/goal list shows numbered live goals and archived results", async () => { await runGoal(hooks, sid, "add beta") const listText = await runGoal(hooks, sid, "list") assert.match(listText, /Goals \(2\):/) - assert.match(listText, /\[focused\] beta/) - assert.match(listText, /\[background\] alpha/) + assert.match(listText, /\[focused\] beta.*state: active/) + assert.match(listText, /\[background\] alpha.*state: paused.*backgrounded/) // The first idle belongs to the routed list response: it resumes the loop but // must not treat that control response as goal progress or completion. @@ -5152,6 +5268,32 @@ test("/goal list shows numbered live goals and archived results", async () => { assert.match(afterList, /\[achieved\] beta/) }) +test("/goal list shows the focused goal's paused or blocked state and reason", async () => { + const paused = await createHooks() + const pausedSessionID = "multi-focused-paused" + await runGoal(paused.hooks, pausedSessionID, "ship it") + await runGoal(paused.hooks, pausedSessionID, "pause") + + const pausedList = await runGoal(paused.hooks, pausedSessionID, "list") + assert.match(pausedList, /\[focused\] ship it.*state: paused.*\(paused\)/) + + const blocked = await createHooks({ + messages: async () => ({ data: [message("Need approval.\n[goal:blocked]")] }), + options: { minDelayMs: 1 }, + }) + const blockedSessionID = "multi-focused-blocked" + await runGoal(blocked.hooks, blockedSessionID, "ship it") + await blocked.hooks.event({ + event: { + type: "session.status", + properties: { sessionID: blockedSessionID, status: { type: "idle" } }, + }, + }) + + const blockedList = await runGoal(blocked.hooks, blockedSessionID, "list") + assert.match(blockedList, /\[focused\] ship it.*state: blocked.*Need approval/) +}) + test("/goal focus switches the active goal and backgrounds the prior one", async () => { const { hooks } = await createHooks() const sid = "multi-s3" @@ -5418,7 +5560,281 @@ test("completed archives survive a persistence round-trip", async () => { } }) -// ── Visible audit messages ───────────────────────────────────────────────── +// ── Visible lifecycle and audit messages ─────────────────────────────────── + +test("slash-command lifecycle messages cover transitions without leaking goal text or spamming queries", async () => { + const lifecycle = [] + const { hooks } = await createHooks({ + options: { + minDelayMs: 1, + lifecycleMessenger: async (sessionID, text) => lifecycle.push({ sessionID, text }), + }, + }) + const sid = "lifecycle-slash-s1" + const privateObjective = "ship private customer ACME-SECRET" + + await runGoal(hooks, sid, privateObjective) + assert.equal(lifecycle.length, 1) + assert.match(lifecycle[0].text, /Goal (?:active|started)/i) + assert.doesNotMatch(lifecycle[0].text, /ACME-SECRET/) + + const afterStart = lifecycle.length + await runGoal(hooks, sid, "status") + await runGoal(hooks, sid, "list") + await runGoal(hooks, sid, "history") + assert.equal(lifecycle.length, afterStart) + + await runGoal(hooks, sid, "pause") + assert.match(lifecycle.at(-1).text, /Goal paused/i) + await runGoal(hooks, sid, "resume") + assert.match(lifecycle.at(-1).text, /Goal resumed/i) + await runGoal(hooks, sid, "edit revised private objective") + assert.match(lifecycle.at(-1).text, /Goal updated/i) + + const beforeIdle = lifecycle.length + await hooks.event({ + event: { type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } }, + }) + assert.equal(lifecycle.length, beforeIdle) + + await runGoal(hooks, sid, "clear") + assert.match(lifecycle.at(-1).text, /Goal cleared/i) + assert.ok(lifecycle.every(({ sessionID }) => sessionID === sid)) + assert.ok(lifecycle.every(({ text }) => !text.includes("private"))) +}) + +test("repeated pause without a state transition emits one lifecycle notice", async () => { + const lifecycle = [] + const { hooks } = await createHooks({ + options: { + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, + }) + const sessionID = "lifecycle-repeat-pause" + + await runGoal(hooks, sessionID, "ship it") + lifecycle.length = 0 + assert.match(await runGoal(hooks, sessionID, "pause"), /Goal paused/) + assert.match(await runGoal(hooks, sessionID, "pause"), /already paused/) + assert.equal(lifecycle.filter((text) => /Goal paused/i.test(text)).length, 1) + + await runGoal(hooks, sessionID, "resume") + lifecycle.length = 0 + const first = JSON.parse(await hooks.tool.goal_pause.execute({}, { sessionID })) + const second = JSON.parse(await hooks.tool.goal_pause.execute({}, { sessionID })) + assert.equal(first.ok, true) + assert.equal(second.ok, true) + assert.match(second.message, /already paused/) + assert.equal(lifecycle.filter((text) => /Goal paused/i.test(text)).length, 1) +}) + +test("consecutive objective edits each emit lifecycle feedback", async () => { + const lifecycle = [] + const { hooks } = await createHooks({ + options: { + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, + }) + const sessionID = "lifecycle-consecutive-edits" + + await runGoal(hooks, sessionID, "initial objective") + lifecycle.length = 0 + await runGoal(hooks, sessionID, "edit first revision") + await runGoal(hooks, sessionID, "edit second revision") + + assert.deepEqual(lifecycle, ["Goal updated and active.", "Goal updated and active."]) + assert.equal(currentGoal(sessionID).condition, "second revision") +}) + +test("lifecycleMessages:false suppresses lifecycle messages", async () => { + const lifecycle = [] + const { hooks } = await createHooks({ + options: { + lifecycleMessages: false, + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, + }) + const sid = "lifecycle-off-s1" + + await runGoal(hooks, sid, "ship it") + await runGoal(hooks, sid, "pause") + await runGoal(hooks, sid, "resume") + await runGoal(hooks, sid, "clear") + + assert.equal(lifecycle.length, 0) +}) + +test("lifecycle messenger failures never change committed goal state", async () => { + const { hooks } = await createHooks({ + options: { + lifecycleMessenger: async () => { + throw new Error("advisory transport unavailable") + }, + }, + }) + const sid = "lifecycle-failure-s1" + + await runGoal(hooks, sid, "ship it") + assert.equal(currentGoal(sid).stopped, false) + await runGoal(hooks, sid, "pause") + assert.equal(currentGoal(sid).stopped, true) + await runGoal(hooks, sid, "resume") + assert.equal(currentGoal(sid).stopped, false) + await runGoal(hooks, sid, "clear") + assert.equal(currentGoal(sid), null) +}) + +test("terminal audit messages do not duplicate lifecycle notices, with a lifecycle fallback when disabled", async () => { + const audits = [] + const lifecycle = [] + const first = await createHooks({ + messages: async () => ({ + data: [message("Done.\n[goal:evidence] suite green\n[goal:complete]")], + }), + options: { + minDelayMs: 1, + auditMessenger: async (_sessionID, text) => audits.push(text), + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, + }) + await runGoal(first.hooks, "lifecycle-terminal-audit", "ship it") + lifecycle.length = 0 + await first.hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "lifecycle-terminal-audit", status: { type: "idle" } }, + }, + }) + assert.equal(audits.length, 2) + assert.equal(lifecycle.length, 0) + + const fallback = [] + const second = await createHooks({ + messages: async () => ({ + data: [message("Done.\n[goal:evidence] suite green\n[goal:complete]")], + }), + options: { + minDelayMs: 1, + auditMessages: false, + lifecycleMessenger: async (_sessionID, text) => fallback.push(text), + }, + }) + await runGoal(second.hooks, "lifecycle-terminal-fallback", "ship it") + fallback.length = 0 + await second.hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "lifecycle-terminal-fallback", status: { type: "idle" } }, + }, + }) + assert.equal(fallback.length, 1) + assert.match(fallback[0], /Goal achieved/i) +}) + +test("plugin-wired canonical terminal tools use audit notices or one lifecycle fallback", async () => { + const audits = [] + const lifecycle = [] + const audited = await createHooks({ + options: { + auditMessenger: async (_sessionID, text) => audits.push(text), + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }, + }) + const call = async (hooks, sessionID, name, args = {}) => + JSON.parse(await hooks.tool[name].execute(args, { sessionID })) + + assert.equal( + (await call(audited.hooks, "canonical-audit-complete", "goal_set", { objective: "ship it" })).ok, + true, + ) + audits.length = 0 + lifecycle.length = 0 + assert.equal( + (await call(audited.hooks, "canonical-audit-complete", "goal_complete", { summary: "suite green" })).ok, + true, + ) + assert.equal(audits.length, 2) + assert.match(audits[0], /Auditing goal completion/i) + assert.match(audits[1], /completion accepted/i) + assert.equal(lifecycle.length, 0) + + assert.equal( + (await call(audited.hooks, "canonical-audit-block", "goal_set", { objective: "deploy it" })).ok, + true, + ) + audits.length = 0 + lifecycle.length = 0 + assert.equal( + (await call(audited.hooks, "canonical-audit-block", "goal_block", { blocker: "approval required" })).ok, + true, + ) + assert.equal(audits.length, 2) + assert.match(audits[0], /Auditing goal blocker/i) + assert.match(audits[1], /paused as blocked/i) + assert.equal(lifecycle.length, 0) + await audited.hooks.dispose() + + const fallbackAudits = [] + const fallbackLifecycle = [] + const fallback = await createHooks({ + options: { + auditMessages: false, + auditMessenger: async (_sessionID, text) => fallbackAudits.push(text), + lifecycleMessenger: async (_sessionID, text) => fallbackLifecycle.push(text), + }, + }) + + assert.equal( + (await call(fallback.hooks, "canonical-fallback-complete", "goal_set", { objective: "ship it" })).ok, + true, + ) + fallbackLifecycle.length = 0 + assert.equal( + (await call(fallback.hooks, "canonical-fallback-complete", "goal_complete", { summary: "suite green" })).ok, + true, + ) + assert.deepEqual(fallbackAudits, []) + assert.equal(fallbackLifecycle.length, 1) + assert.match(fallbackLifecycle[0], /Goal achieved/i) + + assert.equal( + (await call(fallback.hooks, "canonical-fallback-block", "goal_set", { objective: "deploy it" })).ok, + true, + ) + fallbackLifecycle.length = 0 + assert.equal( + (await call(fallback.hooks, "canonical-fallback-block", "goal_block", { blocker: "approval required" })).ok, + true, + ) + assert.deepEqual(fallbackAudits, []) + assert.equal(fallbackLifecycle.length, 1) + assert.match(fallbackLifecycle[0], /Goal blocked/i) + await fallback.hooks.dispose() +}) + +test("defaultLifecycleMessenger uses app.log and TUI toasts with state-aware variants", async () => { + const logs = [] + const toasts = [] + const client = { + app: { log: async (input) => logs.push(input) }, + tui: { showToast: async (input) => toasts.push(input) }, + } + + await defaultLifecycleMessenger(client, "lifecycle-transport", "Goal active.") + await defaultLifecycleMessenger(client, "lifecycle-transport", "Goal paused: safety limit reached.") + await defaultLifecycleMessenger(client, "lifecycle-transport", "Goal achieved.") + + assert.equal(logs.length, 3) + assert.ok(logs.every((entry) => entry.body.extra.kind === "goal-lifecycle")) + assert.ok(logs.every((entry) => entry.body.extra.sessionID === "lifecycle-transport")) + assert.deepEqual(toasts.map((toast) => toast.body.variant), ["info", "warning", "success"]) + assert.deepEqual(toasts.map((toast) => toast.body.message), [ + "Goal active.", + "Goal paused: safety limit reached.", + "Goal achieved.", + ]) + await defaultLifecycleMessenger({}, "s", "Goal active.") +}) test("completion emits visible audit-start and audit-result messages", async () => { const audits = [] @@ -5847,6 +6263,28 @@ test("agent tool handlers set, read, update, and clear a goal", async () => { assert.ok(persistCalls.length > 0) }) +test("agent tool handlers emit the same lifecycle transitions as slash commands", async () => { + const lifecycle = [] + const { handlers } = makeAgentHandlers({ + announceLifecycle: async (sessionID, text) => lifecycle.push({ sessionID, text }), + }) + const sid = "agent-lifecycle-s1" + + await handlers.setGoal(sid, { objective: "private agent objective" }) + await handlers.updateGoal(sid, { objective: "revised private agent objective" }) + await handlers.updateGoal(sid, { status: "paused" }) + await handlers.updateGoal(sid, { status: "resumed" }) + await handlers.updateGoal(sid, { status: "blocked", blocker: "private deployment key missing" }) + await handlers.clearGoal(sid) + + assert.deepEqual( + lifecycle.map(({ text }) => text.match(/Goal (?:active|updated|paused|resumed|blocked|cleared)/i)?.[0]), + ["Goal active", "Goal updated", "Goal paused", "Goal resumed", "Goal blocked", "Goal cleared"], + ) + assert.ok(lifecycle.every(({ sessionID }) => sessionID === sid)) + assert.ok(lifecycle.every(({ text }) => !text.includes("private"))) +}) + test("agent set_goal honors limit overrides, schema fields, and rejects an empty objective", async () => { const { handlers } = makeAgentHandlers() assert.match( @@ -6115,6 +6553,44 @@ test("canonical errors use state and stable codes instead of parsing legacy pros assert.match(rejected.message, /custom provider verdict/) }) +test("canonical goal_block rejects when its goal changes during terminal persistence", async () => { + const schema = { + string: () => ({ optional: () => "str?" }), + number: () => ({ optional: () => "num?" }), + array: () => ({ optional: () => "array?" }), + object: () => "object", + enum: () => "enum", + } + const toolHelper = (definition) => definition + toolHelper.schema = schema + let signalPersist + let releasePersist + const persistStarted = new Promise((resolve) => { signalPersist = resolve }) + const persistGate = new Promise((resolve) => { releasePersist = resolve }) + const { handlers } = makeAgentHandlers({ + persistTerminalState: async () => { + signalPersist() + await persistGate + return true + }, + }) + const tools = buildAgentTools(toolHelper, handlers) + const context = { sessionID: "canonical-block-race" } + + await tools.goal_set.execute({ objective: "old objective" }, context) + const blocking = tools.goal_block.execute({ blocker: "need approval" }, context) + await persistStarted + await tools.goal_set.execute({ objective: "replacement objective" }, context) + releasePersist() + const result = JSON.parse(await blocking) + + assert.equal(result.ok, false) + assert.equal(result.error, "goal_changed") + assert.match(result.message, /changed.*not (?:recorded|reported)/i) + assert.equal(currentGoal(context.sessionID).condition, "replacement objective") + assert.equal(currentGoal(context.sessionID).stopped, false) +}) + test("/goal resume preserves registry identity and clears cleanly", async () => { const { hooks } = await createHooks() const run = (args) => @@ -6550,6 +7026,134 @@ test("agent completion remains paused when neither state nor ledger records the assert.equal(goal.stopReason, "terminal persistence failed") }) +test("terminal persistence failure cannot resurrect a concurrently replaced goal", async () => { + let signalPersist + let releasePersist + const persistStarted = new Promise((resolve) => { signalPersist = resolve }) + const persistGate = new Promise((resolve) => { releasePersist = resolve }) + const handlers = buildAgentToolHandlers({ + defaultGoalOptions: normalizeOptions(), + persist: async () => true, + persistTerminalState: async () => { + signalPersist() + await persistGate + return false + }, + }) + const sessionID = "terminal-storage-replacement-race" + + await handlers.setGoal(sessionID, { objective: "old objective" }) + const completing = handlers.updateGoal(sessionID, { + status: "complete", + evidence: "suite green", + }) + await persistStarted + await handlers.setGoal(sessionID, { objective: "replacement objective" }) + releasePersist() + const result = await completing + + assert.match(result, /could not be persisted.*goal changed|goal changed.*could not be persisted|before the goal changed/i) + assert.doesNotMatch(result, /Goal remains paused/i) + assert.equal(currentGoal(sessionID).condition, "replacement objective") + assert.equal(currentGoal(sessionID).stopped, false) + assert.deepEqual(listSessionGoals(sessionID).map((goal) => goal.condition), ["replacement objective"]) + assert.doesNotMatch(await handlers.getGoal(sessionID), /State: achieved/) + assert.doesNotMatch(formatGoalList(sessionID), /old objective/) +}) + +test("another session's result eviction cannot suppress terminal rollback", async () => { + let signalPersist + let releasePersist + const persistStarted = new Promise((resolve) => { signalPersist = resolve }) + const persistGate = new Promise((resolve) => { releasePersist = resolve }) + const handlers = buildAgentToolHandlers({ + defaultGoalOptions: normalizeOptions({ maxStoredResults: 1 }), + persist: async () => true, + persistTerminalState: async (sessionID) => { + if (sessionID !== "rollback-A") return true + signalPersist() + await persistGate + return false + }, + }) + + await handlers.setGoal("rollback-A", { objective: "A objective" }) + const completingA = handlers.updateGoal("rollback-A", { + status: "complete", + evidence: "verified", + }) + await persistStarted + + await handlers.setGoal("rollback-B", { objective: "B objective" }) + assert.match( + await handlers.updateGoal("rollback-B", { status: "complete", evidence: "verified" }), + /archived/i, + ) + + releasePersist() + assert.match(await completingA, /remains paused/i) + assert.equal(currentGoal("rollback-A").condition, "A objective") + assert.equal(currentGoal("rollback-A").stopReason, "terminal persistence failed") + assert.doesNotMatch(formatGoalList("rollback-A"), /\[achieved\]/) + assert.match(formatGoalList("rollback-B"), /\[achieved\] B objective/) +}) + +test("terminal persistence failure cannot resurrect a concurrently cleared goal", async () => { + let signalPersist + let releasePersist + const persistStarted = new Promise((resolve) => { signalPersist = resolve }) + const persistGate = new Promise((resolve) => { releasePersist = resolve }) + const handlers = buildAgentToolHandlers({ + defaultGoalOptions: normalizeOptions(), + persist: async () => true, + persistTerminalState: async (sessionID, label) => { + if (sessionID === "rollback-clear" && label === "completion") { + signalPersist() + await persistGate + return false + } + return true + }, + }) + + await handlers.setGoal("rollback-clear", { objective: "clear this goal" }) + const completing = handlers.updateGoal("rollback-clear", { + status: "complete", + evidence: "verified", + }) + await persistStarted + assert.equal(await handlers.clearGoal("rollback-clear"), "Goal cleared.") + + releasePersist() + assert.match(await completing, /goal changed/i) + assert.equal(currentGoal("rollback-clear"), null) + assert.doesNotMatch(formatGoalList("rollback-clear"), /clear this goal|\[achieved\]/) +}) + +test("agent blocked state fails closed when neither snapshot nor ledger is durable", async () => { + const audits = [] + const handlers = buildAgentToolHandlers({ + defaultGoalOptions: normalizeOptions(), + persist: async () => false, + persistTerminalState: async () => false, + auditMessagesEnabled: true, + announceAudit: async (_sessionID, text) => audits.push(text), + }) + const sessionID = "blocked-dual-storage-failure" + + await handlers.setGoal(sessionID, { objective: "ship it" }) + const result = await handlers.updateGoal(sessionID, { + status: "blocked", + blocker: "need approval", + }) + + assert.match(result, /could not be persisted/) + assert.equal(currentGoal(sessionID).stopped, true) + assert.equal(currentGoal(sessionID).stopReason, "terminal persistence failed") + assert.ok(audits.some((text) => /storage failed/i.test(text))) + assert.ok(!audits.some((text) => /paused as blocked/i.test(text))) +}) + test("ordered completion storage failure rolls back premature successor promotion", async () => { const client = { app: { log: async () => {} }, @@ -6632,6 +7236,124 @@ test("ledger cross-check removes completed goals still active in a stale state f } }) +test("ledger cross-check restores a newer blocked state and reason from a stale active snapshot", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-blocked-ledger-xcheck-")) + const stateFilePath = join(dir, "state.json") + const sessionID = "blocked-ledger-restart" + const ledgerFilePath = sessionLedgerPath(stateFilePath, sessionID) + const lifecycle = [] + let prompts = 0 + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ data: [message("still working")] }), + promptAsync: async () => { + prompts += 1 + return {} + }, + }, + } + let first + let second + try { + first = await GoalPlugin({ client }, { stateFilePath, lifecycleMessages: false }) + await runGoal(first, sessionID, "ship it") + const staleGoal = currentGoal(sessionID) + const entries = await readLedgerEntries(ledgerFilePath) + const blockedAt = Math.max(...entries.map((entry) => Number(entry.ts) || 0), Date.now()) + 1 + await first.dispose() + first = null + + assert.equal(appendLedgerLine(ledgerFilePath, { + ts: blockedAt, + sessionID, + goalId: staleGoal.goalId, + condition: staleGoal.condition, + snapshot: { + options: staleGoal.options, + stopped: true, + stopReason: "blocked", + blockedReason: "need a production credential", + }, + type: "blocked", + detail: "need a production credential", + }), true) + + second = await GoalPlugin({ client }, { + stateFilePath, + lifecycleMessenger: async (_sessionID, text) => lifecycle.push(text), + }) + const status = await runGoal(second, sessionID, "status") + const recovered = currentGoal(sessionID) + assert.match(status, /State: blocked/) + assert.match(status, /Blocked reason: need a production credential/) + assert.equal(recovered.stopReason, "blocked") + assert.equal(recovered.blockedReason, "need a production credential") + assert.ok(recovered.history.some((entry) => entry.type === "blocked" && /production credential/.test(entry.detail))) + assert.equal(lifecycle.filter((text) => /recovered as blocked/i.test(text)).length, 1) + + await second.event({ + event: { type: "session.status", properties: { sessionID, status: { type: "idle" } } }, + }) + assert.equal(prompts, 0) + const rewritten = JSON.parse(await readFile(sessionStatePath(stateFilePath, sessionID), "utf8")) + assert.equal(rewritten.goals[0].stopReason, "blocked") + assert.equal(rewritten.goals[0].blockedReason, "need a production credential") + } finally { + await first?.dispose() + await second?.dispose() + setLedgerSink(null) + await rm(dir, { recursive: true, force: true }) + } +}) + +test("a persisted resume state wins over an older blocked ledger entry", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-blocked-ledger-resume-")) + const stateFilePath = join(dir, "state.json") + const sessionID = "blocked-ledger-resumed" + const ledgerFilePath = sessionLedgerPath(stateFilePath, sessionID) + const client = { + app: { log: async () => {} }, + session: { messages: async () => ({ data: [] }), promptAsync: async () => ({}) }, + } + let first + let second + try { + first = await GoalPlugin({ client }, { stateFilePath, lifecycleMessages: false }) + await runGoal(first, sessionID, "ship it") + const goal = currentGoal(sessionID) + assert.equal(appendLedgerLine(ledgerFilePath, { + ts: Date.now(), + sessionID, + goalId: goal.goalId, + condition: goal.condition, + snapshot: { + options: goal.options, + stopped: true, + stopReason: "blocked", + blockedReason: "obsolete blocker", + }, + type: "blocked", + detail: "obsolete blocker", + }), true) + await runGoal(first, sessionID, "pause") + await runGoal(first, sessionID, "resume") + await first.dispose() + first = null + + second = await GoalPlugin({ client }, { stateFilePath, lifecycleMessages: false }) + const status = await runGoal(second, sessionID, "status") + assert.match(status, /State: paused/) + assert.doesNotMatch(status, /State: blocked|obsolete blocker/) + assert.equal(currentGoal(sessionID).stopReason, "recovered after restart") + } finally { + await first?.dispose() + await second?.dispose() + setLedgerSink(null) + await rm(dir, { recursive: true, force: true }) + } +}) + test("ledger-only ordered completion promotes the queued successor during restart recovery", async () => { const dir = await mkdtemp(join(tmpdir(), "goal-plugin-ordered-ledger-xcheck-")) const stateFilePath = join(dir, "state.json") diff --git a/test/host-lifecycle.test.js b/test/host-lifecycle.test.js index 68b4f5c..0a6de07 100644 --- a/test/host-lifecycle.test.js +++ b/test/host-lifecycle.test.js @@ -843,7 +843,13 @@ test("passive goal tools reject honestly, remain per-session, and take over paus await idle(contender, sessionID) assert.equal(promptCalls.length, 1) assert.ok(messagesCalls.length >= 1) - assert.equal(logs.length, 1) + const lifecycleLogs = logs.filter((entry) => entry.body.extra?.kind === "goal-lifecycle") + const leaseWarnings = logs.filter((entry) => entry.body.extra?.kind !== "goal-lifecycle") + assert.equal(leaseWarnings.length, 1) + assert.equal(lifecycleLogs.length, 3) + assert.match(lifecycleLogs[0].body.message, /Goal active/i) + assert.match(lifecycleLogs[1].body.message, /Goal recovered and paused/i) + assert.match(lifecycleLogs[2].body.message, /Goal resumed/i) } finally { await contender?.dispose() await owner?.dispose()