opencode-goal-plugin 0.6.8 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +22 -4
- package/index.d.ts +17 -0
- package/package.json +1 -1
- package/scripts/verify.mjs +9 -1
- package/src/goal-plugin.js +790 -64
package/src/goal-plugin.js
CHANGED
|
@@ -95,6 +95,7 @@ function createRuntimeState() {
|
|
|
95
95
|
sessionArchive: new Map(),
|
|
96
96
|
sessionOrdered: new Set(),
|
|
97
97
|
lastGoalResults: new Map(),
|
|
98
|
+
sessionMutationVersions: new Map(),
|
|
98
99
|
seenTokens: new Map(),
|
|
99
100
|
seenUsage: new Map(),
|
|
100
101
|
seenOutputTokens: new Map(),
|
|
@@ -165,6 +166,7 @@ const sessionArchive = runtimeCollection("sessionArchive")
|
|
|
165
166
|
const sessionOrdered = runtimeCollection("sessionOrdered")
|
|
166
167
|
const MAX_ARCHIVED_PER_SESSION = 10
|
|
167
168
|
const lastGoalResults = runtimeCollection("lastGoalResults")
|
|
169
|
+
const sessionMutationVersions = runtimeCollection("sessionMutationVersions")
|
|
168
170
|
const seenTokens = runtimeCollection("seenTokens")
|
|
169
171
|
const seenUsage = runtimeCollection("seenUsage")
|
|
170
172
|
const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
@@ -490,6 +492,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
|
490
492
|
options: goal.options,
|
|
491
493
|
stopped: goal.stopped,
|
|
492
494
|
stopReason: goal.stopReason,
|
|
495
|
+
blockedReason: goal.blockedReason,
|
|
493
496
|
ordered: sessionOrdered.has(goal.sessionID),
|
|
494
497
|
},
|
|
495
498
|
type,
|
|
@@ -504,6 +507,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
|
504
507
|
function pushHistory(goal, type, detail, timestamp = Date.now()) {
|
|
505
508
|
const entry = makeHistoryEntry(type, detail, timestamp)
|
|
506
509
|
goal.history = [...(goal.history || []), entry].slice(-MAX_HISTORY_ENTRIES)
|
|
510
|
+
markSessionMutation(goal.sessionID)
|
|
507
511
|
return emitLedgerEvent(goal, entry.type, entry.detail, entry.timestamp)
|
|
508
512
|
}
|
|
509
513
|
|
|
@@ -638,6 +642,7 @@ function reconstructGoalsFromLedger(entries) {
|
|
|
638
642
|
const condition = [...events].reverse().find((event) => typeof event.condition === "string" && event.condition.trim())?.condition?.trim()
|
|
639
643
|
if (!condition) continue
|
|
640
644
|
const snapshot = [...events].reverse().find((event) => isPlainObject(event.snapshot))?.snapshot || {}
|
|
645
|
+
const latestBlocked = [...events].reverse().find((event) => event.type === "blocked")
|
|
641
646
|
|
|
642
647
|
const history = events
|
|
643
648
|
.map((event) =>
|
|
@@ -659,6 +664,12 @@ function reconstructGoalsFromLedger(entries) {
|
|
|
659
664
|
options: isPlainObject(snapshot.options) ? snapshot.options : {},
|
|
660
665
|
stopped: snapshot.stopped === true,
|
|
661
666
|
stopReason: typeof snapshot.stopReason === "string" ? snapshot.stopReason : "",
|
|
667
|
+
blockedReason:
|
|
668
|
+
typeof snapshot.blockedReason === "string"
|
|
669
|
+
? snapshot.blockedReason
|
|
670
|
+
: snapshot.stopReason === "blocked" && typeof latestBlocked?.detail === "string"
|
|
671
|
+
? latestBlocked.detail
|
|
672
|
+
: "",
|
|
662
673
|
ordered: snapshot.ordered === true || events.some((event) => /ordered goal/i.test(String(event.detail || ""))),
|
|
663
674
|
startedAt: normalizeTimestamp(events[0]?.ts),
|
|
664
675
|
history,
|
|
@@ -675,9 +686,19 @@ function recordCheckpoint(goal, text, timestamp = Date.now()) {
|
|
|
675
686
|
const checkpoint = { summary, timestamp }
|
|
676
687
|
goal.lastCheckpoint = checkpoint
|
|
677
688
|
goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
|
|
689
|
+
markSessionMutation(goal.sessionID)
|
|
678
690
|
}
|
|
679
691
|
|
|
680
|
-
function
|
|
692
|
+
function goalDisplayState(goal) {
|
|
693
|
+
if (!goal?.stopped) return "active"
|
|
694
|
+
return goal.stopReason === "blocked" ? "blocked" : "paused"
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function formatStatus(
|
|
698
|
+
goal,
|
|
699
|
+
commandName = "goal",
|
|
700
|
+
completionAuditLabel = "evidence gate only (independent verifier off)",
|
|
701
|
+
) {
|
|
681
702
|
const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
|
|
682
703
|
const lastProgress =
|
|
683
704
|
goal.lastProgressAt > 0
|
|
@@ -688,6 +709,8 @@ function formatStatus(goal, commandName = "goal") {
|
|
|
688
709
|
: "none yet"
|
|
689
710
|
const lines = [
|
|
690
711
|
`Active goal: ${goal.condition}`,
|
|
712
|
+
`State: ${goalDisplayState(goal)}`,
|
|
713
|
+
`Completion audit: ${completionAuditLabel}`,
|
|
691
714
|
]
|
|
692
715
|
if (goal.successCriteria) lines.push(`Success criteria: ${goal.successCriteria}`)
|
|
693
716
|
if (goal.constraints) lines.push(`Constraints: ${goal.constraints}`)
|
|
@@ -772,8 +795,16 @@ function sessionGoalMap(sessionID) {
|
|
|
772
795
|
return map
|
|
773
796
|
}
|
|
774
797
|
|
|
798
|
+
function markSessionMutation(sessionID) {
|
|
799
|
+
if (!sessionID) return 0
|
|
800
|
+
const next = (sessionMutationVersions.get(sessionID) || 0) + 1
|
|
801
|
+
sessionMutationVersions.set(sessionID, next)
|
|
802
|
+
return next
|
|
803
|
+
}
|
|
804
|
+
|
|
775
805
|
function registerSessionGoal(goal) {
|
|
776
806
|
sessionGoalMap(goal.sessionID).set(goal.goalId, goal)
|
|
807
|
+
markSessionMutation(goal.sessionID)
|
|
777
808
|
}
|
|
778
809
|
|
|
779
810
|
function listSessionGoals(sessionID) {
|
|
@@ -796,12 +827,13 @@ function setBoundedMessageValue(map, messageID, value) {
|
|
|
796
827
|
function removeSessionGoal(sessionID, goalId) {
|
|
797
828
|
const map = sessionGoals.get(sessionID)
|
|
798
829
|
if (!map) return
|
|
799
|
-
map.delete(goalId)
|
|
830
|
+
if (map.delete(goalId)) markSessionMutation(sessionID)
|
|
800
831
|
if (map.size === 0) sessionGoals.delete(sessionID)
|
|
801
832
|
}
|
|
802
833
|
|
|
803
834
|
function focusGoal(sessionID, goal) {
|
|
804
835
|
goalStates.set(sessionID, goal)
|
|
836
|
+
markSessionMutation(sessionID)
|
|
805
837
|
}
|
|
806
838
|
|
|
807
839
|
function pauseGoalClock(goal, timestamp = Date.now()) {
|
|
@@ -858,6 +890,10 @@ function cleanupGoal(sessionID) {
|
|
|
858
890
|
}
|
|
859
891
|
goalStates.delete(sessionID)
|
|
860
892
|
activeContinues.delete(sessionID)
|
|
893
|
+
// Increment even when no focused goal remains. A concurrent clear of a
|
|
894
|
+
// provisional completion is otherwise indistinguishable from unrelated
|
|
895
|
+
// global result-retention pruning while its terminal write is in flight.
|
|
896
|
+
markSessionMutation(sessionID)
|
|
861
897
|
}
|
|
862
898
|
|
|
863
899
|
function clearRuntimeState() {
|
|
@@ -868,6 +904,7 @@ function clearRuntimeState() {
|
|
|
868
904
|
sessionArchive.clear()
|
|
869
905
|
sessionOrdered.clear()
|
|
870
906
|
lastGoalResults.clear()
|
|
907
|
+
sessionMutationVersions.clear()
|
|
871
908
|
seenTokens.clear()
|
|
872
909
|
seenUsage.clear()
|
|
873
910
|
seenOutputTokens.clear()
|
|
@@ -908,6 +945,7 @@ function clearSessionRuntimeState(
|
|
|
908
945
|
runtime.sessionStatuses.delete(sessionID)
|
|
909
946
|
if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID)
|
|
910
947
|
runtime.passiveSessions.delete(sessionID)
|
|
948
|
+
markSessionMutation(sessionID)
|
|
911
949
|
if (!preserveCommandSecurity) {
|
|
912
950
|
runtime.pendingCommandTurns.delete(sessionID)
|
|
913
951
|
runtime.activeCommandTurns.delete(sessionID)
|
|
@@ -966,16 +1004,60 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
|
|
|
966
1004
|
lastGoalResults.delete(sessionID)
|
|
967
1005
|
lastGoalResults.set(sessionID, result)
|
|
968
1006
|
// Keep a per-session archive so completed goals stay readable via /goal list.
|
|
969
|
-
|
|
1007
|
+
const archivedResult = { ...result }
|
|
1008
|
+
archiveSessionResult(sessionID, archivedResult)
|
|
970
1009
|
pruneGoalResults(goal.options)
|
|
1010
|
+
markSessionMutation(sessionID)
|
|
1011
|
+
return { lastResult: result, archivedResult }
|
|
971
1012
|
}
|
|
972
1013
|
|
|
973
|
-
function
|
|
974
|
-
|
|
1014
|
+
function captureFocusedGoalSnapshot(sessionID) {
|
|
1015
|
+
const goal = goalStates.get(sessionID) || null
|
|
1016
|
+
return {
|
|
1017
|
+
goal,
|
|
1018
|
+
serialized: goal ? JSON.stringify(serializeGoal(goal)) : "",
|
|
1019
|
+
mutationVersion: sessionMutationVersions.get(sessionID) || 0,
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function focusedGoalSnapshotIsCurrent(sessionID, snapshot) {
|
|
1024
|
+
const current = goalStates.get(sessionID) || null
|
|
1025
|
+
if (current !== snapshot?.goal) return false
|
|
1026
|
+
if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false
|
|
1027
|
+
return !current || JSON.stringify(serializeGoal(current)) === snapshot.serialized
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function restoreAfterTerminalPersistenceFailure(
|
|
1031
|
+
sessionID,
|
|
1032
|
+
goal,
|
|
1033
|
+
{ ordered = false, expectedCurrentSnapshot, expectedResult } = {},
|
|
1034
|
+
) {
|
|
1035
|
+
// A terminal write can yield while another command replaces, edits, pauses,
|
|
1036
|
+
// resumes, clears, or advances the session. Never roll the old goal back over
|
|
1037
|
+
// that newer state. The per-session mutation version catches a concurrent
|
|
1038
|
+
// clear even when both the expected and current focused goal are null, while
|
|
1039
|
+
// remaining unaffected by result-retention pruning in a different session.
|
|
1040
|
+
const expectedLastResult = expectedResult?.lastResult || expectedResult
|
|
1041
|
+
const expectedArchivedResult = expectedResult?.archivedResult
|
|
1042
|
+
const canRestore =
|
|
1043
|
+
!expectedCurrentSnapshot ||
|
|
1044
|
+
focusedGoalSnapshotIsCurrent(sessionID, expectedCurrentSnapshot)
|
|
1045
|
+
|
|
1046
|
+
// Remove only this failed provisional completion record. A newer concurrent
|
|
1047
|
+
// result/archive entry belongs to the newer operation and must survive.
|
|
1048
|
+
if (expectedLastResult && lastGoalResults.get(sessionID) === expectedLastResult) {
|
|
1049
|
+
lastGoalResults.delete(sessionID)
|
|
1050
|
+
}
|
|
975
1051
|
const archived = sessionArchive.get(sessionID) || []
|
|
976
|
-
if (
|
|
1052
|
+
if (expectedArchivedResult) {
|
|
1053
|
+
const retained = archived.filter((entry) => entry !== expectedArchivedResult)
|
|
1054
|
+
if (retained.length) sessionArchive.set(sessionID, retained)
|
|
1055
|
+
else sessionArchive.delete(sessionID)
|
|
1056
|
+
} else if (archived.length) {
|
|
977
1057
|
sessionArchive.set(sessionID, archived.slice(0, -1))
|
|
978
1058
|
}
|
|
1059
|
+
|
|
1060
|
+
if (!canRestore) return false
|
|
979
1061
|
const prematurelyPromoted = goalStates.get(sessionID)
|
|
980
1062
|
if (prematurelyPromoted && prematurelyPromoted.goalId !== goal.goalId) {
|
|
981
1063
|
prematurelyPromoted.stopped = true
|
|
@@ -990,6 +1072,7 @@ function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = fal
|
|
|
990
1072
|
goal.lastStatus = "Terminal state could not be persisted. Goal kept paused; fix storage and retry."
|
|
991
1073
|
registerSessionGoal(goal)
|
|
992
1074
|
focusGoal(sessionID, goal)
|
|
1075
|
+
return true
|
|
993
1076
|
}
|
|
994
1077
|
|
|
995
1078
|
function resetGoalBudget(goal) {
|
|
@@ -1543,15 +1626,15 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
|
|
|
1543
1626
|
}
|
|
1544
1627
|
|
|
1545
1628
|
// After applyParsedStateFile loads goals into goalStates, check the ledger for
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1629
|
+
// state transitions that landed after the snapshot. Completed/cleared goals are
|
|
1630
|
+
// removed so they cannot be re-driven, while a newer blocked event is overlaid
|
|
1631
|
+
// so its state and concrete reason survive a failed snapshot write.
|
|
1549
1632
|
async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1550
1633
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1551
1634
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1552
1635
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1553
1636
|
})
|
|
1554
|
-
if (!entries.length) return
|
|
1637
|
+
if (!entries.length) return { removed: 0, blocked: 0 }
|
|
1555
1638
|
|
|
1556
1639
|
const terminalGoals = new Set()
|
|
1557
1640
|
for (const entry of entries) {
|
|
@@ -1564,16 +1647,69 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
|
|
|
1564
1647
|
terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
|
|
1565
1648
|
}
|
|
1566
1649
|
}
|
|
1567
|
-
if (!terminalGoals.size) return
|
|
1568
|
-
|
|
1569
1650
|
let removed = 0
|
|
1651
|
+
let blocked = 0
|
|
1570
1652
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1571
1653
|
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1572
1654
|
for (const goal of [...goals.values()]) {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1655
|
+
const key = `${sessionID}\0${goal.goalId}`
|
|
1656
|
+
if (terminalGoals.has(key)) {
|
|
1657
|
+
removeSessionGoal(sessionID, goal.goalId)
|
|
1658
|
+
if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
|
|
1659
|
+
removed += 1
|
|
1660
|
+
continue
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const persistedHistory = (goal.history || []).filter((event) => event.type !== "recovered")
|
|
1664
|
+
const latestPersistedTimestamp = persistedHistory.reduce(
|
|
1665
|
+
(latest, event) => Math.max(latest, normalizeTimestamp(event.timestamp, 0)),
|
|
1666
|
+
0,
|
|
1667
|
+
)
|
|
1668
|
+
let latestLedgerState = null
|
|
1669
|
+
let latestLedgerTimestamp = -1
|
|
1670
|
+
for (const entry of entries) {
|
|
1671
|
+
if (entry.sessionID !== sessionID || entry.goalId !== goal.goalId || entry.type === "recovered") continue
|
|
1672
|
+
const timestamp = normalizeTimestamp(entry.ts, 0)
|
|
1673
|
+
if (timestamp < latestPersistedTimestamp) continue
|
|
1674
|
+
const detail = summarizeText(entry.detail, 400)
|
|
1675
|
+
const alreadyApplied = persistedHistory.some(
|
|
1676
|
+
(event) =>
|
|
1677
|
+
event.type === entry.type &&
|
|
1678
|
+
normalizeTimestamp(event.timestamp, 0) === timestamp &&
|
|
1679
|
+
event.detail === detail,
|
|
1680
|
+
)
|
|
1681
|
+
if (timestamp >= latestLedgerTimestamp) {
|
|
1682
|
+
latestLedgerState = { entry, alreadyApplied }
|
|
1683
|
+
latestLedgerTimestamp = timestamp
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (
|
|
1687
|
+
latestLedgerState?.alreadyApplied ||
|
|
1688
|
+
latestLedgerState?.entry?.type !== "blocked" ||
|
|
1689
|
+
latestLedgerState.entry.snapshot?.stopped !== true ||
|
|
1690
|
+
latestLedgerState.entry.snapshot?.stopReason !== "blocked"
|
|
1691
|
+
) continue
|
|
1692
|
+
|
|
1693
|
+
const reason = summarizeText(
|
|
1694
|
+
latestLedgerState.entry.snapshot?.blockedReason || latestLedgerState.entry.detail,
|
|
1695
|
+
MAX_GOAL_BLOCKER_LENGTH,
|
|
1696
|
+
)
|
|
1697
|
+
if (!reason) continue
|
|
1698
|
+
goal.stopped = true
|
|
1699
|
+
goal.stopReason = "blocked"
|
|
1700
|
+
goal.blockedReason = reason
|
|
1701
|
+
goal.lastStatus = "Recovered blocked goal state from the lifecycle ledger after the saved snapshot lagged behind."
|
|
1702
|
+
goal.continuationClaim = null
|
|
1703
|
+
goal.history = [
|
|
1704
|
+
...(goal.history || []),
|
|
1705
|
+
makeHistoryEntry(
|
|
1706
|
+
"blocked",
|
|
1707
|
+
reason,
|
|
1708
|
+
normalizeTimestamp(latestLedgerState.entry.ts),
|
|
1709
|
+
),
|
|
1710
|
+
].slice(-MAX_HISTORY_ENTRIES)
|
|
1711
|
+
pauseGoalClock(goal)
|
|
1712
|
+
blocked += 1
|
|
1577
1713
|
}
|
|
1578
1714
|
if (!goalStates.has(sessionID) && sessionOrdered.has(sessionID) && goals.size > 0) {
|
|
1579
1715
|
promoteNextOrderedGoal(sessionID)
|
|
@@ -1585,6 +1721,13 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
|
|
|
1585
1721
|
`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).`,
|
|
1586
1722
|
)
|
|
1587
1723
|
}
|
|
1724
|
+
if (blocked > 0) {
|
|
1725
|
+
await logPluginError(
|
|
1726
|
+
client,
|
|
1727
|
+
`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).`,
|
|
1728
|
+
)
|
|
1729
|
+
}
|
|
1730
|
+
return { removed, blocked }
|
|
1588
1731
|
}
|
|
1589
1732
|
|
|
1590
1733
|
async function pathExists(path) {
|
|
@@ -1810,8 +1953,8 @@ async function loadPersistedSessionState(persistence, client, sessionID) {
|
|
|
1810
1953
|
const state = await readPersistedStateFile(persistence.stateFilePath, client)
|
|
1811
1954
|
if (state.status === "loaded") {
|
|
1812
1955
|
await applyParsedStateFile(state.raw, client, sessionID)
|
|
1813
|
-
await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1814
|
-
return "loaded"
|
|
1956
|
+
const reconciliation = await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1957
|
+
return reconciliation.blocked > 0 ? "reconciled-blocked" : "loaded"
|
|
1815
1958
|
}
|
|
1816
1959
|
const recovered = await reconstructFromLedger(persistence, client, sessionID)
|
|
1817
1960
|
if (state.status === "invalid" && recovered === "reconstructed") {
|
|
@@ -2802,6 +2945,8 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2802
2945
|
}
|
|
2803
2946
|
|
|
2804
2947
|
const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
|
|
2948
|
+
const AGENT_COMPLETE_SUCCESS = "Goal marked complete and archived."
|
|
2949
|
+
const AGENT_BLOCK_SUCCESS = "Goal marked blocked."
|
|
2805
2950
|
|
|
2806
2951
|
// Programmatic equivalents of the /goal command, exposed to the agent as tools
|
|
2807
2952
|
// Each handler operates on a session id and mutates
|
|
@@ -2810,14 +2955,24 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
|
|
|
2810
2955
|
// result. Goal creation/replacement routes through the multi-goal registry
|
|
2811
2956
|
// (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
|
|
2812
2957
|
// path, so tool-created goals persist and are driven by the idle handler.
|
|
2813
|
-
function buildAgentToolHandlers({
|
|
2958
|
+
function buildAgentToolHandlers({
|
|
2959
|
+
defaultGoalOptions,
|
|
2960
|
+
persist,
|
|
2961
|
+
persistTerminalState = null,
|
|
2962
|
+
completionAuditor = null,
|
|
2963
|
+
completionAuditLabel = "evidence gate only (independent verifier off)",
|
|
2964
|
+
announceAudit = async () => {},
|
|
2965
|
+
auditMessagesEnabled = false,
|
|
2966
|
+
announceLifecycle = () => {},
|
|
2967
|
+
commandName = "goal",
|
|
2968
|
+
}) {
|
|
2814
2969
|
// Use persistTerminalState (which logs on failure) for terminal operations when
|
|
2815
2970
|
// available; fall back to plain persist for callers that don't wire it up (e.g.
|
|
2816
2971
|
// tests using buildAgentToolHandlers directly).
|
|
2817
2972
|
const persistFinal = persistTerminalState || persist
|
|
2818
2973
|
async function getGoal(sessionID) {
|
|
2819
2974
|
const goal = goalStates.get(sessionID)
|
|
2820
|
-
if (goal) return formatStatus(goal)
|
|
2975
|
+
if (goal) return formatStatus(goal, commandName, completionAuditLabel)
|
|
2821
2976
|
const lastResult = lastGoalResults.get(sessionID)
|
|
2822
2977
|
if (lastResult) return formatGoalResult(lastResult)
|
|
2823
2978
|
return "No active goal."
|
|
@@ -2887,12 +3042,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2887
3042
|
// Mirror the `/goal <condition>` replace path: discard the focused goal and
|
|
2888
3043
|
// its saved result, drop any ordered sequence, then register + focus the new
|
|
2889
3044
|
// goal so it persists and the idle handler drives it.
|
|
3045
|
+
const replacedGoal = goalStates.get(sessionID)
|
|
2890
3046
|
sessionOrdered.delete(sessionID)
|
|
2891
3047
|
cleanupGoal(sessionID)
|
|
2892
3048
|
lastGoalResults.delete(sessionID)
|
|
2893
3049
|
registerSessionGoal(goal)
|
|
2894
3050
|
focusGoal(sessionID, goal)
|
|
2895
3051
|
await persist(sessionID)
|
|
3052
|
+
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
|
|
3053
|
+
goal,
|
|
3054
|
+
transition: replacedGoal ? "replaced-active" : "active",
|
|
3055
|
+
expectedState: "active",
|
|
3056
|
+
})
|
|
2896
3057
|
// Escape in the tool result only: goal.condition is stored raw so callers
|
|
2897
3058
|
// that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
|
|
2898
3059
|
// themselves. Escaping here prevents XML metacharacters in user-supplied
|
|
@@ -2920,6 +3081,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2920
3081
|
}
|
|
2921
3082
|
|
|
2922
3083
|
const messages = []
|
|
3084
|
+
let lifecycleNotice = null
|
|
2923
3085
|
|
|
2924
3086
|
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
2925
3087
|
if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) {
|
|
@@ -2938,6 +3100,13 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2938
3100
|
goal.lastStatus = "Goal objective updated."
|
|
2939
3101
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
|
|
2940
3102
|
messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
|
|
3103
|
+
lifecycleNotice = {
|
|
3104
|
+
text: `Goal updated; state remains ${goalDisplayState(goal)}.`,
|
|
3105
|
+
transition: "updated",
|
|
3106
|
+
reason: goalDisplayState(goal),
|
|
3107
|
+
expectedState: goalDisplayState(goal),
|
|
3108
|
+
expectedStopReason: goal.stopped ? goal.stopReason : "",
|
|
3109
|
+
}
|
|
2941
3110
|
}
|
|
2942
3111
|
|
|
2943
3112
|
if (args.status !== undefined) {
|
|
@@ -2950,13 +3119,24 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2950
3119
|
if (!evidence) return "Completion evidence is required before a goal can be archived."
|
|
2951
3120
|
if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH)
|
|
2952
3121
|
return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.`
|
|
3122
|
+
const auditedGoalID = goal.goalId
|
|
3123
|
+
const auditedRunID = goal.runId
|
|
3124
|
+
if (auditMessagesEnabled) {
|
|
3125
|
+
await announceAudit(
|
|
3126
|
+
sessionID,
|
|
3127
|
+
"Auditing goal completion: checking submitted evidence before archiving.",
|
|
3128
|
+
)
|
|
3129
|
+
const goalAfterAnnouncement = activeGoal(sessionID, auditedGoalID, auditedRunID)
|
|
3130
|
+
if (!goalAfterAnnouncement) {
|
|
3131
|
+
return "Completion audit finished after the goal changed; completion was not recorded."
|
|
3132
|
+
}
|
|
3133
|
+
goal = goalAfterAnnouncement
|
|
3134
|
+
}
|
|
2953
3135
|
// If a completion auditor is configured, run it before archiving so the
|
|
2954
3136
|
// agent tool path has the same integrity gate as the [goal:complete] marker
|
|
2955
3137
|
// path. Without this, an autonomous agent could bypass the auditor by
|
|
2956
3138
|
// calling update_goal({status:"complete"}) instead of using the marker.
|
|
2957
3139
|
if (completionAuditor) {
|
|
2958
|
-
const auditedGoalID = goal.goalId
|
|
2959
|
-
const auditedRunID = goal.runId
|
|
2960
3140
|
let verdict
|
|
2961
3141
|
try {
|
|
2962
3142
|
verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
|
|
@@ -2975,6 +3155,25 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2975
3155
|
goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
2976
3156
|
pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
2977
3157
|
await persist(sessionID)
|
|
3158
|
+
const rejectedGoalAfterPersist = currentGoal(sessionID, auditedGoalID, auditedRunID)
|
|
3159
|
+
if (
|
|
3160
|
+
rejectedGoalAfterPersist !== goal ||
|
|
3161
|
+
!goal.stopped ||
|
|
3162
|
+
goal.stopReason !== "audit rejected"
|
|
3163
|
+
) {
|
|
3164
|
+
return "Completion audit was rejected, but the goal changed while that state was persisted; current state was left untouched."
|
|
3165
|
+
}
|
|
3166
|
+
if (auditMessagesEnabled) {
|
|
3167
|
+
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
3168
|
+
} else {
|
|
3169
|
+
announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
|
|
3170
|
+
goal,
|
|
3171
|
+
transition: "audit-rejected",
|
|
3172
|
+
reason,
|
|
3173
|
+
expectedState: "paused",
|
|
3174
|
+
expectedStopReason: "audit rejected",
|
|
3175
|
+
})
|
|
3176
|
+
}
|
|
2978
3177
|
return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
|
|
2979
3178
|
}
|
|
2980
3179
|
}
|
|
@@ -2985,16 +3184,72 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2985
3184
|
evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.",
|
|
2986
3185
|
)
|
|
2987
3186
|
const ordered = sessionOrdered.has(sessionID)
|
|
2988
|
-
rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
3187
|
+
const completedResult = rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
2989
3188
|
cleanupGoal(sessionID)
|
|
2990
3189
|
// Advance an ordered sequence just like the marker path does.
|
|
2991
|
-
|
|
3190
|
+
const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
|
|
3191
|
+
const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
|
|
2992
3192
|
const durable = await persistFinal(sessionID, "completion", ledgerDurable)
|
|
2993
3193
|
if (durable === false) {
|
|
2994
|
-
restoreAfterTerminalPersistenceFailure(sessionID, goal, {
|
|
2995
|
-
|
|
3194
|
+
const restored = restoreAfterTerminalPersistenceFailure(sessionID, goal, {
|
|
3195
|
+
ordered,
|
|
3196
|
+
expectedCurrentSnapshot: postCompletionSnapshot,
|
|
3197
|
+
expectedResult: completedResult,
|
|
3198
|
+
})
|
|
3199
|
+
if (auditMessagesEnabled) {
|
|
3200
|
+
await announceAudit(
|
|
3201
|
+
sessionID,
|
|
3202
|
+
restored
|
|
3203
|
+
? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
|
|
3204
|
+
: "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
|
|
3205
|
+
)
|
|
3206
|
+
} else {
|
|
3207
|
+
announceLifecycle(
|
|
3208
|
+
sessionID,
|
|
3209
|
+
restored
|
|
3210
|
+
? "Goal paused — completion could not be recorded durably."
|
|
3211
|
+
: "Previous goal completion could not be confirmed durably after goal state changed.",
|
|
3212
|
+
restored
|
|
3213
|
+
? {
|
|
3214
|
+
goal,
|
|
3215
|
+
transition: "terminal-persistence-failed",
|
|
3216
|
+
reason: goal.stopReason,
|
|
3217
|
+
expectedState: "paused",
|
|
3218
|
+
expectedStopReason: "terminal persistence failed",
|
|
3219
|
+
}
|
|
3220
|
+
: {
|
|
3221
|
+
transition: "terminal-persistence-raced",
|
|
3222
|
+
requireCurrent: false,
|
|
3223
|
+
},
|
|
3224
|
+
)
|
|
3225
|
+
}
|
|
3226
|
+
return restored
|
|
3227
|
+
? "Completion verified, but terminal state could not be persisted. Goal remains paused."
|
|
3228
|
+
: "Completion verified, but its terminal state could not be persisted before the goal changed. Current state was left untouched."
|
|
2996
3229
|
}
|
|
2997
|
-
|
|
3230
|
+
const activePromoted = promoted
|
|
3231
|
+
? activeGoal(sessionID, promoted.goalId, promoted.runId)
|
|
3232
|
+
: null
|
|
3233
|
+
if (auditMessagesEnabled) {
|
|
3234
|
+
await announceAudit(
|
|
3235
|
+
sessionID,
|
|
3236
|
+
activePromoted
|
|
3237
|
+
? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
|
|
3238
|
+
: "Audit result: completion accepted — goal archived as achieved.",
|
|
3239
|
+
)
|
|
3240
|
+
} else {
|
|
3241
|
+
announceLifecycle(
|
|
3242
|
+
sessionID,
|
|
3243
|
+
activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
|
|
3244
|
+
{
|
|
3245
|
+
goal: activePromoted || goal,
|
|
3246
|
+
transition: activePromoted ? "achieved-promoted" : "achieved",
|
|
3247
|
+
requireCurrent: Boolean(activePromoted),
|
|
3248
|
+
expectedState: activePromoted ? "active" : "",
|
|
3249
|
+
},
|
|
3250
|
+
)
|
|
3251
|
+
}
|
|
3252
|
+
return AGENT_COMPLETE_SUCCESS
|
|
2998
3253
|
}
|
|
2999
3254
|
if (status === "blocked") {
|
|
3000
3255
|
const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
|
|
@@ -3002,18 +3257,80 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3002
3257
|
return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
|
|
3003
3258
|
if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH)
|
|
3004
3259
|
return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.`
|
|
3260
|
+
const blockedGoalID = goal.goalId
|
|
3261
|
+
const blockedRunID = goal.runId
|
|
3262
|
+
if (auditMessagesEnabled) {
|
|
3263
|
+
await announceAudit(
|
|
3264
|
+
sessionID,
|
|
3265
|
+
"Auditing goal blocker: checking the submitted blocker before pausing.",
|
|
3266
|
+
)
|
|
3267
|
+
const goalAfterAnnouncement = activeGoal(sessionID, blockedGoalID, blockedRunID)
|
|
3268
|
+
if (!goalAfterAnnouncement) {
|
|
3269
|
+
return "Blocker audit finished after the goal changed; blocked state was not recorded."
|
|
3270
|
+
}
|
|
3271
|
+
goal = goalAfterAnnouncement
|
|
3272
|
+
}
|
|
3005
3273
|
goal.blockedReason = blockerText
|
|
3006
3274
|
goal.stopped = true
|
|
3007
3275
|
goal.stopReason = "blocked"
|
|
3008
3276
|
goal.lastStatus = "Assistant reported blocked."
|
|
3009
|
-
pushHistory(goal, "blocked", goal.blockedReason)
|
|
3010
|
-
messages.push(
|
|
3277
|
+
const ledgerDurable = pushHistory(goal, "blocked", goal.blockedReason)
|
|
3278
|
+
messages.push(AGENT_BLOCK_SUCCESS)
|
|
3279
|
+
const durable = await persistFinal(sessionID, "blocked", ledgerDurable)
|
|
3280
|
+
const blockedGoalAfterPersist = currentGoal(sessionID, blockedGoalID, blockedRunID)
|
|
3281
|
+
if (blockedGoalAfterPersist !== goal || goal.stopReason !== "blocked") {
|
|
3282
|
+
return "Blocked state changed while persistence completed; blocked state was not reported."
|
|
3283
|
+
}
|
|
3284
|
+
if (durable === false) {
|
|
3285
|
+
goal.stopReason = "terminal persistence failed"
|
|
3286
|
+
goal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
3287
|
+
if (auditMessagesEnabled) {
|
|
3288
|
+
await announceAudit(
|
|
3289
|
+
sessionID,
|
|
3290
|
+
"Audit result: blocker recognized, but storage failed; goal remains paused.",
|
|
3291
|
+
)
|
|
3292
|
+
} else {
|
|
3293
|
+
announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
|
|
3294
|
+
goal,
|
|
3295
|
+
transition: "terminal-persistence-failed",
|
|
3296
|
+
expectedState: "paused",
|
|
3297
|
+
expectedStopReason: "terminal persistence failed",
|
|
3298
|
+
})
|
|
3299
|
+
}
|
|
3300
|
+
return "Blocker recognized, but terminal state could not be persisted. Goal remains paused."
|
|
3301
|
+
}
|
|
3302
|
+
if (auditMessagesEnabled) {
|
|
3303
|
+
await announceAudit(
|
|
3304
|
+
sessionID,
|
|
3305
|
+
`Audit result: goal paused as blocked — ${summarizeText(blockerText, 160)}. Run /${commandName} resume after addressing it.`,
|
|
3306
|
+
)
|
|
3307
|
+
} else {
|
|
3308
|
+
announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
|
|
3309
|
+
goal,
|
|
3310
|
+
transition: "blocked",
|
|
3311
|
+
expectedState: "blocked",
|
|
3312
|
+
expectedStopReason: "blocked",
|
|
3313
|
+
})
|
|
3314
|
+
}
|
|
3315
|
+
return messages.join(" ")
|
|
3011
3316
|
} else if (status === "paused") {
|
|
3012
|
-
goal.stopped
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3317
|
+
if (goal.stopped && goal.stopReason === "paused") {
|
|
3318
|
+
if (!messages.length) return "Goal is already paused."
|
|
3319
|
+
messages.push("Goal is already paused.")
|
|
3320
|
+
} else {
|
|
3321
|
+
goal.stopped = true
|
|
3322
|
+
goal.stopReason = "paused"
|
|
3323
|
+
goal.lastStatus = "Goal paused."
|
|
3324
|
+
pushHistory(goal, "paused", "Paused via agent tool.")
|
|
3325
|
+
messages.push("Goal paused.")
|
|
3326
|
+
lifecycleNotice = {
|
|
3327
|
+
text: "Goal paused.",
|
|
3328
|
+
transition: "paused",
|
|
3329
|
+
reason: goal.stopReason,
|
|
3330
|
+
expectedState: "paused",
|
|
3331
|
+
expectedStopReason: "paused",
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3017
3334
|
} else if (status === "resumed") {
|
|
3018
3335
|
if (!goal.stopped)
|
|
3019
3336
|
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
|
|
|
3027
3344
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
3028
3345
|
pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.")
|
|
3029
3346
|
messages.push("Goal resumed with fresh limits.")
|
|
3347
|
+
lifecycleNotice = {
|
|
3348
|
+
text: "Goal resumed with fresh limits.",
|
|
3349
|
+
transition: "resumed",
|
|
3350
|
+
expectedState: "active",
|
|
3351
|
+
}
|
|
3030
3352
|
}
|
|
3031
3353
|
}
|
|
3032
3354
|
|
|
@@ -3034,6 +3356,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3034
3356
|
return "Nothing to update. Provide `objective` and/or `status`."
|
|
3035
3357
|
}
|
|
3036
3358
|
await persist(sessionID)
|
|
3359
|
+
if (lifecycleNotice) {
|
|
3360
|
+
announceLifecycle(sessionID, lifecycleNotice.text, {
|
|
3361
|
+
goal,
|
|
3362
|
+
transition: lifecycleNotice.transition,
|
|
3363
|
+
reason: lifecycleNotice.reason,
|
|
3364
|
+
expectedState: lifecycleNotice.expectedState,
|
|
3365
|
+
expectedStopReason: lifecycleNotice.expectedStopReason,
|
|
3366
|
+
})
|
|
3367
|
+
}
|
|
3037
3368
|
return messages.join(" ")
|
|
3038
3369
|
}
|
|
3039
3370
|
|
|
@@ -3042,15 +3373,33 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3042
3373
|
// focused goal + result. Without sessionGoals.delete, background goals added via
|
|
3043
3374
|
// `/goal add` survive clear and resurrect as the focused goal on restart.
|
|
3044
3375
|
// Record the clear in the ledger before cleanupGoal removes the goal object.
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3376
|
+
const goals = listSessionGoals(sessionID)
|
|
3377
|
+
const clearedGoal = goalStates.get(sessionID) || goals[0] || null
|
|
3378
|
+
const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
|
|
3379
|
+
const ledgerDurable =
|
|
3380
|
+
goals.length > 0 &&
|
|
3381
|
+
goals.map((goal) => pushHistory(goal, "cleared", "Cleared via agent tool.")).every(Boolean)
|
|
3048
3382
|
sessionOrdered.delete(sessionID)
|
|
3049
3383
|
sessionGoals.delete(sessionID)
|
|
3050
3384
|
cleanupGoal(sessionID)
|
|
3051
3385
|
lastGoalResults.delete(sessionID)
|
|
3052
|
-
await persistFinal(sessionID, "clear")
|
|
3053
|
-
|
|
3386
|
+
const durable = await persistFinal(sessionID, "clear", ledgerDurable)
|
|
3387
|
+
const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
|
|
3388
|
+
if (hadState && clearStillCurrent) {
|
|
3389
|
+
announceLifecycle(sessionID, durable === false
|
|
3390
|
+
? "Goal cleared in memory, but storage failed; it may reappear after restart."
|
|
3391
|
+
: "Goal cleared.", {
|
|
3392
|
+
goal: clearedGoal,
|
|
3393
|
+
transition: durable === false ? "clear-persistence-failed" : "cleared",
|
|
3394
|
+
requireCurrent: false,
|
|
3395
|
+
})
|
|
3396
|
+
}
|
|
3397
|
+
if (!clearStillCurrent) {
|
|
3398
|
+
return "Clear persistence finished after goal state changed; current state was left untouched."
|
|
3399
|
+
}
|
|
3400
|
+
return durable === false
|
|
3401
|
+
? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
|
|
3402
|
+
: "Goal cleared."
|
|
3054
3403
|
}
|
|
3055
3404
|
|
|
3056
3405
|
return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
|
|
@@ -3175,8 +3524,22 @@ function buildAgentTools(
|
|
|
3175
3524
|
return goalToolFailure("already_running", "Goal is already running.")
|
|
3176
3525
|
}
|
|
3177
3526
|
const message = await handlers.updateGoal(sessionID, args)
|
|
3178
|
-
if (args.status === "complete"
|
|
3179
|
-
|
|
3527
|
+
if (args.status === "complete") {
|
|
3528
|
+
if (
|
|
3529
|
+
message !== AGENT_COMPLETE_SUCCESS ||
|
|
3530
|
+
currentGoal(sessionID, before.goalId, before.runId)
|
|
3531
|
+
) {
|
|
3532
|
+
return goalToolFailure("completion_rejected", message)
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
if (args.status === "blocked") {
|
|
3536
|
+
const after = currentGoal(sessionID, before.goalId, before.runId)
|
|
3537
|
+
if (after !== before) {
|
|
3538
|
+
return goalToolFailure("goal_changed", message)
|
|
3539
|
+
}
|
|
3540
|
+
if (message !== AGENT_BLOCK_SUCCESS || !after.stopped || after.stopReason !== "blocked") {
|
|
3541
|
+
return goalToolFailure("block_rejected", message)
|
|
3542
|
+
}
|
|
3180
3543
|
}
|
|
3181
3544
|
return goalToolSuccess(message)
|
|
3182
3545
|
},
|
|
@@ -3296,8 +3659,14 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
3296
3659
|
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
|
|
3297
3660
|
goals.forEach((goal, index) => {
|
|
3298
3661
|
const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
|
|
3299
|
-
const state = goal
|
|
3300
|
-
|
|
3662
|
+
const state = goalDisplayState(goal)
|
|
3663
|
+
const reason = state === "blocked"
|
|
3664
|
+
? goal.blockedReason || goal.stopReason
|
|
3665
|
+
: goal.stopped
|
|
3666
|
+
? goal.stopReason
|
|
3667
|
+
: ""
|
|
3668
|
+
const reasonText = reason ? ` (${summarizeText(reason, 160)})` : ""
|
|
3669
|
+
lines.push(`${index + 1}. [${marker}] ${goal.condition} — state: ${state}${reasonText}`)
|
|
3301
3670
|
})
|
|
3302
3671
|
lines.push(`Switch with \`/${commandName} focus <number>\`.`)
|
|
3303
3672
|
} else {
|
|
@@ -3343,6 +3712,37 @@ async function defaultAuditMessenger(client, sessionID, text) {
|
|
|
3343
3712
|
}
|
|
3344
3713
|
}
|
|
3345
3714
|
|
|
3715
|
+
// High-signal lifecycle feedback uses the same non-blocking host surfaces as
|
|
3716
|
+
// audit notices, but remains a separate channel so callers can configure each
|
|
3717
|
+
// independently. Messages are normalized and bounded before they reach either
|
|
3718
|
+
// host API; goal objectives, evidence, and workspace paths are deliberately
|
|
3719
|
+
// excluded by transition call sites.
|
|
3720
|
+
async function defaultLifecycleMessenger(client, sessionID, text) {
|
|
3721
|
+
const message = summarizeText(text, 500)
|
|
3722
|
+
const warning = /\b(?:paused|blocked|recovered|failed|passive)\b/i.test(message)
|
|
3723
|
+
const success = /\b(?:achieved|completed)\b/i.test(message)
|
|
3724
|
+
if (client?.app?.log) {
|
|
3725
|
+
dispatchAdvisoryHostCall(() => client.app.log({
|
|
3726
|
+
body: {
|
|
3727
|
+
service: "opencode-goal-plugin",
|
|
3728
|
+
level: warning ? "warn" : "info",
|
|
3729
|
+
message,
|
|
3730
|
+
extra: { sessionID, kind: "goal-lifecycle" },
|
|
3731
|
+
},
|
|
3732
|
+
}))
|
|
3733
|
+
}
|
|
3734
|
+
if (client?.tui?.showToast) {
|
|
3735
|
+
dispatchAdvisoryHostCall(() => client.tui.showToast({
|
|
3736
|
+
body: {
|
|
3737
|
+
title: "Goal workflow",
|
|
3738
|
+
message,
|
|
3739
|
+
variant: warning ? "warning" : success ? "success" : "info",
|
|
3740
|
+
duration: 6000,
|
|
3741
|
+
},
|
|
3742
|
+
}))
|
|
3743
|
+
}
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3346
3746
|
// Completion auditor. When an auditor is configured, a [goal:complete]
|
|
3347
3747
|
// is verified before the goal is archived: an approved verdict archives it, a
|
|
3348
3748
|
// rejected verdict restores the goal (pauses it with the reason) instead of
|
|
@@ -3499,6 +3899,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3499
3899
|
return persistence.persistChain
|
|
3500
3900
|
}
|
|
3501
3901
|
|
|
3902
|
+
const lifecycleMessagesEnabled = pluginOptions.lifecycleMessages !== false
|
|
3903
|
+
const lifecycleMessenger =
|
|
3904
|
+
typeof pluginOptions.lifecycleMessenger === "function"
|
|
3905
|
+
? pluginOptions.lifecycleMessenger
|
|
3906
|
+
: (sessionID, text) => defaultLifecycleMessenger(client, sessionID, text)
|
|
3907
|
+
const announceLifecycle = (
|
|
3908
|
+
sessionID,
|
|
3909
|
+
text,
|
|
3910
|
+
{
|
|
3911
|
+
goal,
|
|
3912
|
+
transition = "state",
|
|
3913
|
+
reason = "",
|
|
3914
|
+
requireCurrent = true,
|
|
3915
|
+
expectedState = "",
|
|
3916
|
+
expectedStopReason = "",
|
|
3917
|
+
} = {},
|
|
3918
|
+
) => {
|
|
3919
|
+
if (!lifecycleMessagesEnabled || !sessionID) return false
|
|
3920
|
+
if (requireCurrent && goal) {
|
|
3921
|
+
const current = goalStates.get(sessionID)
|
|
3922
|
+
if (current !== goal) return false
|
|
3923
|
+
if (expectedState && goalDisplayState(current) !== expectedState) return false
|
|
3924
|
+
if (expectedStopReason && current.stopReason !== expectedStopReason) return false
|
|
3925
|
+
}
|
|
3926
|
+
const message = summarizeText(text, 500)
|
|
3927
|
+
if (!message) return false
|
|
3928
|
+
dispatchAdvisoryHostCall(
|
|
3929
|
+
() => lifecycleMessenger(sessionID, message),
|
|
3930
|
+
(error) => {
|
|
3931
|
+
void logPluginError(client, "Failed to deliver goal lifecycle message", error).catch(() => {})
|
|
3932
|
+
},
|
|
3933
|
+
)
|
|
3934
|
+
return true
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3502
3937
|
const passiveLoadResult = (entry) => ({
|
|
3503
3938
|
kind: "passive",
|
|
3504
3939
|
code: SESSION_OWNED_ELSEWHERE,
|
|
@@ -3596,7 +4031,40 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3596
4031
|
const status = await loadPersistedSessionState(persistence, client, sessionID)
|
|
3597
4032
|
if (runtime.disposed) return releaseDisposedSession()
|
|
3598
4033
|
pruneGoalResults(defaultGoalOptions)
|
|
3599
|
-
if (
|
|
4034
|
+
if (
|
|
4035
|
+
status === "loaded" ||
|
|
4036
|
+
status === "missing" ||
|
|
4037
|
+
status === "reconstructed" ||
|
|
4038
|
+
status === "reconciled-blocked"
|
|
4039
|
+
) await persist(sessionID)
|
|
4040
|
+
const recoveredGoal = goalStates.get(sessionID)
|
|
4041
|
+
if (recoveredGoal?.stopped && recoveredGoal.stopReason === "recovered after restart") {
|
|
4042
|
+
announceLifecycle(sessionID, `Goal recovered and paused. Run /${commandName} status, then /${commandName} resume when ready.`, {
|
|
4043
|
+
goal: recoveredGoal,
|
|
4044
|
+
transition: "recovered-paused",
|
|
4045
|
+
reason: recoveredGoal.stopReason,
|
|
4046
|
+
expectedState: "paused",
|
|
4047
|
+
expectedStopReason: "recovered after restart",
|
|
4048
|
+
})
|
|
4049
|
+
} else if (
|
|
4050
|
+
status === "reconciled-blocked" &&
|
|
4051
|
+
recoveredGoal?.stopped &&
|
|
4052
|
+
recoveredGoal.stopReason === "blocked"
|
|
4053
|
+
) {
|
|
4054
|
+
announceLifecycle(sessionID, `Goal recovered as blocked. Run /${commandName} status for the reason.`, {
|
|
4055
|
+
goal: recoveredGoal,
|
|
4056
|
+
transition: "recovered-blocked",
|
|
4057
|
+
reason: recoveredGoal.blockedReason,
|
|
4058
|
+
expectedState: "blocked",
|
|
4059
|
+
expectedStopReason: "blocked",
|
|
4060
|
+
})
|
|
4061
|
+
} else if (recoveredGoal?.lastStatus === "Promoted as the next ordered goal.") {
|
|
4062
|
+
announceLifecycle(sessionID, "Goal state recovered; the next ordered goal is active.", {
|
|
4063
|
+
goal: recoveredGoal,
|
|
4064
|
+
transition: "recovered-promoted",
|
|
4065
|
+
expectedState: "active",
|
|
4066
|
+
})
|
|
4067
|
+
}
|
|
3600
4068
|
if (runtime.disposed) return releaseDisposedSession()
|
|
3601
4069
|
return ACTIVE_PERSISTENCE_OWNED
|
|
3602
4070
|
} catch (error) {
|
|
@@ -3681,6 +4149,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3681
4149
|
reason: "owned verifier agent registration was not confirmed",
|
|
3682
4150
|
})
|
|
3683
4151
|
: null
|
|
4152
|
+
const completionAuditLabel =
|
|
4153
|
+
typeof pluginOptions.auditor === "function"
|
|
4154
|
+
? "custom completion auditor"
|
|
4155
|
+
: pluginOptions.completionAudit
|
|
4156
|
+
? "built-in independent verifier"
|
|
4157
|
+
: "evidence gate only (independent verifier off)"
|
|
3684
4158
|
|
|
3685
4159
|
clearRuntimeState()
|
|
3686
4160
|
|
|
@@ -3689,6 +4163,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3689
4163
|
persist,
|
|
3690
4164
|
persistTerminalState,
|
|
3691
4165
|
completionAuditor,
|
|
4166
|
+
completionAuditLabel,
|
|
4167
|
+
announceAudit,
|
|
4168
|
+
auditMessagesEnabled,
|
|
4169
|
+
announceLifecycle,
|
|
3692
4170
|
commandName,
|
|
3693
4171
|
})
|
|
3694
4172
|
|
|
@@ -3714,6 +4192,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3714
4192
|
) => {
|
|
3715
4193
|
const goal = goalStates.get(sessionID)
|
|
3716
4194
|
if (!goal) return false
|
|
4195
|
+
if (goal.stopped && goal.stopReason === reason) return false
|
|
3717
4196
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
3718
4197
|
goal.stopped = true
|
|
3719
4198
|
goal.stopReason = reason
|
|
@@ -3722,6 +4201,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3722
4201
|
pushHistory(goal, "paused", history)
|
|
3723
4202
|
activeContinues.delete(sessionID)
|
|
3724
4203
|
await persist(sessionID)
|
|
4204
|
+
announceLifecycle(sessionID, `Goal paused — ${summarizeText(reason, 160)}.`, {
|
|
4205
|
+
goal,
|
|
4206
|
+
transition: "paused",
|
|
4207
|
+
reason,
|
|
4208
|
+
expectedState: "paused",
|
|
4209
|
+
expectedStopReason: reason,
|
|
4210
|
+
})
|
|
3725
4211
|
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3726
4212
|
return true
|
|
3727
4213
|
}
|
|
@@ -3798,6 +4284,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3798
4284
|
goal.stopReason = "continuation claim persistence failed"
|
|
3799
4285
|
goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
|
|
3800
4286
|
pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
|
|
4287
|
+
announceLifecycle(sessionID, "Goal paused — continuation state could not be persisted.", {
|
|
4288
|
+
goal,
|
|
4289
|
+
transition: "continuation-persistence-failed",
|
|
4290
|
+
reason: goal.stopReason,
|
|
4291
|
+
expectedState: "paused",
|
|
4292
|
+
expectedStopReason: "continuation claim persistence failed",
|
|
4293
|
+
})
|
|
3801
4294
|
return null
|
|
3802
4295
|
}
|
|
3803
4296
|
return goal
|
|
@@ -3992,7 +4485,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3992
4485
|
replaceCommandOutputText(
|
|
3993
4486
|
output,
|
|
3994
4487
|
goal
|
|
3995
|
-
? formatStatus(goal, commandName)
|
|
4488
|
+
? formatStatus(goal, commandName, completionAuditLabel)
|
|
3996
4489
|
: lastResult
|
|
3997
4490
|
? formatGoalResult(lastResult)
|
|
3998
4491
|
: `No active goal. Set one with \`/${commandName} <condition>\`.`,
|
|
@@ -4033,15 +4526,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4033
4526
|
// sessionGoals.delete clears ALL backgrounded goals so they do not
|
|
4034
4527
|
// resurrect as the focused goal on restart (cleanupGoal only removes the
|
|
4035
4528
|
// focused one; background goals from `/goal add` would survive otherwise).
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4529
|
+
const goals = listSessionGoals(sessionID)
|
|
4530
|
+
const clearedGoal = goalStates.get(sessionID) || goals[0] || null
|
|
4531
|
+
const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
|
|
4532
|
+
const ledgerDurable =
|
|
4533
|
+
goals.length > 0 &&
|
|
4534
|
+
goals.map((goal) => pushHistory(goal, "cleared", "User cleared the goal.")).every(Boolean)
|
|
4039
4535
|
sessionOrdered.delete(sessionID)
|
|
4040
4536
|
sessionGoals.delete(sessionID)
|
|
4041
4537
|
cleanupGoal(sessionID)
|
|
4042
4538
|
lastGoalResults.delete(sessionID)
|
|
4043
|
-
await
|
|
4044
|
-
|
|
4539
|
+
const durable = await persistTerminalState(sessionID, "clear", ledgerDurable)
|
|
4540
|
+
const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
|
|
4541
|
+
if (hadState && clearStillCurrent) {
|
|
4542
|
+
announceLifecycle(sessionID, durable === false
|
|
4543
|
+
? "Goal cleared in memory, but storage failed; it may reappear after restart."
|
|
4544
|
+
: "Goal cleared.", {
|
|
4545
|
+
goal: clearedGoal,
|
|
4546
|
+
transition: durable === false ? "clear-persistence-failed" : "cleared",
|
|
4547
|
+
requireCurrent: false,
|
|
4548
|
+
})
|
|
4549
|
+
}
|
|
4550
|
+
replaceCommandOutputText(
|
|
4551
|
+
output,
|
|
4552
|
+
!clearStillCurrent
|
|
4553
|
+
? "Clear persistence finished after goal state changed; current state was left untouched."
|
|
4554
|
+
: durable === false
|
|
4555
|
+
? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
|
|
4556
|
+
: "Goal cleared.",
|
|
4557
|
+
)
|
|
4045
4558
|
return
|
|
4046
4559
|
}
|
|
4047
4560
|
|
|
@@ -4051,6 +4564,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4051
4564
|
replaceCommandOutputText(output, `No active goal. Set one with \`/${commandName} <condition>\`.`)
|
|
4052
4565
|
return
|
|
4053
4566
|
}
|
|
4567
|
+
if (goal.stopped && goal.stopReason === "paused") {
|
|
4568
|
+
replaceCommandOutputText(output, "Goal is already paused.")
|
|
4569
|
+
return
|
|
4570
|
+
}
|
|
4054
4571
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
4055
4572
|
goal.stopped = true
|
|
4056
4573
|
goal.stopReason = "paused"
|
|
@@ -4059,6 +4576,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4059
4576
|
activeContinues.delete(sessionID)
|
|
4060
4577
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
4061
4578
|
await persist(sessionID)
|
|
4579
|
+
announceLifecycle(sessionID, "Goal paused.", {
|
|
4580
|
+
goal,
|
|
4581
|
+
transition: "paused",
|
|
4582
|
+
reason: goal.stopReason,
|
|
4583
|
+
expectedState: "paused",
|
|
4584
|
+
expectedStopReason: "paused",
|
|
4585
|
+
})
|
|
4062
4586
|
await abortAcceptedContinuation(sessionID)
|
|
4063
4587
|
replaceCommandOutputText(output, `Goal paused: ${goal.condition}`)
|
|
4064
4588
|
return
|
|
@@ -4085,6 +4609,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4085
4609
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
4086
4610
|
pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
|
|
4087
4611
|
await persist(sessionID)
|
|
4612
|
+
announceLifecycle(sessionID, "Goal resumed with fresh limits.", {
|
|
4613
|
+
goal,
|
|
4614
|
+
transition: "resumed",
|
|
4615
|
+
expectedState: "active",
|
|
4616
|
+
})
|
|
4088
4617
|
replaceCommandOutputText(output, `Goal resumed with fresh limits: ${goal.condition}`, {
|
|
4089
4618
|
startsWork: true,
|
|
4090
4619
|
})
|
|
@@ -4132,6 +4661,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4132
4661
|
goal.lastStatus = "Goal objective updated."
|
|
4133
4662
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
4134
4663
|
await persist(sessionID)
|
|
4664
|
+
announceLifecycle(sessionID, "Goal updated and active.", {
|
|
4665
|
+
goal,
|
|
4666
|
+
transition: "updated-active",
|
|
4667
|
+
expectedState: "active",
|
|
4668
|
+
})
|
|
4135
4669
|
replaceCommandOutputText(
|
|
4136
4670
|
output,
|
|
4137
4671
|
[
|
|
@@ -4212,6 +4746,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4212
4746
|
focusGoal(sessionID, firstGoal)
|
|
4213
4747
|
sessionOrdered.add(sessionID)
|
|
4214
4748
|
await persist(sessionID)
|
|
4749
|
+
announceLifecycle(sessionID, `Ordered goal sequence active (${objectives.length} goals).`, {
|
|
4750
|
+
goal: firstGoal,
|
|
4751
|
+
transition: "sequence-active",
|
|
4752
|
+
reason: String(objectives.length),
|
|
4753
|
+
expectedState: "active",
|
|
4754
|
+
})
|
|
4215
4755
|
replaceCommandOutputText(
|
|
4216
4756
|
output,
|
|
4217
4757
|
[
|
|
@@ -4277,6 +4817,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4277
4817
|
pushHistory(target, "focused", "Brought into focus as the session's active goal.")
|
|
4278
4818
|
focusGoal(sessionID, target)
|
|
4279
4819
|
await persist(sessionID)
|
|
4820
|
+
announceLifecycle(sessionID, "Goal focus changed; selected goal active.", {
|
|
4821
|
+
goal: target,
|
|
4822
|
+
transition: "focused-active",
|
|
4823
|
+
expectedState: "active",
|
|
4824
|
+
})
|
|
4280
4825
|
replaceCommandOutputText(
|
|
4281
4826
|
output,
|
|
4282
4827
|
[
|
|
@@ -4335,6 +4880,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4335
4880
|
registerSessionGoal(added)
|
|
4336
4881
|
focusGoal(sessionID, added)
|
|
4337
4882
|
await persist(sessionID)
|
|
4883
|
+
announceLifecycle(sessionID, current
|
|
4884
|
+
? "Goal added and active; previous goal backgrounded."
|
|
4885
|
+
: "Goal added and active.", {
|
|
4886
|
+
goal: added,
|
|
4887
|
+
transition: current ? "added-active-backgrounded" : "added-active",
|
|
4888
|
+
expectedState: "active",
|
|
4889
|
+
})
|
|
4338
4890
|
const total = listSessionGoals(sessionID).length
|
|
4339
4891
|
replaceCommandOutputText(
|
|
4340
4892
|
output,
|
|
@@ -4373,6 +4925,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4373
4925
|
registerSessionGoal(goal)
|
|
4374
4926
|
focusGoal(sessionID, goal)
|
|
4375
4927
|
await persist(sessionID)
|
|
4928
|
+
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
|
|
4929
|
+
goal,
|
|
4930
|
+
transition: replacedGoal ? "replaced-active" : "active",
|
|
4931
|
+
expectedState: "active",
|
|
4932
|
+
})
|
|
4376
4933
|
replaceCommandOutputText(
|
|
4377
4934
|
output,
|
|
4378
4935
|
[
|
|
@@ -4740,7 +5297,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4740
5297
|
auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
4741
5298
|
pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
4742
5299
|
await persist(sessionID)
|
|
4743
|
-
|
|
5300
|
+
const rejectedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
|
|
5301
|
+
if (
|
|
5302
|
+
rejectedGoalAfterPersist !== auditedGoal ||
|
|
5303
|
+
!auditedGoal.stopped ||
|
|
5304
|
+
auditedGoal.stopReason !== "audit rejected"
|
|
5305
|
+
) return
|
|
5306
|
+
if (auditMessagesEnabled) {
|
|
5307
|
+
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
5308
|
+
} else {
|
|
5309
|
+
announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
|
|
5310
|
+
goal: auditedGoal,
|
|
5311
|
+
transition: "audit-rejected",
|
|
5312
|
+
reason,
|
|
5313
|
+
expectedState: "paused",
|
|
5314
|
+
expectedStopReason: "audit rejected",
|
|
5315
|
+
})
|
|
5316
|
+
}
|
|
4744
5317
|
return
|
|
4745
5318
|
}
|
|
4746
5319
|
pushHistory(
|
|
@@ -4760,23 +5333,80 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4760
5333
|
`Assistant marked the goal complete with evidence: ${summarizeText(evidence, 400)}`,
|
|
4761
5334
|
)
|
|
4762
5335
|
const ordered = sessionOrdered.has(sessionID)
|
|
4763
|
-
|
|
5336
|
+
const completedResult = rememberGoalResult(
|
|
5337
|
+
sessionID,
|
|
5338
|
+
activeGoalAfterMessages,
|
|
5339
|
+
"achieved",
|
|
5340
|
+
"",
|
|
5341
|
+
evidence,
|
|
5342
|
+
)
|
|
4764
5343
|
cleanupGoal(sessionID)
|
|
4765
5344
|
// Ordered sequence: auto-promote the next goal so the
|
|
4766
5345
|
// session keeps working through the sequence without manual /goal focus.
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
}
|
|
5346
|
+
const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
|
|
5347
|
+
const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
|
|
4770
5348
|
const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
|
|
4771
5349
|
if (durable === false) {
|
|
4772
|
-
|
|
4773
|
-
await announceAudit(
|
|
5350
|
+
const restored = restoreAfterTerminalPersistenceFailure(
|
|
4774
5351
|
sessionID,
|
|
4775
|
-
|
|
5352
|
+
activeGoalAfterMessages,
|
|
5353
|
+
{
|
|
5354
|
+
ordered,
|
|
5355
|
+
expectedCurrentSnapshot: postCompletionSnapshot,
|
|
5356
|
+
expectedResult: completedResult,
|
|
5357
|
+
},
|
|
4776
5358
|
)
|
|
5359
|
+
if (auditMessagesEnabled) {
|
|
5360
|
+
await announceAudit(
|
|
5361
|
+
sessionID,
|
|
5362
|
+
restored
|
|
5363
|
+
? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
|
|
5364
|
+
: "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
|
|
5365
|
+
)
|
|
5366
|
+
} else {
|
|
5367
|
+
announceLifecycle(
|
|
5368
|
+
sessionID,
|
|
5369
|
+
restored
|
|
5370
|
+
? "Goal paused — completion could not be recorded durably."
|
|
5371
|
+
: "Previous goal completion could not be confirmed durably after goal state changed.",
|
|
5372
|
+
restored
|
|
5373
|
+
? {
|
|
5374
|
+
goal: activeGoalAfterMessages,
|
|
5375
|
+
transition: "terminal-persistence-failed",
|
|
5376
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5377
|
+
expectedState: "paused",
|
|
5378
|
+
expectedStopReason: "terminal persistence failed",
|
|
5379
|
+
}
|
|
5380
|
+
: {
|
|
5381
|
+
transition: "terminal-persistence-raced",
|
|
5382
|
+
requireCurrent: false,
|
|
5383
|
+
},
|
|
5384
|
+
)
|
|
5385
|
+
}
|
|
4777
5386
|
return
|
|
4778
5387
|
}
|
|
4779
|
-
|
|
5388
|
+
const activePromoted = promoted
|
|
5389
|
+
? activeGoal(sessionID, promoted.goalId, promoted.runId)
|
|
5390
|
+
: null
|
|
5391
|
+
if (auditMessagesEnabled) {
|
|
5392
|
+
await announceAudit(
|
|
5393
|
+
sessionID,
|
|
5394
|
+
activePromoted
|
|
5395
|
+
? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
|
|
5396
|
+
: "Audit result: completion accepted — goal archived as achieved.",
|
|
5397
|
+
)
|
|
5398
|
+
} else {
|
|
5399
|
+
announceLifecycle(
|
|
5400
|
+
sessionID,
|
|
5401
|
+
activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
|
|
5402
|
+
{
|
|
5403
|
+
goal: activePromoted || activeGoalAfterMessages,
|
|
5404
|
+
transition: activePromoted ? "achieved-promoted" : "achieved",
|
|
5405
|
+
requireCurrent: Boolean(activePromoted),
|
|
5406
|
+
expectedState: activePromoted ? "active" : "",
|
|
5407
|
+
},
|
|
5408
|
+
)
|
|
5409
|
+
}
|
|
4780
5410
|
return
|
|
4781
5411
|
}
|
|
4782
5412
|
completionUnverified = true
|
|
@@ -4802,16 +5432,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4802
5432
|
blockedGoal.stopReason = "blocked"
|
|
4803
5433
|
const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
|
|
4804
5434
|
const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
|
|
5435
|
+
const blockedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
|
|
5436
|
+
if (
|
|
5437
|
+
blockedGoalAfterPersist !== blockedGoal ||
|
|
5438
|
+
!blockedGoal.stopped ||
|
|
5439
|
+
blockedGoal.stopReason !== "blocked"
|
|
5440
|
+
) return
|
|
4805
5441
|
if (durable === false) {
|
|
4806
5442
|
blockedGoal.stopReason = "terminal persistence failed"
|
|
4807
5443
|
blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
4808
|
-
|
|
5444
|
+
if (auditMessagesEnabled) {
|
|
5445
|
+
await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
|
|
5446
|
+
} else {
|
|
5447
|
+
announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
|
|
5448
|
+
goal: blockedGoal,
|
|
5449
|
+
transition: "terminal-persistence-failed",
|
|
5450
|
+
reason: blockedGoal.stopReason,
|
|
5451
|
+
expectedState: "paused",
|
|
5452
|
+
expectedStopReason: "terminal persistence failed",
|
|
5453
|
+
})
|
|
5454
|
+
}
|
|
4809
5455
|
return
|
|
4810
5456
|
}
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
5457
|
+
if (auditMessagesEnabled) {
|
|
5458
|
+
await announceAudit(
|
|
5459
|
+
sessionID,
|
|
5460
|
+
`Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
|
|
5461
|
+
)
|
|
5462
|
+
} else {
|
|
5463
|
+
announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
|
|
5464
|
+
goal: blockedGoal,
|
|
5465
|
+
transition: "blocked",
|
|
5466
|
+
expectedState: "blocked",
|
|
5467
|
+
expectedStopReason: "blocked",
|
|
5468
|
+
})
|
|
5469
|
+
}
|
|
4815
5470
|
return
|
|
4816
5471
|
}
|
|
4817
5472
|
blockerUnstated = true
|
|
@@ -4826,6 +5481,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4826
5481
|
|
|
4827
5482
|
const limitReason = stopReason(activeGoalAfterMessages)
|
|
4828
5483
|
if (limitReason) {
|
|
5484
|
+
let lifecycleAnnounced = false
|
|
4829
5485
|
if (!activeGoalAfterMessages.budgetWrapupSent) {
|
|
4830
5486
|
const claimedGoal = await claimContinuationSource(
|
|
4831
5487
|
sessionID,
|
|
@@ -4842,6 +5498,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4842
5498
|
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
4843
5499
|
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
4844
5500
|
await persist(sessionID)
|
|
5501
|
+
lifecycleAnnounced = announceLifecycle(
|
|
5502
|
+
sessionID,
|
|
5503
|
+
`Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`,
|
|
5504
|
+
{
|
|
5505
|
+
goal: claimedGoal,
|
|
5506
|
+
transition: "limit-paused",
|
|
5507
|
+
reason: limitReason,
|
|
5508
|
+
expectedState: "paused",
|
|
5509
|
+
expectedStopReason: limitReason,
|
|
5510
|
+
},
|
|
5511
|
+
)
|
|
4845
5512
|
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
4846
5513
|
let response
|
|
4847
5514
|
try {
|
|
@@ -4868,6 +5535,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4868
5535
|
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
4869
5536
|
}
|
|
4870
5537
|
await persist(sessionID)
|
|
5538
|
+
if (!lifecycleAnnounced) {
|
|
5539
|
+
announceLifecycle(sessionID, `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, {
|
|
5540
|
+
goal: activeGoalAfterMessages,
|
|
5541
|
+
transition: "limit-paused",
|
|
5542
|
+
reason: limitReason,
|
|
5543
|
+
expectedState: "paused",
|
|
5544
|
+
expectedStopReason: limitReason,
|
|
5545
|
+
})
|
|
5546
|
+
}
|
|
4871
5547
|
return
|
|
4872
5548
|
}
|
|
4873
5549
|
|
|
@@ -4923,6 +5599,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4923
5599
|
`Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
|
|
4924
5600
|
)
|
|
4925
5601
|
await persist(sessionID)
|
|
5602
|
+
announceLifecycle(sessionID, "Goal paused — no progress threshold reached.", {
|
|
5603
|
+
goal: activeGoalAfterMessages,
|
|
5604
|
+
transition: "no-progress-paused",
|
|
5605
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5606
|
+
expectedState: "paused",
|
|
5607
|
+
expectedStopReason: "no progress",
|
|
5608
|
+
})
|
|
4926
5609
|
return
|
|
4927
5610
|
}
|
|
4928
5611
|
|
|
@@ -4968,6 +5651,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4968
5651
|
`Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
|
|
4969
5652
|
)
|
|
4970
5653
|
await persist(sessionID)
|
|
5654
|
+
announceLifecycle(sessionID, "Goal paused — no-tool-call threshold reached.", {
|
|
5655
|
+
goal: activeGoalAfterMessages,
|
|
5656
|
+
transition: "no-tool-calls-paused",
|
|
5657
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5658
|
+
expectedState: "paused",
|
|
5659
|
+
expectedStopReason: "no tool calls",
|
|
5660
|
+
})
|
|
4971
5661
|
return
|
|
4972
5662
|
}
|
|
4973
5663
|
|
|
@@ -5017,6 +5707,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5017
5707
|
// the hard-limit path which also persists before its promptAsync call.
|
|
5018
5708
|
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
5019
5709
|
await persist(sessionID)
|
|
5710
|
+
announceLifecycle(sessionID, "Goal paused — budget threshold reached; final handoff requested.", {
|
|
5711
|
+
goal: activeGoalBeforePrompt,
|
|
5712
|
+
transition: "budget-wrapup-paused",
|
|
5713
|
+
reason: activeGoalBeforePrompt.stopReason,
|
|
5714
|
+
expectedState: "paused",
|
|
5715
|
+
expectedStopReason: "budget wrap-up requested",
|
|
5716
|
+
})
|
|
5020
5717
|
}
|
|
5021
5718
|
|
|
5022
5719
|
activeGoalBeforePrompt.turnCount += 1
|
|
@@ -5057,6 +5754,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5057
5754
|
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
5058
5755
|
)
|
|
5059
5756
|
await persist(sessionID)
|
|
5757
|
+
announceLifecycle(sessionID, "Goal paused — repeated completion/blocker format failures.", {
|
|
5758
|
+
goal: activeGoalBeforePrompt,
|
|
5759
|
+
transition: "format-failures-paused",
|
|
5760
|
+
reason: activeGoalBeforePrompt.stopReason,
|
|
5761
|
+
expectedState: "paused",
|
|
5762
|
+
expectedStopReason: "format validation failures",
|
|
5763
|
+
})
|
|
5060
5764
|
return
|
|
5061
5765
|
}
|
|
5062
5766
|
}
|
|
@@ -5081,6 +5785,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5081
5785
|
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
5082
5786
|
}
|
|
5083
5787
|
|
|
5788
|
+
let promptFailurePausedGoal = null
|
|
5084
5789
|
if (response.error) {
|
|
5085
5790
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
5086
5791
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
@@ -5096,6 +5801,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5096
5801
|
activeGoalAfterPrompt.stopped = true
|
|
5097
5802
|
activeGoalAfterPrompt.stopReason = "auto-continue failures"
|
|
5098
5803
|
activeGoalAfterPrompt.lastStatus = `${message}; paused after ${activeGoalAfterPrompt.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
5804
|
+
promptFailurePausedGoal = activeGoalAfterPrompt
|
|
5099
5805
|
}
|
|
5100
5806
|
}
|
|
5101
5807
|
await logPluginError(client, message, response.error)
|
|
@@ -5119,6 +5825,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5119
5825
|
}
|
|
5120
5826
|
}
|
|
5121
5827
|
await persist(sessionID)
|
|
5828
|
+
if (promptFailurePausedGoal) {
|
|
5829
|
+
announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
|
|
5830
|
+
goal: promptFailurePausedGoal,
|
|
5831
|
+
transition: "prompt-failures-paused",
|
|
5832
|
+
reason: promptFailurePausedGoal.stopReason,
|
|
5833
|
+
expectedState: "paused",
|
|
5834
|
+
expectedStopReason: "auto-continue failures",
|
|
5835
|
+
})
|
|
5836
|
+
}
|
|
5122
5837
|
} catch (error) {
|
|
5123
5838
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
5124
5839
|
if (activeGoalAfterError) {
|
|
@@ -5139,6 +5854,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5139
5854
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
5140
5855
|
}
|
|
5141
5856
|
await persist(sessionID)
|
|
5857
|
+
if (activeGoalAfterError.stopped && activeGoalAfterError.stopReason === "auto-continue failures") {
|
|
5858
|
+
announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
|
|
5859
|
+
goal: activeGoalAfterError,
|
|
5860
|
+
transition: "prompt-failures-paused",
|
|
5861
|
+
reason: activeGoalAfterError.stopReason,
|
|
5862
|
+
expectedState: "paused",
|
|
5863
|
+
expectedStopReason: "auto-continue failures",
|
|
5864
|
+
})
|
|
5865
|
+
}
|
|
5142
5866
|
}
|
|
5143
5867
|
await logPluginError(client, "Auto-continue failed", error)
|
|
5144
5868
|
} finally {
|
|
@@ -5357,6 +6081,7 @@ export const testInternals = {
|
|
|
5357
6081
|
ledgerPathFor,
|
|
5358
6082
|
setLedgerSink,
|
|
5359
6083
|
defaultAuditMessenger,
|
|
6084
|
+
defaultLifecycleMessenger,
|
|
5360
6085
|
buildAuditPrompt,
|
|
5361
6086
|
parseAuditVerdict,
|
|
5362
6087
|
createChildSessionAuditor,
|
|
@@ -5375,6 +6100,7 @@ export const testInternals = {
|
|
|
5375
6100
|
extractCompletionEvidence,
|
|
5376
6101
|
findLatestAssistantMessage,
|
|
5377
6102
|
formatArgumentErrors,
|
|
6103
|
+
goalDisplayState,
|
|
5378
6104
|
formatStatus,
|
|
5379
6105
|
getSessionID,
|
|
5380
6106
|
goalIsBlocked,
|