opencode-goal-plugin 0.6.8 → 0.8.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 +17 -0
- package/README.md +24 -4
- package/index.d.ts +36 -0
- package/package.json +1 -1
- package/scripts/verify.mjs +9 -1
- package/src/goal-plugin.js +1117 -72
- package/src/opencode-session-api.js +7 -1
package/src/goal-plugin.js
CHANGED
|
@@ -40,6 +40,9 @@ function legacyHomeStateFilePath(env = process.env) {
|
|
|
40
40
|
return join(homeBase(env), ".opencode-goal-plugin", "state.json")
|
|
41
41
|
}
|
|
42
42
|
const MAX_HISTORY_ENTRIES = 20
|
|
43
|
+
// Marks a plugin-synthesized parent wake so the receiving pass knows it is
|
|
44
|
+
// re-examining an assistant turn that has already been scored.
|
|
45
|
+
const CHILD_WAKE_EVENT_FLAG = Symbol.for("opencode-goal-plugin.childWake")
|
|
43
46
|
const MAX_CHECKPOINTS = 5
|
|
44
47
|
const CHECKPOINT_CHAR_LIMIT = 280
|
|
45
48
|
const MAX_GOAL_OBJECTIVE_LENGTH = 4000
|
|
@@ -74,6 +77,8 @@ const DEFAULT_OPTIONS = {
|
|
|
74
77
|
noProgressTokenThreshold: 50,
|
|
75
78
|
noProgressTurnsBeforePause: 2,
|
|
76
79
|
noToolCallTurnsBeforePause: 2,
|
|
80
|
+
noInterruptOnUserMessage: false,
|
|
81
|
+
noContinueWhileChildrenActive: false,
|
|
77
82
|
budgetWrapupRatio: 0.8,
|
|
78
83
|
warnTurnsRemaining: 3,
|
|
79
84
|
warnDurationMsRemaining: 60 * 1000,
|
|
@@ -95,6 +100,7 @@ function createRuntimeState() {
|
|
|
95
100
|
sessionArchive: new Map(),
|
|
96
101
|
sessionOrdered: new Set(),
|
|
97
102
|
lastGoalResults: new Map(),
|
|
103
|
+
sessionMutationVersions: new Map(),
|
|
98
104
|
seenTokens: new Map(),
|
|
99
105
|
seenUsage: new Map(),
|
|
100
106
|
seenOutputTokens: new Map(),
|
|
@@ -165,6 +171,7 @@ const sessionArchive = runtimeCollection("sessionArchive")
|
|
|
165
171
|
const sessionOrdered = runtimeCollection("sessionOrdered")
|
|
166
172
|
const MAX_ARCHIVED_PER_SESSION = 10
|
|
167
173
|
const lastGoalResults = runtimeCollection("lastGoalResults")
|
|
174
|
+
const sessionMutationVersions = runtimeCollection("sessionMutationVersions")
|
|
168
175
|
const seenTokens = runtimeCollection("seenTokens")
|
|
169
176
|
const seenUsage = runtimeCollection("seenUsage")
|
|
170
177
|
const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
@@ -490,6 +497,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
|
490
497
|
options: goal.options,
|
|
491
498
|
stopped: goal.stopped,
|
|
492
499
|
stopReason: goal.stopReason,
|
|
500
|
+
blockedReason: goal.blockedReason,
|
|
493
501
|
ordered: sessionOrdered.has(goal.sessionID),
|
|
494
502
|
},
|
|
495
503
|
type,
|
|
@@ -504,6 +512,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
|
504
512
|
function pushHistory(goal, type, detail, timestamp = Date.now()) {
|
|
505
513
|
const entry = makeHistoryEntry(type, detail, timestamp)
|
|
506
514
|
goal.history = [...(goal.history || []), entry].slice(-MAX_HISTORY_ENTRIES)
|
|
515
|
+
markSessionMutation(goal.sessionID)
|
|
507
516
|
return emitLedgerEvent(goal, entry.type, entry.detail, entry.timestamp)
|
|
508
517
|
}
|
|
509
518
|
|
|
@@ -638,6 +647,7 @@ function reconstructGoalsFromLedger(entries) {
|
|
|
638
647
|
const condition = [...events].reverse().find((event) => typeof event.condition === "string" && event.condition.trim())?.condition?.trim()
|
|
639
648
|
if (!condition) continue
|
|
640
649
|
const snapshot = [...events].reverse().find((event) => isPlainObject(event.snapshot))?.snapshot || {}
|
|
650
|
+
const latestBlocked = [...events].reverse().find((event) => event.type === "blocked")
|
|
641
651
|
|
|
642
652
|
const history = events
|
|
643
653
|
.map((event) =>
|
|
@@ -659,6 +669,12 @@ function reconstructGoalsFromLedger(entries) {
|
|
|
659
669
|
options: isPlainObject(snapshot.options) ? snapshot.options : {},
|
|
660
670
|
stopped: snapshot.stopped === true,
|
|
661
671
|
stopReason: typeof snapshot.stopReason === "string" ? snapshot.stopReason : "",
|
|
672
|
+
blockedReason:
|
|
673
|
+
typeof snapshot.blockedReason === "string"
|
|
674
|
+
? snapshot.blockedReason
|
|
675
|
+
: snapshot.stopReason === "blocked" && typeof latestBlocked?.detail === "string"
|
|
676
|
+
? latestBlocked.detail
|
|
677
|
+
: "",
|
|
662
678
|
ordered: snapshot.ordered === true || events.some((event) => /ordered goal/i.test(String(event.detail || ""))),
|
|
663
679
|
startedAt: normalizeTimestamp(events[0]?.ts),
|
|
664
680
|
history,
|
|
@@ -675,9 +691,19 @@ function recordCheckpoint(goal, text, timestamp = Date.now()) {
|
|
|
675
691
|
const checkpoint = { summary, timestamp }
|
|
676
692
|
goal.lastCheckpoint = checkpoint
|
|
677
693
|
goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
|
|
694
|
+
markSessionMutation(goal.sessionID)
|
|
678
695
|
}
|
|
679
696
|
|
|
680
|
-
function
|
|
697
|
+
function goalDisplayState(goal) {
|
|
698
|
+
if (!goal?.stopped) return "active"
|
|
699
|
+
return goal.stopReason === "blocked" ? "blocked" : "paused"
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function formatStatus(
|
|
703
|
+
goal,
|
|
704
|
+
commandName = "goal",
|
|
705
|
+
completionAuditLabel = "evidence gate only (independent verifier off)",
|
|
706
|
+
) {
|
|
681
707
|
const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
|
|
682
708
|
const lastProgress =
|
|
683
709
|
goal.lastProgressAt > 0
|
|
@@ -688,6 +714,8 @@ function formatStatus(goal, commandName = "goal") {
|
|
|
688
714
|
: "none yet"
|
|
689
715
|
const lines = [
|
|
690
716
|
`Active goal: ${goal.condition}`,
|
|
717
|
+
`State: ${goalDisplayState(goal)}`,
|
|
718
|
+
`Completion audit: ${completionAuditLabel}`,
|
|
691
719
|
]
|
|
692
720
|
if (goal.successCriteria) lines.push(`Success criteria: ${goal.successCriteria}`)
|
|
693
721
|
if (goal.constraints) lines.push(`Constraints: ${goal.constraints}`)
|
|
@@ -772,8 +800,16 @@ function sessionGoalMap(sessionID) {
|
|
|
772
800
|
return map
|
|
773
801
|
}
|
|
774
802
|
|
|
803
|
+
function markSessionMutation(sessionID) {
|
|
804
|
+
if (!sessionID) return 0
|
|
805
|
+
const next = (sessionMutationVersions.get(sessionID) || 0) + 1
|
|
806
|
+
sessionMutationVersions.set(sessionID, next)
|
|
807
|
+
return next
|
|
808
|
+
}
|
|
809
|
+
|
|
775
810
|
function registerSessionGoal(goal) {
|
|
776
811
|
sessionGoalMap(goal.sessionID).set(goal.goalId, goal)
|
|
812
|
+
markSessionMutation(goal.sessionID)
|
|
777
813
|
}
|
|
778
814
|
|
|
779
815
|
function listSessionGoals(sessionID) {
|
|
@@ -796,12 +832,13 @@ function setBoundedMessageValue(map, messageID, value) {
|
|
|
796
832
|
function removeSessionGoal(sessionID, goalId) {
|
|
797
833
|
const map = sessionGoals.get(sessionID)
|
|
798
834
|
if (!map) return
|
|
799
|
-
map.delete(goalId)
|
|
835
|
+
if (map.delete(goalId)) markSessionMutation(sessionID)
|
|
800
836
|
if (map.size === 0) sessionGoals.delete(sessionID)
|
|
801
837
|
}
|
|
802
838
|
|
|
803
839
|
function focusGoal(sessionID, goal) {
|
|
804
840
|
goalStates.set(sessionID, goal)
|
|
841
|
+
markSessionMutation(sessionID)
|
|
805
842
|
}
|
|
806
843
|
|
|
807
844
|
function pauseGoalClock(goal, timestamp = Date.now()) {
|
|
@@ -858,6 +895,10 @@ function cleanupGoal(sessionID) {
|
|
|
858
895
|
}
|
|
859
896
|
goalStates.delete(sessionID)
|
|
860
897
|
activeContinues.delete(sessionID)
|
|
898
|
+
// Increment even when no focused goal remains. A concurrent clear of a
|
|
899
|
+
// provisional completion is otherwise indistinguishable from unrelated
|
|
900
|
+
// global result-retention pruning while its terminal write is in flight.
|
|
901
|
+
markSessionMutation(sessionID)
|
|
861
902
|
}
|
|
862
903
|
|
|
863
904
|
function clearRuntimeState() {
|
|
@@ -868,6 +909,7 @@ function clearRuntimeState() {
|
|
|
868
909
|
sessionArchive.clear()
|
|
869
910
|
sessionOrdered.clear()
|
|
870
911
|
lastGoalResults.clear()
|
|
912
|
+
sessionMutationVersions.clear()
|
|
871
913
|
seenTokens.clear()
|
|
872
914
|
seenUsage.clear()
|
|
873
915
|
seenOutputTokens.clear()
|
|
@@ -908,6 +950,7 @@ function clearSessionRuntimeState(
|
|
|
908
950
|
runtime.sessionStatuses.delete(sessionID)
|
|
909
951
|
if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID)
|
|
910
952
|
runtime.passiveSessions.delete(sessionID)
|
|
953
|
+
markSessionMutation(sessionID)
|
|
911
954
|
if (!preserveCommandSecurity) {
|
|
912
955
|
runtime.pendingCommandTurns.delete(sessionID)
|
|
913
956
|
runtime.activeCommandTurns.delete(sessionID)
|
|
@@ -966,16 +1009,60 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
|
|
|
966
1009
|
lastGoalResults.delete(sessionID)
|
|
967
1010
|
lastGoalResults.set(sessionID, result)
|
|
968
1011
|
// Keep a per-session archive so completed goals stay readable via /goal list.
|
|
969
|
-
|
|
1012
|
+
const archivedResult = { ...result }
|
|
1013
|
+
archiveSessionResult(sessionID, archivedResult)
|
|
970
1014
|
pruneGoalResults(goal.options)
|
|
1015
|
+
markSessionMutation(sessionID)
|
|
1016
|
+
return { lastResult: result, archivedResult }
|
|
971
1017
|
}
|
|
972
1018
|
|
|
973
|
-
function
|
|
974
|
-
|
|
1019
|
+
function captureFocusedGoalSnapshot(sessionID) {
|
|
1020
|
+
const goal = goalStates.get(sessionID) || null
|
|
1021
|
+
return {
|
|
1022
|
+
goal,
|
|
1023
|
+
serialized: goal ? JSON.stringify(serializeGoal(goal)) : "",
|
|
1024
|
+
mutationVersion: sessionMutationVersions.get(sessionID) || 0,
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function focusedGoalSnapshotIsCurrent(sessionID, snapshot) {
|
|
1029
|
+
const current = goalStates.get(sessionID) || null
|
|
1030
|
+
if (current !== snapshot?.goal) return false
|
|
1031
|
+
if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false
|
|
1032
|
+
return !current || JSON.stringify(serializeGoal(current)) === snapshot.serialized
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function restoreAfterTerminalPersistenceFailure(
|
|
1036
|
+
sessionID,
|
|
1037
|
+
goal,
|
|
1038
|
+
{ ordered = false, expectedCurrentSnapshot, expectedResult } = {},
|
|
1039
|
+
) {
|
|
1040
|
+
// A terminal write can yield while another command replaces, edits, pauses,
|
|
1041
|
+
// resumes, clears, or advances the session. Never roll the old goal back over
|
|
1042
|
+
// that newer state. The per-session mutation version catches a concurrent
|
|
1043
|
+
// clear even when both the expected and current focused goal are null, while
|
|
1044
|
+
// remaining unaffected by result-retention pruning in a different session.
|
|
1045
|
+
const expectedLastResult = expectedResult?.lastResult || expectedResult
|
|
1046
|
+
const expectedArchivedResult = expectedResult?.archivedResult
|
|
1047
|
+
const canRestore =
|
|
1048
|
+
!expectedCurrentSnapshot ||
|
|
1049
|
+
focusedGoalSnapshotIsCurrent(sessionID, expectedCurrentSnapshot)
|
|
1050
|
+
|
|
1051
|
+
// Remove only this failed provisional completion record. A newer concurrent
|
|
1052
|
+
// result/archive entry belongs to the newer operation and must survive.
|
|
1053
|
+
if (expectedLastResult && lastGoalResults.get(sessionID) === expectedLastResult) {
|
|
1054
|
+
lastGoalResults.delete(sessionID)
|
|
1055
|
+
}
|
|
975
1056
|
const archived = sessionArchive.get(sessionID) || []
|
|
976
|
-
if (
|
|
1057
|
+
if (expectedArchivedResult) {
|
|
1058
|
+
const retained = archived.filter((entry) => entry !== expectedArchivedResult)
|
|
1059
|
+
if (retained.length) sessionArchive.set(sessionID, retained)
|
|
1060
|
+
else sessionArchive.delete(sessionID)
|
|
1061
|
+
} else if (archived.length) {
|
|
977
1062
|
sessionArchive.set(sessionID, archived.slice(0, -1))
|
|
978
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
if (!canRestore) return false
|
|
979
1066
|
const prematurelyPromoted = goalStates.get(sessionID)
|
|
980
1067
|
if (prematurelyPromoted && prematurelyPromoted.goalId !== goal.goalId) {
|
|
981
1068
|
prematurelyPromoted.stopped = true
|
|
@@ -990,6 +1077,7 @@ function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = fal
|
|
|
990
1077
|
goal.lastStatus = "Terminal state could not be persisted. Goal kept paused; fix storage and retry."
|
|
991
1078
|
registerSessionGoal(goal)
|
|
992
1079
|
focusGoal(sessionID, goal)
|
|
1080
|
+
return true
|
|
993
1081
|
}
|
|
994
1082
|
|
|
995
1083
|
function resetGoalBudget(goal) {
|
|
@@ -1093,6 +1181,8 @@ function normalizeOptions(options = {}) {
|
|
|
1093
1181
|
Number.isSafeInteger(options.noToolCallTurnsBeforePause) && options.noToolCallTurnsBeforePause >= 0
|
|
1094
1182
|
? options.noToolCallTurnsBeforePause
|
|
1095
1183
|
: DEFAULT_OPTIONS.noToolCallTurnsBeforePause,
|
|
1184
|
+
noInterruptOnUserMessage: options.noInterruptOnUserMessage === true,
|
|
1185
|
+
noContinueWhileChildrenActive: options.noContinueWhileChildrenActive === true,
|
|
1096
1186
|
budgetWrapupRatio:
|
|
1097
1187
|
Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1
|
|
1098
1188
|
? Number(options.budgetWrapupRatio)
|
|
@@ -1543,15 +1633,15 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
|
|
|
1543
1633
|
}
|
|
1544
1634
|
|
|
1545
1635
|
// After applyParsedStateFile loads goals into goalStates, check the ledger for
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1636
|
+
// state transitions that landed after the snapshot. Completed/cleared goals are
|
|
1637
|
+
// removed so they cannot be re-driven, while a newer blocked event is overlaid
|
|
1638
|
+
// so its state and concrete reason survive a failed snapshot write.
|
|
1549
1639
|
async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1550
1640
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1551
1641
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1552
1642
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1553
1643
|
})
|
|
1554
|
-
if (!entries.length) return
|
|
1644
|
+
if (!entries.length) return { removed: 0, blocked: 0 }
|
|
1555
1645
|
|
|
1556
1646
|
const terminalGoals = new Set()
|
|
1557
1647
|
for (const entry of entries) {
|
|
@@ -1564,16 +1654,69 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
|
|
|
1564
1654
|
terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
|
|
1565
1655
|
}
|
|
1566
1656
|
}
|
|
1567
|
-
if (!terminalGoals.size) return
|
|
1568
|
-
|
|
1569
1657
|
let removed = 0
|
|
1658
|
+
let blocked = 0
|
|
1570
1659
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1571
1660
|
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1572
1661
|
for (const goal of [...goals.values()]) {
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1662
|
+
const key = `${sessionID}\0${goal.goalId}`
|
|
1663
|
+
if (terminalGoals.has(key)) {
|
|
1664
|
+
removeSessionGoal(sessionID, goal.goalId)
|
|
1665
|
+
if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
|
|
1666
|
+
removed += 1
|
|
1667
|
+
continue
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const persistedHistory = (goal.history || []).filter((event) => event.type !== "recovered")
|
|
1671
|
+
const latestPersistedTimestamp = persistedHistory.reduce(
|
|
1672
|
+
(latest, event) => Math.max(latest, normalizeTimestamp(event.timestamp, 0)),
|
|
1673
|
+
0,
|
|
1674
|
+
)
|
|
1675
|
+
let latestLedgerState = null
|
|
1676
|
+
let latestLedgerTimestamp = -1
|
|
1677
|
+
for (const entry of entries) {
|
|
1678
|
+
if (entry.sessionID !== sessionID || entry.goalId !== goal.goalId || entry.type === "recovered") continue
|
|
1679
|
+
const timestamp = normalizeTimestamp(entry.ts, 0)
|
|
1680
|
+
if (timestamp < latestPersistedTimestamp) continue
|
|
1681
|
+
const detail = summarizeText(entry.detail, 400)
|
|
1682
|
+
const alreadyApplied = persistedHistory.some(
|
|
1683
|
+
(event) =>
|
|
1684
|
+
event.type === entry.type &&
|
|
1685
|
+
normalizeTimestamp(event.timestamp, 0) === timestamp &&
|
|
1686
|
+
event.detail === detail,
|
|
1687
|
+
)
|
|
1688
|
+
if (timestamp >= latestLedgerTimestamp) {
|
|
1689
|
+
latestLedgerState = { entry, alreadyApplied }
|
|
1690
|
+
latestLedgerTimestamp = timestamp
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (
|
|
1694
|
+
latestLedgerState?.alreadyApplied ||
|
|
1695
|
+
latestLedgerState?.entry?.type !== "blocked" ||
|
|
1696
|
+
latestLedgerState.entry.snapshot?.stopped !== true ||
|
|
1697
|
+
latestLedgerState.entry.snapshot?.stopReason !== "blocked"
|
|
1698
|
+
) continue
|
|
1699
|
+
|
|
1700
|
+
const reason = summarizeText(
|
|
1701
|
+
latestLedgerState.entry.snapshot?.blockedReason || latestLedgerState.entry.detail,
|
|
1702
|
+
MAX_GOAL_BLOCKER_LENGTH,
|
|
1703
|
+
)
|
|
1704
|
+
if (!reason) continue
|
|
1705
|
+
goal.stopped = true
|
|
1706
|
+
goal.stopReason = "blocked"
|
|
1707
|
+
goal.blockedReason = reason
|
|
1708
|
+
goal.lastStatus = "Recovered blocked goal state from the lifecycle ledger after the saved snapshot lagged behind."
|
|
1709
|
+
goal.continuationClaim = null
|
|
1710
|
+
goal.history = [
|
|
1711
|
+
...(goal.history || []),
|
|
1712
|
+
makeHistoryEntry(
|
|
1713
|
+
"blocked",
|
|
1714
|
+
reason,
|
|
1715
|
+
normalizeTimestamp(latestLedgerState.entry.ts),
|
|
1716
|
+
),
|
|
1717
|
+
].slice(-MAX_HISTORY_ENTRIES)
|
|
1718
|
+
pauseGoalClock(goal)
|
|
1719
|
+
blocked += 1
|
|
1577
1720
|
}
|
|
1578
1721
|
if (!goalStates.has(sessionID) && sessionOrdered.has(sessionID) && goals.size > 0) {
|
|
1579
1722
|
promoteNextOrderedGoal(sessionID)
|
|
@@ -1585,6 +1728,13 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
|
|
|
1585
1728
|
`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
1729
|
)
|
|
1587
1730
|
}
|
|
1731
|
+
if (blocked > 0) {
|
|
1732
|
+
await logPluginError(
|
|
1733
|
+
client,
|
|
1734
|
+
`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).`,
|
|
1735
|
+
)
|
|
1736
|
+
}
|
|
1737
|
+
return { removed, blocked }
|
|
1588
1738
|
}
|
|
1589
1739
|
|
|
1590
1740
|
async function pathExists(path) {
|
|
@@ -1810,8 +1960,8 @@ async function loadPersistedSessionState(persistence, client, sessionID) {
|
|
|
1810
1960
|
const state = await readPersistedStateFile(persistence.stateFilePath, client)
|
|
1811
1961
|
if (state.status === "loaded") {
|
|
1812
1962
|
await applyParsedStateFile(state.raw, client, sessionID)
|
|
1813
|
-
await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1814
|
-
return "loaded"
|
|
1963
|
+
const reconciliation = await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1964
|
+
return reconciliation.blocked > 0 ? "reconciled-blocked" : "loaded"
|
|
1815
1965
|
}
|
|
1816
1966
|
const recovered = await reconstructFromLedger(persistence, client, sessionID)
|
|
1817
1967
|
if (state.status === "invalid" && recovered === "reconstructed") {
|
|
@@ -2802,6 +2952,8 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2802
2952
|
}
|
|
2803
2953
|
|
|
2804
2954
|
const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
|
|
2955
|
+
const AGENT_COMPLETE_SUCCESS = "Goal marked complete and archived."
|
|
2956
|
+
const AGENT_BLOCK_SUCCESS = "Goal marked blocked."
|
|
2805
2957
|
|
|
2806
2958
|
// Programmatic equivalents of the /goal command, exposed to the agent as tools
|
|
2807
2959
|
// Each handler operates on a session id and mutates
|
|
@@ -2810,14 +2962,24 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
|
|
|
2810
2962
|
// result. Goal creation/replacement routes through the multi-goal registry
|
|
2811
2963
|
// (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
|
|
2812
2964
|
// path, so tool-created goals persist and are driven by the idle handler.
|
|
2813
|
-
function buildAgentToolHandlers({
|
|
2965
|
+
function buildAgentToolHandlers({
|
|
2966
|
+
defaultGoalOptions,
|
|
2967
|
+
persist,
|
|
2968
|
+
persistTerminalState = null,
|
|
2969
|
+
completionAuditor = null,
|
|
2970
|
+
completionAuditLabel = "evidence gate only (independent verifier off)",
|
|
2971
|
+
announceAudit = async () => {},
|
|
2972
|
+
auditMessagesEnabled = false,
|
|
2973
|
+
announceLifecycle = () => {},
|
|
2974
|
+
commandName = "goal",
|
|
2975
|
+
}) {
|
|
2814
2976
|
// Use persistTerminalState (which logs on failure) for terminal operations when
|
|
2815
2977
|
// available; fall back to plain persist for callers that don't wire it up (e.g.
|
|
2816
2978
|
// tests using buildAgentToolHandlers directly).
|
|
2817
2979
|
const persistFinal = persistTerminalState || persist
|
|
2818
2980
|
async function getGoal(sessionID) {
|
|
2819
2981
|
const goal = goalStates.get(sessionID)
|
|
2820
|
-
if (goal) return formatStatus(goal)
|
|
2982
|
+
if (goal) return formatStatus(goal, commandName, completionAuditLabel)
|
|
2821
2983
|
const lastResult = lastGoalResults.get(sessionID)
|
|
2822
2984
|
if (lastResult) return formatGoalResult(lastResult)
|
|
2823
2985
|
return "No active goal."
|
|
@@ -2887,12 +3049,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2887
3049
|
// Mirror the `/goal <condition>` replace path: discard the focused goal and
|
|
2888
3050
|
// its saved result, drop any ordered sequence, then register + focus the new
|
|
2889
3051
|
// goal so it persists and the idle handler drives it.
|
|
3052
|
+
const replacedGoal = goalStates.get(sessionID)
|
|
2890
3053
|
sessionOrdered.delete(sessionID)
|
|
2891
3054
|
cleanupGoal(sessionID)
|
|
2892
3055
|
lastGoalResults.delete(sessionID)
|
|
2893
3056
|
registerSessionGoal(goal)
|
|
2894
3057
|
focusGoal(sessionID, goal)
|
|
2895
3058
|
await persist(sessionID)
|
|
3059
|
+
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
|
|
3060
|
+
goal,
|
|
3061
|
+
transition: replacedGoal ? "replaced-active" : "active",
|
|
3062
|
+
expectedState: "active",
|
|
3063
|
+
})
|
|
2896
3064
|
// Escape in the tool result only: goal.condition is stored raw so callers
|
|
2897
3065
|
// that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
|
|
2898
3066
|
// themselves. Escaping here prevents XML metacharacters in user-supplied
|
|
@@ -2920,6 +3088,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2920
3088
|
}
|
|
2921
3089
|
|
|
2922
3090
|
const messages = []
|
|
3091
|
+
let lifecycleNotice = null
|
|
2923
3092
|
|
|
2924
3093
|
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
2925
3094
|
if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) {
|
|
@@ -2938,6 +3107,13 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2938
3107
|
goal.lastStatus = "Goal objective updated."
|
|
2939
3108
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
|
|
2940
3109
|
messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
|
|
3110
|
+
lifecycleNotice = {
|
|
3111
|
+
text: `Goal updated; state remains ${goalDisplayState(goal)}.`,
|
|
3112
|
+
transition: "updated",
|
|
3113
|
+
reason: goalDisplayState(goal),
|
|
3114
|
+
expectedState: goalDisplayState(goal),
|
|
3115
|
+
expectedStopReason: goal.stopped ? goal.stopReason : "",
|
|
3116
|
+
}
|
|
2941
3117
|
}
|
|
2942
3118
|
|
|
2943
3119
|
if (args.status !== undefined) {
|
|
@@ -2950,13 +3126,24 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2950
3126
|
if (!evidence) return "Completion evidence is required before a goal can be archived."
|
|
2951
3127
|
if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH)
|
|
2952
3128
|
return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.`
|
|
3129
|
+
const auditedGoalID = goal.goalId
|
|
3130
|
+
const auditedRunID = goal.runId
|
|
3131
|
+
if (auditMessagesEnabled) {
|
|
3132
|
+
await announceAudit(
|
|
3133
|
+
sessionID,
|
|
3134
|
+
"Auditing goal completion: checking submitted evidence before archiving.",
|
|
3135
|
+
)
|
|
3136
|
+
const goalAfterAnnouncement = activeGoal(sessionID, auditedGoalID, auditedRunID)
|
|
3137
|
+
if (!goalAfterAnnouncement) {
|
|
3138
|
+
return "Completion audit finished after the goal changed; completion was not recorded."
|
|
3139
|
+
}
|
|
3140
|
+
goal = goalAfterAnnouncement
|
|
3141
|
+
}
|
|
2953
3142
|
// If a completion auditor is configured, run it before archiving so the
|
|
2954
3143
|
// agent tool path has the same integrity gate as the [goal:complete] marker
|
|
2955
3144
|
// path. Without this, an autonomous agent could bypass the auditor by
|
|
2956
3145
|
// calling update_goal({status:"complete"}) instead of using the marker.
|
|
2957
3146
|
if (completionAuditor) {
|
|
2958
|
-
const auditedGoalID = goal.goalId
|
|
2959
|
-
const auditedRunID = goal.runId
|
|
2960
3147
|
let verdict
|
|
2961
3148
|
try {
|
|
2962
3149
|
verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
|
|
@@ -2975,6 +3162,25 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2975
3162
|
goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
2976
3163
|
pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
2977
3164
|
await persist(sessionID)
|
|
3165
|
+
const rejectedGoalAfterPersist = currentGoal(sessionID, auditedGoalID, auditedRunID)
|
|
3166
|
+
if (
|
|
3167
|
+
rejectedGoalAfterPersist !== goal ||
|
|
3168
|
+
!goal.stopped ||
|
|
3169
|
+
goal.stopReason !== "audit rejected"
|
|
3170
|
+
) {
|
|
3171
|
+
return "Completion audit was rejected, but the goal changed while that state was persisted; current state was left untouched."
|
|
3172
|
+
}
|
|
3173
|
+
if (auditMessagesEnabled) {
|
|
3174
|
+
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
3175
|
+
} else {
|
|
3176
|
+
announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
|
|
3177
|
+
goal,
|
|
3178
|
+
transition: "audit-rejected",
|
|
3179
|
+
reason,
|
|
3180
|
+
expectedState: "paused",
|
|
3181
|
+
expectedStopReason: "audit rejected",
|
|
3182
|
+
})
|
|
3183
|
+
}
|
|
2978
3184
|
return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
|
|
2979
3185
|
}
|
|
2980
3186
|
}
|
|
@@ -2985,16 +3191,72 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2985
3191
|
evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.",
|
|
2986
3192
|
)
|
|
2987
3193
|
const ordered = sessionOrdered.has(sessionID)
|
|
2988
|
-
rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
3194
|
+
const completedResult = rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
2989
3195
|
cleanupGoal(sessionID)
|
|
2990
3196
|
// Advance an ordered sequence just like the marker path does.
|
|
2991
|
-
|
|
3197
|
+
const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
|
|
3198
|
+
const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
|
|
2992
3199
|
const durable = await persistFinal(sessionID, "completion", ledgerDurable)
|
|
2993
3200
|
if (durable === false) {
|
|
2994
|
-
restoreAfterTerminalPersistenceFailure(sessionID, goal, {
|
|
2995
|
-
|
|
3201
|
+
const restored = restoreAfterTerminalPersistenceFailure(sessionID, goal, {
|
|
3202
|
+
ordered,
|
|
3203
|
+
expectedCurrentSnapshot: postCompletionSnapshot,
|
|
3204
|
+
expectedResult: completedResult,
|
|
3205
|
+
})
|
|
3206
|
+
if (auditMessagesEnabled) {
|
|
3207
|
+
await announceAudit(
|
|
3208
|
+
sessionID,
|
|
3209
|
+
restored
|
|
3210
|
+
? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
|
|
3211
|
+
: "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
|
|
3212
|
+
)
|
|
3213
|
+
} else {
|
|
3214
|
+
announceLifecycle(
|
|
3215
|
+
sessionID,
|
|
3216
|
+
restored
|
|
3217
|
+
? "Goal paused — completion could not be recorded durably."
|
|
3218
|
+
: "Previous goal completion could not be confirmed durably after goal state changed.",
|
|
3219
|
+
restored
|
|
3220
|
+
? {
|
|
3221
|
+
goal,
|
|
3222
|
+
transition: "terminal-persistence-failed",
|
|
3223
|
+
reason: goal.stopReason,
|
|
3224
|
+
expectedState: "paused",
|
|
3225
|
+
expectedStopReason: "terminal persistence failed",
|
|
3226
|
+
}
|
|
3227
|
+
: {
|
|
3228
|
+
transition: "terminal-persistence-raced",
|
|
3229
|
+
requireCurrent: false,
|
|
3230
|
+
},
|
|
3231
|
+
)
|
|
3232
|
+
}
|
|
3233
|
+
return restored
|
|
3234
|
+
? "Completion verified, but terminal state could not be persisted. Goal remains paused."
|
|
3235
|
+
: "Completion verified, but its terminal state could not be persisted before the goal changed. Current state was left untouched."
|
|
3236
|
+
}
|
|
3237
|
+
const activePromoted = promoted
|
|
3238
|
+
? activeGoal(sessionID, promoted.goalId, promoted.runId)
|
|
3239
|
+
: null
|
|
3240
|
+
if (auditMessagesEnabled) {
|
|
3241
|
+
await announceAudit(
|
|
3242
|
+
sessionID,
|
|
3243
|
+
activePromoted
|
|
3244
|
+
? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
|
|
3245
|
+
: "Audit result: completion accepted — goal archived as achieved.",
|
|
3246
|
+
)
|
|
3247
|
+
} else {
|
|
3248
|
+
announceLifecycle(
|
|
3249
|
+
sessionID,
|
|
3250
|
+
activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
|
|
3251
|
+
{
|
|
3252
|
+
goal: activePromoted || goal,
|
|
3253
|
+
transition: activePromoted ? "achieved-promoted" : "achieved",
|
|
3254
|
+
requireCurrent: Boolean(activePromoted),
|
|
3255
|
+
expectedState: activePromoted ? "active" : "",
|
|
3256
|
+
},
|
|
3257
|
+
)
|
|
2996
3258
|
}
|
|
2997
|
-
return
|
|
3259
|
+
return AGENT_COMPLETE_SUCCESS
|
|
2998
3260
|
}
|
|
2999
3261
|
if (status === "blocked") {
|
|
3000
3262
|
const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
|
|
@@ -3002,18 +3264,80 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3002
3264
|
return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
|
|
3003
3265
|
if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH)
|
|
3004
3266
|
return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.`
|
|
3267
|
+
const blockedGoalID = goal.goalId
|
|
3268
|
+
const blockedRunID = goal.runId
|
|
3269
|
+
if (auditMessagesEnabled) {
|
|
3270
|
+
await announceAudit(
|
|
3271
|
+
sessionID,
|
|
3272
|
+
"Auditing goal blocker: checking the submitted blocker before pausing.",
|
|
3273
|
+
)
|
|
3274
|
+
const goalAfterAnnouncement = activeGoal(sessionID, blockedGoalID, blockedRunID)
|
|
3275
|
+
if (!goalAfterAnnouncement) {
|
|
3276
|
+
return "Blocker audit finished after the goal changed; blocked state was not recorded."
|
|
3277
|
+
}
|
|
3278
|
+
goal = goalAfterAnnouncement
|
|
3279
|
+
}
|
|
3005
3280
|
goal.blockedReason = blockerText
|
|
3006
3281
|
goal.stopped = true
|
|
3007
3282
|
goal.stopReason = "blocked"
|
|
3008
3283
|
goal.lastStatus = "Assistant reported blocked."
|
|
3009
|
-
pushHistory(goal, "blocked", goal.blockedReason)
|
|
3010
|
-
messages.push(
|
|
3284
|
+
const ledgerDurable = pushHistory(goal, "blocked", goal.blockedReason)
|
|
3285
|
+
messages.push(AGENT_BLOCK_SUCCESS)
|
|
3286
|
+
const durable = await persistFinal(sessionID, "blocked", ledgerDurable)
|
|
3287
|
+
const blockedGoalAfterPersist = currentGoal(sessionID, blockedGoalID, blockedRunID)
|
|
3288
|
+
if (blockedGoalAfterPersist !== goal || goal.stopReason !== "blocked") {
|
|
3289
|
+
return "Blocked state changed while persistence completed; blocked state was not reported."
|
|
3290
|
+
}
|
|
3291
|
+
if (durable === false) {
|
|
3292
|
+
goal.stopReason = "terminal persistence failed"
|
|
3293
|
+
goal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
3294
|
+
if (auditMessagesEnabled) {
|
|
3295
|
+
await announceAudit(
|
|
3296
|
+
sessionID,
|
|
3297
|
+
"Audit result: blocker recognized, but storage failed; goal remains paused.",
|
|
3298
|
+
)
|
|
3299
|
+
} else {
|
|
3300
|
+
announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
|
|
3301
|
+
goal,
|
|
3302
|
+
transition: "terminal-persistence-failed",
|
|
3303
|
+
expectedState: "paused",
|
|
3304
|
+
expectedStopReason: "terminal persistence failed",
|
|
3305
|
+
})
|
|
3306
|
+
}
|
|
3307
|
+
return "Blocker recognized, but terminal state could not be persisted. Goal remains paused."
|
|
3308
|
+
}
|
|
3309
|
+
if (auditMessagesEnabled) {
|
|
3310
|
+
await announceAudit(
|
|
3311
|
+
sessionID,
|
|
3312
|
+
`Audit result: goal paused as blocked — ${summarizeText(blockerText, 160)}. Run /${commandName} resume after addressing it.`,
|
|
3313
|
+
)
|
|
3314
|
+
} else {
|
|
3315
|
+
announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
|
|
3316
|
+
goal,
|
|
3317
|
+
transition: "blocked",
|
|
3318
|
+
expectedState: "blocked",
|
|
3319
|
+
expectedStopReason: "blocked",
|
|
3320
|
+
})
|
|
3321
|
+
}
|
|
3322
|
+
return messages.join(" ")
|
|
3011
3323
|
} else if (status === "paused") {
|
|
3012
|
-
goal.stopped
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3324
|
+
if (goal.stopped && goal.stopReason === "paused") {
|
|
3325
|
+
if (!messages.length) return "Goal is already paused."
|
|
3326
|
+
messages.push("Goal is already paused.")
|
|
3327
|
+
} else {
|
|
3328
|
+
goal.stopped = true
|
|
3329
|
+
goal.stopReason = "paused"
|
|
3330
|
+
goal.lastStatus = "Goal paused."
|
|
3331
|
+
pushHistory(goal, "paused", "Paused via agent tool.")
|
|
3332
|
+
messages.push("Goal paused.")
|
|
3333
|
+
lifecycleNotice = {
|
|
3334
|
+
text: "Goal paused.",
|
|
3335
|
+
transition: "paused",
|
|
3336
|
+
reason: goal.stopReason,
|
|
3337
|
+
expectedState: "paused",
|
|
3338
|
+
expectedStopReason: "paused",
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3017
3341
|
} else if (status === "resumed") {
|
|
3018
3342
|
if (!goal.stopped)
|
|
3019
3343
|
return "Goal is already running. Pause or stop it first if you want to reset the budget window."
|
|
@@ -3027,6 +3351,11 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3027
3351
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
3028
3352
|
pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.")
|
|
3029
3353
|
messages.push("Goal resumed with fresh limits.")
|
|
3354
|
+
lifecycleNotice = {
|
|
3355
|
+
text: "Goal resumed with fresh limits.",
|
|
3356
|
+
transition: "resumed",
|
|
3357
|
+
expectedState: "active",
|
|
3358
|
+
}
|
|
3030
3359
|
}
|
|
3031
3360
|
}
|
|
3032
3361
|
|
|
@@ -3034,6 +3363,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3034
3363
|
return "Nothing to update. Provide `objective` and/or `status`."
|
|
3035
3364
|
}
|
|
3036
3365
|
await persist(sessionID)
|
|
3366
|
+
if (lifecycleNotice) {
|
|
3367
|
+
announceLifecycle(sessionID, lifecycleNotice.text, {
|
|
3368
|
+
goal,
|
|
3369
|
+
transition: lifecycleNotice.transition,
|
|
3370
|
+
reason: lifecycleNotice.reason,
|
|
3371
|
+
expectedState: lifecycleNotice.expectedState,
|
|
3372
|
+
expectedStopReason: lifecycleNotice.expectedStopReason,
|
|
3373
|
+
})
|
|
3374
|
+
}
|
|
3037
3375
|
return messages.join(" ")
|
|
3038
3376
|
}
|
|
3039
3377
|
|
|
@@ -3042,15 +3380,33 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
3042
3380
|
// focused goal + result. Without sessionGoals.delete, background goals added via
|
|
3043
3381
|
// `/goal add` survive clear and resurrect as the focused goal on restart.
|
|
3044
3382
|
// Record the clear in the ledger before cleanupGoal removes the goal object.
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3383
|
+
const goals = listSessionGoals(sessionID)
|
|
3384
|
+
const clearedGoal = goalStates.get(sessionID) || goals[0] || null
|
|
3385
|
+
const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
|
|
3386
|
+
const ledgerDurable =
|
|
3387
|
+
goals.length > 0 &&
|
|
3388
|
+
goals.map((goal) => pushHistory(goal, "cleared", "Cleared via agent tool.")).every(Boolean)
|
|
3048
3389
|
sessionOrdered.delete(sessionID)
|
|
3049
3390
|
sessionGoals.delete(sessionID)
|
|
3050
3391
|
cleanupGoal(sessionID)
|
|
3051
3392
|
lastGoalResults.delete(sessionID)
|
|
3052
|
-
await persistFinal(sessionID, "clear")
|
|
3053
|
-
|
|
3393
|
+
const durable = await persistFinal(sessionID, "clear", ledgerDurable)
|
|
3394
|
+
const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
|
|
3395
|
+
if (hadState && clearStillCurrent) {
|
|
3396
|
+
announceLifecycle(sessionID, durable === false
|
|
3397
|
+
? "Goal cleared in memory, but storage failed; it may reappear after restart."
|
|
3398
|
+
: "Goal cleared.", {
|
|
3399
|
+
goal: clearedGoal,
|
|
3400
|
+
transition: durable === false ? "clear-persistence-failed" : "cleared",
|
|
3401
|
+
requireCurrent: false,
|
|
3402
|
+
})
|
|
3403
|
+
}
|
|
3404
|
+
if (!clearStillCurrent) {
|
|
3405
|
+
return "Clear persistence finished after goal state changed; current state was left untouched."
|
|
3406
|
+
}
|
|
3407
|
+
return durable === false
|
|
3408
|
+
? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
|
|
3409
|
+
: "Goal cleared."
|
|
3054
3410
|
}
|
|
3055
3411
|
|
|
3056
3412
|
return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
|
|
@@ -3175,8 +3531,22 @@ function buildAgentTools(
|
|
|
3175
3531
|
return goalToolFailure("already_running", "Goal is already running.")
|
|
3176
3532
|
}
|
|
3177
3533
|
const message = await handlers.updateGoal(sessionID, args)
|
|
3178
|
-
if (args.status === "complete"
|
|
3179
|
-
|
|
3534
|
+
if (args.status === "complete") {
|
|
3535
|
+
if (
|
|
3536
|
+
message !== AGENT_COMPLETE_SUCCESS ||
|
|
3537
|
+
currentGoal(sessionID, before.goalId, before.runId)
|
|
3538
|
+
) {
|
|
3539
|
+
return goalToolFailure("completion_rejected", message)
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
if (args.status === "blocked") {
|
|
3543
|
+
const after = currentGoal(sessionID, before.goalId, before.runId)
|
|
3544
|
+
if (after !== before) {
|
|
3545
|
+
return goalToolFailure("goal_changed", message)
|
|
3546
|
+
}
|
|
3547
|
+
if (message !== AGENT_BLOCK_SUCCESS || !after.stopped || after.stopReason !== "blocked") {
|
|
3548
|
+
return goalToolFailure("block_rejected", message)
|
|
3549
|
+
}
|
|
3180
3550
|
}
|
|
3181
3551
|
return goalToolSuccess(message)
|
|
3182
3552
|
},
|
|
@@ -3296,8 +3666,14 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
3296
3666
|
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
|
|
3297
3667
|
goals.forEach((goal, index) => {
|
|
3298
3668
|
const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
|
|
3299
|
-
const state = goal
|
|
3300
|
-
|
|
3669
|
+
const state = goalDisplayState(goal)
|
|
3670
|
+
const reason = state === "blocked"
|
|
3671
|
+
? goal.blockedReason || goal.stopReason
|
|
3672
|
+
: goal.stopped
|
|
3673
|
+
? goal.stopReason
|
|
3674
|
+
: ""
|
|
3675
|
+
const reasonText = reason ? ` (${summarizeText(reason, 160)})` : ""
|
|
3676
|
+
lines.push(`${index + 1}. [${marker}] ${goal.condition} — state: ${state}${reasonText}`)
|
|
3301
3677
|
})
|
|
3302
3678
|
lines.push(`Switch with \`/${commandName} focus <number>\`.`)
|
|
3303
3679
|
} else {
|
|
@@ -3343,6 +3719,37 @@ async function defaultAuditMessenger(client, sessionID, text) {
|
|
|
3343
3719
|
}
|
|
3344
3720
|
}
|
|
3345
3721
|
|
|
3722
|
+
// High-signal lifecycle feedback uses the same non-blocking host surfaces as
|
|
3723
|
+
// audit notices, but remains a separate channel so callers can configure each
|
|
3724
|
+
// independently. Messages are normalized and bounded before they reach either
|
|
3725
|
+
// host API; goal objectives, evidence, and workspace paths are deliberately
|
|
3726
|
+
// excluded by transition call sites.
|
|
3727
|
+
async function defaultLifecycleMessenger(client, sessionID, text) {
|
|
3728
|
+
const message = summarizeText(text, 500)
|
|
3729
|
+
const warning = /\b(?:paused|blocked|recovered|failed|passive)\b/i.test(message)
|
|
3730
|
+
const success = /\b(?:achieved|completed)\b/i.test(message)
|
|
3731
|
+
if (client?.app?.log) {
|
|
3732
|
+
dispatchAdvisoryHostCall(() => client.app.log({
|
|
3733
|
+
body: {
|
|
3734
|
+
service: "opencode-goal-plugin",
|
|
3735
|
+
level: warning ? "warn" : "info",
|
|
3736
|
+
message,
|
|
3737
|
+
extra: { sessionID, kind: "goal-lifecycle" },
|
|
3738
|
+
},
|
|
3739
|
+
}))
|
|
3740
|
+
}
|
|
3741
|
+
if (client?.tui?.showToast) {
|
|
3742
|
+
dispatchAdvisoryHostCall(() => client.tui.showToast({
|
|
3743
|
+
body: {
|
|
3744
|
+
title: "Goal workflow",
|
|
3745
|
+
message,
|
|
3746
|
+
variant: warning ? "warning" : success ? "success" : "info",
|
|
3747
|
+
duration: 6000,
|
|
3748
|
+
},
|
|
3749
|
+
}))
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3346
3753
|
// Completion auditor. When an auditor is configured, a [goal:complete]
|
|
3347
3754
|
// is verified before the goal is archived: an approved verdict archives it, a
|
|
3348
3755
|
// rejected verdict restores the goal (pauses it with the reason) instead of
|
|
@@ -3499,6 +3906,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3499
3906
|
return persistence.persistChain
|
|
3500
3907
|
}
|
|
3501
3908
|
|
|
3909
|
+
const lifecycleMessagesEnabled = pluginOptions.lifecycleMessages !== false
|
|
3910
|
+
const lifecycleMessenger =
|
|
3911
|
+
typeof pluginOptions.lifecycleMessenger === "function"
|
|
3912
|
+
? pluginOptions.lifecycleMessenger
|
|
3913
|
+
: (sessionID, text) => defaultLifecycleMessenger(client, sessionID, text)
|
|
3914
|
+
const announceLifecycle = (
|
|
3915
|
+
sessionID,
|
|
3916
|
+
text,
|
|
3917
|
+
{
|
|
3918
|
+
goal,
|
|
3919
|
+
transition = "state",
|
|
3920
|
+
reason = "",
|
|
3921
|
+
requireCurrent = true,
|
|
3922
|
+
expectedState = "",
|
|
3923
|
+
expectedStopReason = "",
|
|
3924
|
+
} = {},
|
|
3925
|
+
) => {
|
|
3926
|
+
if (!lifecycleMessagesEnabled || !sessionID) return false
|
|
3927
|
+
if (requireCurrent && goal) {
|
|
3928
|
+
const current = goalStates.get(sessionID)
|
|
3929
|
+
if (current !== goal) return false
|
|
3930
|
+
if (expectedState && goalDisplayState(current) !== expectedState) return false
|
|
3931
|
+
if (expectedStopReason && current.stopReason !== expectedStopReason) return false
|
|
3932
|
+
}
|
|
3933
|
+
const message = summarizeText(text, 500)
|
|
3934
|
+
if (!message) return false
|
|
3935
|
+
dispatchAdvisoryHostCall(
|
|
3936
|
+
() => lifecycleMessenger(sessionID, message),
|
|
3937
|
+
(error) => {
|
|
3938
|
+
void logPluginError(client, "Failed to deliver goal lifecycle message", error).catch(() => {})
|
|
3939
|
+
},
|
|
3940
|
+
)
|
|
3941
|
+
return true
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3502
3944
|
const passiveLoadResult = (entry) => ({
|
|
3503
3945
|
kind: "passive",
|
|
3504
3946
|
code: SESSION_OWNED_ELSEWHERE,
|
|
@@ -3596,7 +4038,40 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3596
4038
|
const status = await loadPersistedSessionState(persistence, client, sessionID)
|
|
3597
4039
|
if (runtime.disposed) return releaseDisposedSession()
|
|
3598
4040
|
pruneGoalResults(defaultGoalOptions)
|
|
3599
|
-
if (
|
|
4041
|
+
if (
|
|
4042
|
+
status === "loaded" ||
|
|
4043
|
+
status === "missing" ||
|
|
4044
|
+
status === "reconstructed" ||
|
|
4045
|
+
status === "reconciled-blocked"
|
|
4046
|
+
) await persist(sessionID)
|
|
4047
|
+
const recoveredGoal = goalStates.get(sessionID)
|
|
4048
|
+
if (recoveredGoal?.stopped && recoveredGoal.stopReason === "recovered after restart") {
|
|
4049
|
+
announceLifecycle(sessionID, `Goal recovered and paused. Run /${commandName} status, then /${commandName} resume when ready.`, {
|
|
4050
|
+
goal: recoveredGoal,
|
|
4051
|
+
transition: "recovered-paused",
|
|
4052
|
+
reason: recoveredGoal.stopReason,
|
|
4053
|
+
expectedState: "paused",
|
|
4054
|
+
expectedStopReason: "recovered after restart",
|
|
4055
|
+
})
|
|
4056
|
+
} else if (
|
|
4057
|
+
status === "reconciled-blocked" &&
|
|
4058
|
+
recoveredGoal?.stopped &&
|
|
4059
|
+
recoveredGoal.stopReason === "blocked"
|
|
4060
|
+
) {
|
|
4061
|
+
announceLifecycle(sessionID, `Goal recovered as blocked. Run /${commandName} status for the reason.`, {
|
|
4062
|
+
goal: recoveredGoal,
|
|
4063
|
+
transition: "recovered-blocked",
|
|
4064
|
+
reason: recoveredGoal.blockedReason,
|
|
4065
|
+
expectedState: "blocked",
|
|
4066
|
+
expectedStopReason: "blocked",
|
|
4067
|
+
})
|
|
4068
|
+
} else if (recoveredGoal?.lastStatus === "Promoted as the next ordered goal.") {
|
|
4069
|
+
announceLifecycle(sessionID, "Goal state recovered; the next ordered goal is active.", {
|
|
4070
|
+
goal: recoveredGoal,
|
|
4071
|
+
transition: "recovered-promoted",
|
|
4072
|
+
expectedState: "active",
|
|
4073
|
+
})
|
|
4074
|
+
}
|
|
3600
4075
|
if (runtime.disposed) return releaseDisposedSession()
|
|
3601
4076
|
return ACTIVE_PERSISTENCE_OWNED
|
|
3602
4077
|
} catch (error) {
|
|
@@ -3681,6 +4156,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3681
4156
|
reason: "owned verifier agent registration was not confirmed",
|
|
3682
4157
|
})
|
|
3683
4158
|
: null
|
|
4159
|
+
const completionAuditLabel =
|
|
4160
|
+
typeof pluginOptions.auditor === "function"
|
|
4161
|
+
? "custom completion auditor"
|
|
4162
|
+
: pluginOptions.completionAudit
|
|
4163
|
+
? "built-in independent verifier"
|
|
4164
|
+
: "evidence gate only (independent verifier off)"
|
|
3684
4165
|
|
|
3685
4166
|
clearRuntimeState()
|
|
3686
4167
|
|
|
@@ -3689,6 +4170,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3689
4170
|
persist,
|
|
3690
4171
|
persistTerminalState,
|
|
3691
4172
|
completionAuditor,
|
|
4173
|
+
completionAuditLabel,
|
|
4174
|
+
announceAudit,
|
|
4175
|
+
auditMessagesEnabled,
|
|
4176
|
+
announceLifecycle,
|
|
3692
4177
|
commandName,
|
|
3693
4178
|
})
|
|
3694
4179
|
|
|
@@ -3714,7 +4199,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3714
4199
|
) => {
|
|
3715
4200
|
const goal = goalStates.get(sessionID)
|
|
3716
4201
|
if (!goal) return false
|
|
4202
|
+
if (goal.stopped && goal.stopReason === reason) return false
|
|
3717
4203
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
4204
|
+
// A goal stopping while deferred must release its watched children, or the
|
|
4205
|
+
// watch outlives the goal and a later child idle re-drives a dead loop.
|
|
4206
|
+
clearDeferredChildren(sessionID)
|
|
4207
|
+
childDeferralNotices.delete(childDeferralKey(sessionID, goal))
|
|
3718
4208
|
goal.stopped = true
|
|
3719
4209
|
goal.stopReason = reason
|
|
3720
4210
|
goal.lastStatus = `${status} Run /${commandName} resume to continue.`
|
|
@@ -3722,10 +4212,187 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3722
4212
|
pushHistory(goal, "paused", history)
|
|
3723
4213
|
activeContinues.delete(sessionID)
|
|
3724
4214
|
await persist(sessionID)
|
|
4215
|
+
announceLifecycle(sessionID, `Goal paused — ${summarizeText(reason, 160)}.`, {
|
|
4216
|
+
goal,
|
|
4217
|
+
transition: "paused",
|
|
4218
|
+
reason,
|
|
4219
|
+
expectedState: "paused",
|
|
4220
|
+
expectedStopReason: reason,
|
|
4221
|
+
})
|
|
3725
4222
|
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3726
4223
|
return true
|
|
3727
4224
|
}
|
|
3728
4225
|
|
|
4226
|
+
// A child counts as active only when the host reports a non-idle status for
|
|
4227
|
+
// it. OpenCode drops idle sessions from the `/session/status` map, so bare
|
|
4228
|
+
// key presence happens to work today, but the SDK response type is
|
|
4229
|
+
// `{[id: string]: SessionStatus}` and `SessionStatus` includes `{type:
|
|
4230
|
+
// "idle"}`. A host that reports idle children explicitly would otherwise
|
|
4231
|
+
// gate every continuation forever and stall the goal with no diagnostics.
|
|
4232
|
+
// Unknown/unparseable status shapes stay "active" so the gate errs toward
|
|
4233
|
+
// deferring rather than double-driving a session a child is working in.
|
|
4234
|
+
const childStatusIsActive = (statusMap, childID) => {
|
|
4235
|
+
if (!Object.hasOwn(statusMap, childID)) return false
|
|
4236
|
+
const status = statusMap[childID]
|
|
4237
|
+
return !(isPlainObject(status) && status.type === "idle")
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
// Hosts that cannot report children/status fail open, but the failure is a
|
|
4241
|
+
// property of the host, not of a single turn: logging it on every
|
|
4242
|
+
// continuation attempt would add one identical error per goal turn.
|
|
4243
|
+
const childActivityProbeFailuresLogged = new Set()
|
|
4244
|
+
const logChildActivityProbeFailure = (kind, message, error) => {
|
|
4245
|
+
if (childActivityProbeFailuresLogged.has(kind)) return Promise.resolve()
|
|
4246
|
+
childActivityProbeFailuresLogged.add(kind)
|
|
4247
|
+
return logPluginError(
|
|
4248
|
+
client,
|
|
4249
|
+
`${message} (further ${kind} failures are suppressed for this plugin instance)`,
|
|
4250
|
+
error,
|
|
4251
|
+
)
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
// With noContinueWhileChildrenActive, auto-continue is deferred while any
|
|
4255
|
+
// child session (subagent, background task) is still active, so the goal
|
|
4256
|
+
// loop does not prompt the orchestrator over work a child is already doing.
|
|
4257
|
+
// Fail open: if the host cannot report children/status, continue as before.
|
|
4258
|
+
const activeChildSessionIDs = async (sessionID) => {
|
|
4259
|
+
try {
|
|
4260
|
+
const [children, status] = await Promise.all([
|
|
4261
|
+
sessionApi.children(sessionID),
|
|
4262
|
+
sessionApi.status(),
|
|
4263
|
+
])
|
|
4264
|
+
// A live opencode SDK does not throw on an argument-shape mismatch; it
|
|
4265
|
+
// resolves with `{error, request, response}` and no `data`. Treating that
|
|
4266
|
+
// silently as "no children" would turn the whole gate into a no-op with
|
|
4267
|
+
// no diagnostic, so an unusable payload takes the same logged fail-open
|
|
4268
|
+
// path as a thrown error.
|
|
4269
|
+
if (!Array.isArray(children) || !isPlainObject(status)) {
|
|
4270
|
+
await logChildActivityProbeFailure(
|
|
4271
|
+
"payload",
|
|
4272
|
+
"Child session activity probe returned an unusable payload; continuing without the active-children gate",
|
|
4273
|
+
new Error(
|
|
4274
|
+
`children=${Array.isArray(children) ? "array" : typeof children}, status=${isPlainObject(status) ? "object" : typeof status}`,
|
|
4275
|
+
),
|
|
4276
|
+
)
|
|
4277
|
+
return []
|
|
4278
|
+
}
|
|
4279
|
+
return children
|
|
4280
|
+
.filter(
|
|
4281
|
+
(child) =>
|
|
4282
|
+
isPlainObject(child) &&
|
|
4283
|
+
typeof child.id === "string" &&
|
|
4284
|
+
childStatusIsActive(status, child.id),
|
|
4285
|
+
)
|
|
4286
|
+
.map((child) => child.id)
|
|
4287
|
+
} catch (error) {
|
|
4288
|
+
await logChildActivityProbeFailure(
|
|
4289
|
+
"probe",
|
|
4290
|
+
"Failed to check child session activity; continuing without the active-children gate",
|
|
4291
|
+
error,
|
|
4292
|
+
)
|
|
4293
|
+
return []
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
// Deferral is only announced on the transition into and out of the gated
|
|
4298
|
+
// state. Without this the goal reports itself as running while doing nothing
|
|
4299
|
+
// at all, which is indistinguishable from a hang in `/goal status`.
|
|
4300
|
+
const childDeferralNotices = new Set()
|
|
4301
|
+
const childDeferralKey = (sessionID, goal) =>
|
|
4302
|
+
`${sessionID}\u0000${goal.goalId}\u0000${goal.runId}`
|
|
4303
|
+
|
|
4304
|
+
// Idle events are session-scoped and a child's completion is delivered only
|
|
4305
|
+
// on the child's own session: a parent that is already idle emits nothing at
|
|
4306
|
+
// all while a child runs and finishes (verified against a live opencode
|
|
4307
|
+
// server). Because the continuation driver is purely event-driven, a goal
|
|
4308
|
+
// deferred behind a child would never be retried. Remember the children we
|
|
4309
|
+
// deferred on so their idle event can re-drive the parent exactly once.
|
|
4310
|
+
// Entries carry the goal identity, not just the parent session: `cleanupGoal`
|
|
4311
|
+
// runs on clear/replace/complete from many call sites, so rather than hooking
|
|
4312
|
+
// every one of them the wake path re-validates that the goal which deferred is
|
|
4313
|
+
// still the goal in focus. A stale entry is dropped instead of driving a
|
|
4314
|
+
// continuation for a goal that never deferred.
|
|
4315
|
+
const MAX_DEFERRED_CHILD_WATCH = 256
|
|
4316
|
+
const deferredChildWatch = new Map()
|
|
4317
|
+
// Monotonic marker for idle events seen from sessions that hold no goal. The
|
|
4318
|
+
// probe is asynchronous, so a child can go idle between the status snapshot
|
|
4319
|
+
// and the watch being armed: its event arrives with nothing armed, is
|
|
4320
|
+
// dropped, and the watch is then set on a session that will never emit again.
|
|
4321
|
+
// Recording the sequence at which each child was last seen idle lets the gate
|
|
4322
|
+
// notice that and continue instead of waiting forever.
|
|
4323
|
+
// Guards the synthesized parent wake below against re-entering itself. Keyed
|
|
4324
|
+
// by parent session: the wake is awaited across several SDK round-trips, and
|
|
4325
|
+
// a single shared counter would drop every other parent's wake arriving in
|
|
4326
|
+
// that window — a permanent strand, silently, in an unrelated goal.
|
|
4327
|
+
const childWakeInFlight = new Set()
|
|
4328
|
+
let idleEventSequence = 0
|
|
4329
|
+
const childIdleSequence = new Map()
|
|
4330
|
+
const recordChildIdle = (childSessionID) => {
|
|
4331
|
+
if (!childSessionID) return
|
|
4332
|
+
idleEventSequence += 1
|
|
4333
|
+
childIdleSequence.set(childSessionID, idleEventSequence)
|
|
4334
|
+
while (childIdleSequence.size > MAX_DEFERRED_CHILD_WATCH) {
|
|
4335
|
+
childIdleSequence.delete(childIdleSequence.keys().next().value)
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
const idledSince = (childSessionID, sequence) =>
|
|
4339
|
+
(childIdleSequence.get(childSessionID) ?? 0) > sequence
|
|
4340
|
+
// Returns false when the children cannot all be tracked. Deferring without a
|
|
4341
|
+
// complete watch would strand the goal the moment an untracked child is the
|
|
4342
|
+
// one that finishes, so the caller continues instead. Capacity is never
|
|
4343
|
+
// reclaimed by evicting a live entry: that is the same silent strand seen
|
|
4344
|
+
// from the other direction.
|
|
4345
|
+
const watchDeferredChildren = (sessionID, goal, childIDs) => {
|
|
4346
|
+
for (const [childID, watched] of deferredChildWatch) {
|
|
4347
|
+
if (watched.sessionID === sessionID && !childIDs.includes(childID)) {
|
|
4348
|
+
deferredChildWatch.delete(childID)
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
pruneDeferredChildState()
|
|
4352
|
+
let otherSessionEntries = 0
|
|
4353
|
+
for (const watched of deferredChildWatch.values()) {
|
|
4354
|
+
if (watched.sessionID !== sessionID) otherSessionEntries += 1
|
|
4355
|
+
}
|
|
4356
|
+
if (otherSessionEntries + childIDs.length > MAX_DEFERRED_CHILD_WATCH) return false
|
|
4357
|
+
for (const childID of childIDs) {
|
|
4358
|
+
deferredChildWatch.set(childID, {
|
|
4359
|
+
sessionID,
|
|
4360
|
+
goalId: goal.goalId,
|
|
4361
|
+
runId: goal.runId,
|
|
4362
|
+
})
|
|
4363
|
+
}
|
|
4364
|
+
return true
|
|
4365
|
+
}
|
|
4366
|
+
|
|
4367
|
+
// Bounded like every other runtime map in this file, but eviction must never
|
|
4368
|
+
// discard a watch a live goal is waiting on: that would strand it with no
|
|
4369
|
+
// diagnostic, which is the failure this whole mechanism exists to prevent.
|
|
4370
|
+
// Entries whose goal has been cleared, replaced, completed or stopped are
|
|
4371
|
+
// dead weight and are dropped first; the cap is only enforced against live
|
|
4372
|
+
// entries as a last resort.
|
|
4373
|
+
const deferralGoalIsLive = (watched) => {
|
|
4374
|
+
const goal = goalStates.get(watched.sessionID)
|
|
4375
|
+
return Boolean(
|
|
4376
|
+
goal && goal.goalId === watched.goalId && goal.runId === watched.runId && !goal.stopped,
|
|
4377
|
+
)
|
|
4378
|
+
}
|
|
4379
|
+
const pruneDeferredChildState = () => {
|
|
4380
|
+
for (const [childID, watched] of deferredChildWatch) {
|
|
4381
|
+
if (!deferralGoalIsLive(watched)) deferredChildWatch.delete(childID)
|
|
4382
|
+
}
|
|
4383
|
+
for (const key of childDeferralNotices) {
|
|
4384
|
+
const [noticeSessionID, goalId, runId] = key.split("\u0000")
|
|
4385
|
+
if (!deferralGoalIsLive({ sessionID: noticeSessionID, goalId, runId })) {
|
|
4386
|
+
childDeferralNotices.delete(key)
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
const clearDeferredChildren = (sessionID) => {
|
|
4391
|
+
for (const [childID, watched] of deferredChildWatch) {
|
|
4392
|
+
if (watched.sessionID === sessionID) deferredChildWatch.delete(childID)
|
|
4393
|
+
}
|
|
4394
|
+
}
|
|
4395
|
+
|
|
3729
4396
|
const claimContinuationSource = async (
|
|
3730
4397
|
sessionID,
|
|
3731
4398
|
goalID,
|
|
@@ -3760,10 +4427,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3760
4427
|
return null
|
|
3761
4428
|
}
|
|
3762
4429
|
|
|
4430
|
+
// Human intervention is evaluated before the active-children gate: a real
|
|
4431
|
+
// user message must pause the goal immediately, not once the subagents
|
|
4432
|
+
// happen to go idle.
|
|
3763
4433
|
const newHumanMessage =
|
|
3764
4434
|
refreshed.latestRealUserMessageID &&
|
|
3765
4435
|
refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID
|
|
3766
|
-
if (
|
|
4436
|
+
if (
|
|
4437
|
+
!goal.options.noInterruptOnUserMessage &&
|
|
4438
|
+
(newHumanMessage || userInterventionDetected(messages, goal))
|
|
4439
|
+
) {
|
|
4440
|
+
childDeferralNotices.delete(childDeferralKey(sessionID, goal))
|
|
3767
4441
|
await pauseActiveGoal(sessionID, {
|
|
3768
4442
|
stopReason: "user intervention",
|
|
3769
4443
|
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
@@ -3772,6 +4446,70 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3772
4446
|
return null
|
|
3773
4447
|
}
|
|
3774
4448
|
|
|
4449
|
+
if (goal.options.noContinueWhileChildrenActive) {
|
|
4450
|
+
const deferralKey = childDeferralKey(sessionID, goal)
|
|
4451
|
+
const sequenceBeforeProbe = idleEventSequence
|
|
4452
|
+
let activeChildren = await activeChildSessionIDs(sessionID)
|
|
4453
|
+
// A child running a goal of its own consumes its idle events for that
|
|
4454
|
+
// goal, so it cannot deliver the wake this gate depends on. Deferring
|
|
4455
|
+
// behind one would strand the parent silently; the gate steps aside.
|
|
4456
|
+
const selfDrivenChildren = activeChildren.filter((childID) => goalStates.has(childID))
|
|
4457
|
+
if (selfDrivenChildren.length > 0) {
|
|
4458
|
+
await logChildActivityProbeFailure(
|
|
4459
|
+
"self-driven-child",
|
|
4460
|
+
`Active child session(s) ${selfDrivenChildren.join(", ")} run goals of their own and cannot wake this goal; continuing without the active-children gate`,
|
|
4461
|
+
new Error("watched child holds its own goal state"),
|
|
4462
|
+
)
|
|
4463
|
+
activeChildren = []
|
|
4464
|
+
}
|
|
4465
|
+
if (activeChildren.length > 0) {
|
|
4466
|
+
// Arm the watch, then confirm the children are still active. A child
|
|
4467
|
+
// that went idle while the first probe was in flight would already have
|
|
4468
|
+
// delivered its event, finding nothing armed, and the goal would wait
|
|
4469
|
+
// for a wake-up that can never come. Re-probing after arming closes
|
|
4470
|
+
// that window: from here on any transition is observed by the watch.
|
|
4471
|
+
if (!watchDeferredChildren(sessionID, goal, activeChildren)) {
|
|
4472
|
+
// More concurrent children than the watch can hold. Continuing is the
|
|
4473
|
+
// safe direction: the gate is an optimisation, a stranded goal is not.
|
|
4474
|
+
await logChildActivityProbeFailure(
|
|
4475
|
+
"watch-capacity",
|
|
4476
|
+
`Cannot track ${activeChildren.length} active child session(s) within the watch limit; continuing without the active-children gate`,
|
|
4477
|
+
new Error(`watch limit ${MAX_DEFERRED_CHILD_WATCH} exceeded`),
|
|
4478
|
+
)
|
|
4479
|
+
activeChildren = []
|
|
4480
|
+
} else {
|
|
4481
|
+
activeChildren = await activeChildSessionIDs(sessionID)
|
|
4482
|
+
// Drop any child that went idle while a probe was in flight: its wake
|
|
4483
|
+
// event has already been delivered and will not come again.
|
|
4484
|
+
activeChildren = activeChildren.filter(
|
|
4485
|
+
(childID) => !idledSince(childID, sequenceBeforeProbe),
|
|
4486
|
+
)
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
if (activeChildren.length > 0) {
|
|
4490
|
+
if (!childDeferralNotices.has(deferralKey)) {
|
|
4491
|
+
childDeferralNotices.add(deferralKey)
|
|
4492
|
+
goal.lastStatus =
|
|
4493
|
+
"Auto-continue deferred while a child session (subagent or background task) is still active. The goal is still running and continues once the children finish."
|
|
4494
|
+
// One entry per episode, not one per transition: history is a
|
|
4495
|
+
// 20-entry ring and a subagent-heavy run would otherwise evict
|
|
4496
|
+
// checkpoints and limit warnings.
|
|
4497
|
+
pushHistory(
|
|
4498
|
+
goal,
|
|
4499
|
+
"deferred",
|
|
4500
|
+
"Deferred auto-continue while child sessions were active.",
|
|
4501
|
+
)
|
|
4502
|
+
await persist(sessionID)
|
|
4503
|
+
}
|
|
4504
|
+
return null
|
|
4505
|
+
}
|
|
4506
|
+
clearDeferredChildren(sessionID)
|
|
4507
|
+
if (childDeferralNotices.delete(deferralKey)) {
|
|
4508
|
+
goal.lastStatus = "Child sessions went idle; auto-continue resumed."
|
|
4509
|
+
await persist(sessionID)
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
|
|
3775
4513
|
if (
|
|
3776
4514
|
refreshed.latestAssistantID !== baseline.latestAssistantID ||
|
|
3777
4515
|
refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID
|
|
@@ -3798,6 +4536,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3798
4536
|
goal.stopReason = "continuation claim persistence failed"
|
|
3799
4537
|
goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
|
|
3800
4538
|
pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
|
|
4539
|
+
announceLifecycle(sessionID, "Goal paused — continuation state could not be persisted.", {
|
|
4540
|
+
goal,
|
|
4541
|
+
transition: "continuation-persistence-failed",
|
|
4542
|
+
reason: goal.stopReason,
|
|
4543
|
+
expectedState: "paused",
|
|
4544
|
+
expectedStopReason: "continuation claim persistence failed",
|
|
4545
|
+
})
|
|
3801
4546
|
return null
|
|
3802
4547
|
}
|
|
3803
4548
|
return goal
|
|
@@ -3929,6 +4674,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3929
4674
|
|
|
3930
4675
|
const goal = goalStates.get(sessionID)
|
|
3931
4676
|
if (!goal || goal.stopped) return
|
|
4677
|
+
// With noInterruptOnUserMessage, a human message steers the running loop
|
|
4678
|
+
// instead of pausing the goal for /goal resume.
|
|
4679
|
+
if (goal.options.noInterruptOnUserMessage) return
|
|
3932
4680
|
await pauseActiveGoal(sessionID, {
|
|
3933
4681
|
stopReason: "user intervention",
|
|
3934
4682
|
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
@@ -3992,7 +4740,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3992
4740
|
replaceCommandOutputText(
|
|
3993
4741
|
output,
|
|
3994
4742
|
goal
|
|
3995
|
-
? formatStatus(goal, commandName)
|
|
4743
|
+
? formatStatus(goal, commandName, completionAuditLabel)
|
|
3996
4744
|
: lastResult
|
|
3997
4745
|
? formatGoalResult(lastResult)
|
|
3998
4746
|
: `No active goal. Set one with \`/${commandName} <condition>\`.`,
|
|
@@ -4033,15 +4781,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4033
4781
|
// sessionGoals.delete clears ALL backgrounded goals so they do not
|
|
4034
4782
|
// resurrect as the focused goal on restart (cleanupGoal only removes the
|
|
4035
4783
|
// focused one; background goals from `/goal add` would survive otherwise).
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4784
|
+
const goals = listSessionGoals(sessionID)
|
|
4785
|
+
const clearedGoal = goalStates.get(sessionID) || goals[0] || null
|
|
4786
|
+
const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
|
|
4787
|
+
const ledgerDurable =
|
|
4788
|
+
goals.length > 0 &&
|
|
4789
|
+
goals.map((goal) => pushHistory(goal, "cleared", "User cleared the goal.")).every(Boolean)
|
|
4039
4790
|
sessionOrdered.delete(sessionID)
|
|
4040
4791
|
sessionGoals.delete(sessionID)
|
|
4041
4792
|
cleanupGoal(sessionID)
|
|
4042
4793
|
lastGoalResults.delete(sessionID)
|
|
4043
|
-
await
|
|
4044
|
-
|
|
4794
|
+
const durable = await persistTerminalState(sessionID, "clear", ledgerDurable)
|
|
4795
|
+
const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
|
|
4796
|
+
if (hadState && clearStillCurrent) {
|
|
4797
|
+
announceLifecycle(sessionID, durable === false
|
|
4798
|
+
? "Goal cleared in memory, but storage failed; it may reappear after restart."
|
|
4799
|
+
: "Goal cleared.", {
|
|
4800
|
+
goal: clearedGoal,
|
|
4801
|
+
transition: durable === false ? "clear-persistence-failed" : "cleared",
|
|
4802
|
+
requireCurrent: false,
|
|
4803
|
+
})
|
|
4804
|
+
}
|
|
4805
|
+
replaceCommandOutputText(
|
|
4806
|
+
output,
|
|
4807
|
+
!clearStillCurrent
|
|
4808
|
+
? "Clear persistence finished after goal state changed; current state was left untouched."
|
|
4809
|
+
: durable === false
|
|
4810
|
+
? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
|
|
4811
|
+
: "Goal cleared.",
|
|
4812
|
+
)
|
|
4045
4813
|
return
|
|
4046
4814
|
}
|
|
4047
4815
|
|
|
@@ -4051,6 +4819,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4051
4819
|
replaceCommandOutputText(output, `No active goal. Set one with \`/${commandName} <condition>\`.`)
|
|
4052
4820
|
return
|
|
4053
4821
|
}
|
|
4822
|
+
if (goal.stopped && goal.stopReason === "paused") {
|
|
4823
|
+
replaceCommandOutputText(output, "Goal is already paused.")
|
|
4824
|
+
return
|
|
4825
|
+
}
|
|
4054
4826
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
4055
4827
|
goal.stopped = true
|
|
4056
4828
|
goal.stopReason = "paused"
|
|
@@ -4059,6 +4831,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4059
4831
|
activeContinues.delete(sessionID)
|
|
4060
4832
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
4061
4833
|
await persist(sessionID)
|
|
4834
|
+
announceLifecycle(sessionID, "Goal paused.", {
|
|
4835
|
+
goal,
|
|
4836
|
+
transition: "paused",
|
|
4837
|
+
reason: goal.stopReason,
|
|
4838
|
+
expectedState: "paused",
|
|
4839
|
+
expectedStopReason: "paused",
|
|
4840
|
+
})
|
|
4062
4841
|
await abortAcceptedContinuation(sessionID)
|
|
4063
4842
|
replaceCommandOutputText(output, `Goal paused: ${goal.condition}`)
|
|
4064
4843
|
return
|
|
@@ -4085,6 +4864,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4085
4864
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
4086
4865
|
pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
|
|
4087
4866
|
await persist(sessionID)
|
|
4867
|
+
announceLifecycle(sessionID, "Goal resumed with fresh limits.", {
|
|
4868
|
+
goal,
|
|
4869
|
+
transition: "resumed",
|
|
4870
|
+
expectedState: "active",
|
|
4871
|
+
})
|
|
4088
4872
|
replaceCommandOutputText(output, `Goal resumed with fresh limits: ${goal.condition}`, {
|
|
4089
4873
|
startsWork: true,
|
|
4090
4874
|
})
|
|
@@ -4132,6 +4916,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4132
4916
|
goal.lastStatus = "Goal objective updated."
|
|
4133
4917
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
4134
4918
|
await persist(sessionID)
|
|
4919
|
+
announceLifecycle(sessionID, "Goal updated and active.", {
|
|
4920
|
+
goal,
|
|
4921
|
+
transition: "updated-active",
|
|
4922
|
+
expectedState: "active",
|
|
4923
|
+
})
|
|
4135
4924
|
replaceCommandOutputText(
|
|
4136
4925
|
output,
|
|
4137
4926
|
[
|
|
@@ -4212,6 +5001,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4212
5001
|
focusGoal(sessionID, firstGoal)
|
|
4213
5002
|
sessionOrdered.add(sessionID)
|
|
4214
5003
|
await persist(sessionID)
|
|
5004
|
+
announceLifecycle(sessionID, `Ordered goal sequence active (${objectives.length} goals).`, {
|
|
5005
|
+
goal: firstGoal,
|
|
5006
|
+
transition: "sequence-active",
|
|
5007
|
+
reason: String(objectives.length),
|
|
5008
|
+
expectedState: "active",
|
|
5009
|
+
})
|
|
4215
5010
|
replaceCommandOutputText(
|
|
4216
5011
|
output,
|
|
4217
5012
|
[
|
|
@@ -4277,6 +5072,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4277
5072
|
pushHistory(target, "focused", "Brought into focus as the session's active goal.")
|
|
4278
5073
|
focusGoal(sessionID, target)
|
|
4279
5074
|
await persist(sessionID)
|
|
5075
|
+
announceLifecycle(sessionID, "Goal focus changed; selected goal active.", {
|
|
5076
|
+
goal: target,
|
|
5077
|
+
transition: "focused-active",
|
|
5078
|
+
expectedState: "active",
|
|
5079
|
+
})
|
|
4280
5080
|
replaceCommandOutputText(
|
|
4281
5081
|
output,
|
|
4282
5082
|
[
|
|
@@ -4335,6 +5135,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4335
5135
|
registerSessionGoal(added)
|
|
4336
5136
|
focusGoal(sessionID, added)
|
|
4337
5137
|
await persist(sessionID)
|
|
5138
|
+
announceLifecycle(sessionID, current
|
|
5139
|
+
? "Goal added and active; previous goal backgrounded."
|
|
5140
|
+
: "Goal added and active.", {
|
|
5141
|
+
goal: added,
|
|
5142
|
+
transition: current ? "added-active-backgrounded" : "added-active",
|
|
5143
|
+
expectedState: "active",
|
|
5144
|
+
})
|
|
4338
5145
|
const total = listSessionGoals(sessionID).length
|
|
4339
5146
|
replaceCommandOutputText(
|
|
4340
5147
|
output,
|
|
@@ -4373,6 +5180,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4373
5180
|
registerSessionGoal(goal)
|
|
4374
5181
|
focusGoal(sessionID, goal)
|
|
4375
5182
|
await persist(sessionID)
|
|
5183
|
+
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
|
|
5184
|
+
goal,
|
|
5185
|
+
transition: replacedGoal ? "replaced-active" : "active",
|
|
5186
|
+
expectedState: "active",
|
|
5187
|
+
})
|
|
4376
5188
|
replaceCommandOutputText(
|
|
4377
5189
|
output,
|
|
4378
5190
|
[
|
|
@@ -4590,12 +5402,62 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4590
5402
|
|
|
4591
5403
|
if (!isIdleEvent(event)) return
|
|
4592
5404
|
|
|
4593
|
-
const
|
|
5405
|
+
const emittingSessionID = getSessionID(event)
|
|
5406
|
+
let sessionID = emittingSessionID
|
|
5407
|
+
// A child we deferred on has gone idle. The parent emits no event of its
|
|
5408
|
+
// own, so this is the only chance to re-drive its continuation. Consumed
|
|
5409
|
+
// once: unrelated children (the completion auditor's own session, for
|
|
5410
|
+
// example) are never watched and so can never trigger a continuation.
|
|
5411
|
+
let childWakeEvent = event?.[CHILD_WAKE_EVENT_FLAG] === true
|
|
5412
|
+
if (sessionID && !goalStates.has(sessionID)) recordChildIdle(sessionID)
|
|
5413
|
+
if (sessionID && deferredChildWatch.has(sessionID)) {
|
|
5414
|
+
const watched = deferredChildWatch.get(sessionID)
|
|
5415
|
+
deferredChildWatch.delete(sessionID)
|
|
5416
|
+
// The goal that deferred must still be the goal in focus. If it was
|
|
5417
|
+
// cleared, replaced, completed or restarted in the meantime, this wake
|
|
5418
|
+
// belongs to nothing and must not drive the goal that took its place.
|
|
5419
|
+
const parentGoal = goalStates.get(watched.sessionID)
|
|
5420
|
+
const parentStillWaiting =
|
|
5421
|
+
parentGoal &&
|
|
5422
|
+
parentGoal.goalId === watched.goalId &&
|
|
5423
|
+
parentGoal.runId === watched.runId
|
|
5424
|
+
if (parentStillWaiting && !goalStates.has(sessionID)) {
|
|
5425
|
+
sessionID = watched.sessionID
|
|
5426
|
+
childWakeEvent = true
|
|
5427
|
+
} else if (
|
|
5428
|
+
parentStillWaiting &&
|
|
5429
|
+
!childWakeInFlight.has(watched.sessionID) &&
|
|
5430
|
+
currentRuntime().sessionStatuses.get(watched.sessionID) === "idle"
|
|
5431
|
+
) {
|
|
5432
|
+
// The child acquired a goal of its own after being watched, so it
|
|
5433
|
+
// needs this event for its own loop. Serving only one of the two
|
|
5434
|
+
// would starve the other, so the parent is woken through a
|
|
5435
|
+
// synthesized idle of its own before the child's event continues.
|
|
5436
|
+
childWakeInFlight.add(watched.sessionID)
|
|
5437
|
+
try {
|
|
5438
|
+
await hooks.event({
|
|
5439
|
+
event: {
|
|
5440
|
+
type: "session.idle",
|
|
5441
|
+
properties: { sessionID: watched.sessionID },
|
|
5442
|
+
// B: the synthesized event is a wake pass like any other, so it
|
|
5443
|
+
// must not re-charge the stall gates for an assistant turn the
|
|
5444
|
+
// deferring pass already scored.
|
|
5445
|
+
[CHILD_WAKE_EVENT_FLAG]: true,
|
|
5446
|
+
},
|
|
5447
|
+
})
|
|
5448
|
+
} finally {
|
|
5449
|
+
childWakeInFlight.delete(watched.sessionID)
|
|
5450
|
+
}
|
|
5451
|
+
}
|
|
5452
|
+
}
|
|
4594
5453
|
// Deprecated session.idle carries no status object but is itself an
|
|
4595
5454
|
// authoritative idle signal. Current session.status events were recorded
|
|
4596
|
-
// above before entering this branch.
|
|
5455
|
+
// above before entering this branch. Record it against the session that
|
|
5456
|
+
// actually emitted it: a child going idle says nothing about whether its
|
|
5457
|
+
// parent is idle, and claiming otherwise would defeat the idle guard in
|
|
5458
|
+
// the continuation claim.
|
|
4597
5459
|
if (event?.type === "session.idle") {
|
|
4598
|
-
currentRuntime().sessionStatuses.set(
|
|
5460
|
+
currentRuntime().sessionStatuses.set(emittingSessionID, "idle")
|
|
4599
5461
|
}
|
|
4600
5462
|
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
4601
5463
|
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
@@ -4672,7 +5534,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4672
5534
|
// Latest instruction wins: if a real (non-plugin) user message arrived
|
|
4673
5535
|
// since the last auto-continue, stop driving the loop and defer to the
|
|
4674
5536
|
// human. They can /goal resume to hand control back to the plugin.
|
|
4675
|
-
if (
|
|
5537
|
+
if (
|
|
5538
|
+
!activeGoalAfterMessages.options.noInterruptOnUserMessage &&
|
|
5539
|
+
userInterventionDetected(messages, activeGoalAfterMessages)
|
|
5540
|
+
) {
|
|
4676
5541
|
await pauseActiveGoal(sessionID, {
|
|
4677
5542
|
stopReason: "user intervention",
|
|
4678
5543
|
status: "Auto-continue paused because a new human message arrived; the latest instruction wins.",
|
|
@@ -4740,7 +5605,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4740
5605
|
auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
4741
5606
|
pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
4742
5607
|
await persist(sessionID)
|
|
4743
|
-
|
|
5608
|
+
const rejectedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
|
|
5609
|
+
if (
|
|
5610
|
+
rejectedGoalAfterPersist !== auditedGoal ||
|
|
5611
|
+
!auditedGoal.stopped ||
|
|
5612
|
+
auditedGoal.stopReason !== "audit rejected"
|
|
5613
|
+
) return
|
|
5614
|
+
if (auditMessagesEnabled) {
|
|
5615
|
+
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
5616
|
+
} else {
|
|
5617
|
+
announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
|
|
5618
|
+
goal: auditedGoal,
|
|
5619
|
+
transition: "audit-rejected",
|
|
5620
|
+
reason,
|
|
5621
|
+
expectedState: "paused",
|
|
5622
|
+
expectedStopReason: "audit rejected",
|
|
5623
|
+
})
|
|
5624
|
+
}
|
|
4744
5625
|
return
|
|
4745
5626
|
}
|
|
4746
5627
|
pushHistory(
|
|
@@ -4760,23 +5641,80 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4760
5641
|
`Assistant marked the goal complete with evidence: ${summarizeText(evidence, 400)}`,
|
|
4761
5642
|
)
|
|
4762
5643
|
const ordered = sessionOrdered.has(sessionID)
|
|
4763
|
-
|
|
5644
|
+
const completedResult = rememberGoalResult(
|
|
5645
|
+
sessionID,
|
|
5646
|
+
activeGoalAfterMessages,
|
|
5647
|
+
"achieved",
|
|
5648
|
+
"",
|
|
5649
|
+
evidence,
|
|
5650
|
+
)
|
|
4764
5651
|
cleanupGoal(sessionID)
|
|
4765
5652
|
// Ordered sequence: auto-promote the next goal so the
|
|
4766
5653
|
// session keeps working through the sequence without manual /goal focus.
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
}
|
|
5654
|
+
const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
|
|
5655
|
+
const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
|
|
4770
5656
|
const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
|
|
4771
5657
|
if (durable === false) {
|
|
4772
|
-
|
|
4773
|
-
await announceAudit(
|
|
5658
|
+
const restored = restoreAfterTerminalPersistenceFailure(
|
|
4774
5659
|
sessionID,
|
|
4775
|
-
|
|
5660
|
+
activeGoalAfterMessages,
|
|
5661
|
+
{
|
|
5662
|
+
ordered,
|
|
5663
|
+
expectedCurrentSnapshot: postCompletionSnapshot,
|
|
5664
|
+
expectedResult: completedResult,
|
|
5665
|
+
},
|
|
4776
5666
|
)
|
|
5667
|
+
if (auditMessagesEnabled) {
|
|
5668
|
+
await announceAudit(
|
|
5669
|
+
sessionID,
|
|
5670
|
+
restored
|
|
5671
|
+
? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
|
|
5672
|
+
: "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
|
|
5673
|
+
)
|
|
5674
|
+
} else {
|
|
5675
|
+
announceLifecycle(
|
|
5676
|
+
sessionID,
|
|
5677
|
+
restored
|
|
5678
|
+
? "Goal paused — completion could not be recorded durably."
|
|
5679
|
+
: "Previous goal completion could not be confirmed durably after goal state changed.",
|
|
5680
|
+
restored
|
|
5681
|
+
? {
|
|
5682
|
+
goal: activeGoalAfterMessages,
|
|
5683
|
+
transition: "terminal-persistence-failed",
|
|
5684
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5685
|
+
expectedState: "paused",
|
|
5686
|
+
expectedStopReason: "terminal persistence failed",
|
|
5687
|
+
}
|
|
5688
|
+
: {
|
|
5689
|
+
transition: "terminal-persistence-raced",
|
|
5690
|
+
requireCurrent: false,
|
|
5691
|
+
},
|
|
5692
|
+
)
|
|
5693
|
+
}
|
|
4777
5694
|
return
|
|
4778
5695
|
}
|
|
4779
|
-
|
|
5696
|
+
const activePromoted = promoted
|
|
5697
|
+
? activeGoal(sessionID, promoted.goalId, promoted.runId)
|
|
5698
|
+
: null
|
|
5699
|
+
if (auditMessagesEnabled) {
|
|
5700
|
+
await announceAudit(
|
|
5701
|
+
sessionID,
|
|
5702
|
+
activePromoted
|
|
5703
|
+
? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
|
|
5704
|
+
: "Audit result: completion accepted — goal archived as achieved.",
|
|
5705
|
+
)
|
|
5706
|
+
} else {
|
|
5707
|
+
announceLifecycle(
|
|
5708
|
+
sessionID,
|
|
5709
|
+
activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
|
|
5710
|
+
{
|
|
5711
|
+
goal: activePromoted || activeGoalAfterMessages,
|
|
5712
|
+
transition: activePromoted ? "achieved-promoted" : "achieved",
|
|
5713
|
+
requireCurrent: Boolean(activePromoted),
|
|
5714
|
+
expectedState: activePromoted ? "active" : "",
|
|
5715
|
+
},
|
|
5716
|
+
)
|
|
5717
|
+
}
|
|
4780
5718
|
return
|
|
4781
5719
|
}
|
|
4782
5720
|
completionUnverified = true
|
|
@@ -4802,16 +5740,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4802
5740
|
blockedGoal.stopReason = "blocked"
|
|
4803
5741
|
const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
|
|
4804
5742
|
const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
|
|
5743
|
+
const blockedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
|
|
5744
|
+
if (
|
|
5745
|
+
blockedGoalAfterPersist !== blockedGoal ||
|
|
5746
|
+
!blockedGoal.stopped ||
|
|
5747
|
+
blockedGoal.stopReason !== "blocked"
|
|
5748
|
+
) return
|
|
4805
5749
|
if (durable === false) {
|
|
4806
5750
|
blockedGoal.stopReason = "terminal persistence failed"
|
|
4807
5751
|
blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
4808
|
-
|
|
5752
|
+
if (auditMessagesEnabled) {
|
|
5753
|
+
await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
|
|
5754
|
+
} else {
|
|
5755
|
+
announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
|
|
5756
|
+
goal: blockedGoal,
|
|
5757
|
+
transition: "terminal-persistence-failed",
|
|
5758
|
+
reason: blockedGoal.stopReason,
|
|
5759
|
+
expectedState: "paused",
|
|
5760
|
+
expectedStopReason: "terminal persistence failed",
|
|
5761
|
+
})
|
|
5762
|
+
}
|
|
4809
5763
|
return
|
|
4810
5764
|
}
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
5765
|
+
if (auditMessagesEnabled) {
|
|
5766
|
+
await announceAudit(
|
|
5767
|
+
sessionID,
|
|
5768
|
+
`Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
|
|
5769
|
+
)
|
|
5770
|
+
} else {
|
|
5771
|
+
announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
|
|
5772
|
+
goal: blockedGoal,
|
|
5773
|
+
transition: "blocked",
|
|
5774
|
+
expectedState: "blocked",
|
|
5775
|
+
expectedStopReason: "blocked",
|
|
5776
|
+
})
|
|
5777
|
+
}
|
|
4815
5778
|
return
|
|
4816
5779
|
}
|
|
4817
5780
|
blockerUnstated = true
|
|
@@ -4826,6 +5789,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4826
5789
|
|
|
4827
5790
|
const limitReason = stopReason(activeGoalAfterMessages)
|
|
4828
5791
|
if (limitReason) {
|
|
5792
|
+
let lifecycleAnnounced = false
|
|
4829
5793
|
if (!activeGoalAfterMessages.budgetWrapupSent) {
|
|
4830
5794
|
const claimedGoal = await claimContinuationSource(
|
|
4831
5795
|
sessionID,
|
|
@@ -4842,6 +5806,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4842
5806
|
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
4843
5807
|
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
4844
5808
|
await persist(sessionID)
|
|
5809
|
+
lifecycleAnnounced = announceLifecycle(
|
|
5810
|
+
sessionID,
|
|
5811
|
+
`Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`,
|
|
5812
|
+
{
|
|
5813
|
+
goal: claimedGoal,
|
|
5814
|
+
transition: "limit-paused",
|
|
5815
|
+
reason: limitReason,
|
|
5816
|
+
expectedState: "paused",
|
|
5817
|
+
expectedStopReason: limitReason,
|
|
5818
|
+
},
|
|
5819
|
+
)
|
|
4845
5820
|
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
4846
5821
|
let response
|
|
4847
5822
|
try {
|
|
@@ -4868,6 +5843,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4868
5843
|
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
4869
5844
|
}
|
|
4870
5845
|
await persist(sessionID)
|
|
5846
|
+
if (!lifecycleAnnounced) {
|
|
5847
|
+
announceLifecycle(sessionID, `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, {
|
|
5848
|
+
goal: activeGoalAfterMessages,
|
|
5849
|
+
transition: "limit-paused",
|
|
5850
|
+
reason: limitReason,
|
|
5851
|
+
expectedState: "paused",
|
|
5852
|
+
expectedStopReason: limitReason,
|
|
5853
|
+
})
|
|
5854
|
+
}
|
|
4871
5855
|
return
|
|
4872
5856
|
}
|
|
4873
5857
|
|
|
@@ -4899,7 +5883,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4899
5883
|
!latestHasToolCall &&
|
|
4900
5884
|
!latestHasThinkingTokens &&
|
|
4901
5885
|
(assistantRepeated || !latestText || !assistantChanged)
|
|
4902
|
-
|
|
5886
|
+
// A child-wake pass re-examines an assistant turn the parent already
|
|
5887
|
+
// produced and was already charged for: the parent ran nothing in
|
|
5888
|
+
// between. Charging the stall gates again would pause a healthy goal
|
|
5889
|
+
// after one talk-only turn plus one deferral.
|
|
5890
|
+
if (lowOutputLooksStalled && !childWakeEvent) {
|
|
4903
5891
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
4904
5892
|
if (
|
|
4905
5893
|
activeGoalAfterMessages.noProgressTurns >=
|
|
@@ -4923,6 +5911,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4923
5911
|
`Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
|
|
4924
5912
|
)
|
|
4925
5913
|
await persist(sessionID)
|
|
5914
|
+
announceLifecycle(sessionID, "Goal paused — no progress threshold reached.", {
|
|
5915
|
+
goal: activeGoalAfterMessages,
|
|
5916
|
+
transition: "no-progress-paused",
|
|
5917
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5918
|
+
expectedState: "paused",
|
|
5919
|
+
expectedStopReason: "no progress",
|
|
5920
|
+
})
|
|
4926
5921
|
return
|
|
4927
5922
|
}
|
|
4928
5923
|
|
|
@@ -4932,7 +5927,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4932
5927
|
"warning",
|
|
4933
5928
|
`Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
|
|
4934
5929
|
)
|
|
4935
|
-
} else if (
|
|
5930
|
+
} else if (
|
|
5931
|
+
// A wake pass observes the same assistant turn the deferring pass
|
|
5932
|
+
// already scored, so it must neither charge nor clear the counter.
|
|
5933
|
+
// Resetting here would let an alternating defer/wake cycle keep a
|
|
5934
|
+
// genuinely stalled loop running indefinitely.
|
|
5935
|
+
!childWakeEvent &&
|
|
5936
|
+
(latestOutputTokens !== null || assistantChanged || !latestAssistant)
|
|
5937
|
+
) {
|
|
4936
5938
|
activeGoalAfterMessages.noProgressTurns = 0
|
|
4937
5939
|
}
|
|
4938
5940
|
|
|
@@ -4953,7 +5955,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4953
5955
|
!activationBoundary &&
|
|
4954
5956
|
Boolean(latestAssistant) &&
|
|
4955
5957
|
!latestHasToolCall
|
|
4956
|
-
if (noToolCallContinuation && !lowOutputLooksStalled) {
|
|
5958
|
+
if (noToolCallContinuation && !lowOutputLooksStalled && !childWakeEvent) {
|
|
4957
5959
|
activeGoalAfterMessages.noToolCallTurns += 1
|
|
4958
5960
|
if (
|
|
4959
5961
|
activeGoalAfterMessages.noToolCallTurns >=
|
|
@@ -4968,6 +5970,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4968
5970
|
`Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
|
|
4969
5971
|
)
|
|
4970
5972
|
await persist(sessionID)
|
|
5973
|
+
announceLifecycle(sessionID, "Goal paused — no-tool-call threshold reached.", {
|
|
5974
|
+
goal: activeGoalAfterMessages,
|
|
5975
|
+
transition: "no-tool-calls-paused",
|
|
5976
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5977
|
+
expectedState: "paused",
|
|
5978
|
+
expectedStopReason: "no tool calls",
|
|
5979
|
+
})
|
|
4971
5980
|
return
|
|
4972
5981
|
}
|
|
4973
5982
|
|
|
@@ -5017,6 +6026,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5017
6026
|
// the hard-limit path which also persists before its promptAsync call.
|
|
5018
6027
|
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
5019
6028
|
await persist(sessionID)
|
|
6029
|
+
announceLifecycle(sessionID, "Goal paused — budget threshold reached; final handoff requested.", {
|
|
6030
|
+
goal: activeGoalBeforePrompt,
|
|
6031
|
+
transition: "budget-wrapup-paused",
|
|
6032
|
+
reason: activeGoalBeforePrompt.stopReason,
|
|
6033
|
+
expectedState: "paused",
|
|
6034
|
+
expectedStopReason: "budget wrap-up requested",
|
|
6035
|
+
})
|
|
5020
6036
|
}
|
|
5021
6037
|
|
|
5022
6038
|
activeGoalBeforePrompt.turnCount += 1
|
|
@@ -5057,6 +6073,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5057
6073
|
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
5058
6074
|
)
|
|
5059
6075
|
await persist(sessionID)
|
|
6076
|
+
announceLifecycle(sessionID, "Goal paused — repeated completion/blocker format failures.", {
|
|
6077
|
+
goal: activeGoalBeforePrompt,
|
|
6078
|
+
transition: "format-failures-paused",
|
|
6079
|
+
reason: activeGoalBeforePrompt.stopReason,
|
|
6080
|
+
expectedState: "paused",
|
|
6081
|
+
expectedStopReason: "format validation failures",
|
|
6082
|
+
})
|
|
5060
6083
|
return
|
|
5061
6084
|
}
|
|
5062
6085
|
}
|
|
@@ -5081,6 +6104,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5081
6104
|
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
5082
6105
|
}
|
|
5083
6106
|
|
|
6107
|
+
let promptFailurePausedGoal = null
|
|
5084
6108
|
if (response.error) {
|
|
5085
6109
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
5086
6110
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
@@ -5096,6 +6120,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5096
6120
|
activeGoalAfterPrompt.stopped = true
|
|
5097
6121
|
activeGoalAfterPrompt.stopReason = "auto-continue failures"
|
|
5098
6122
|
activeGoalAfterPrompt.lastStatus = `${message}; paused after ${activeGoalAfterPrompt.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
6123
|
+
promptFailurePausedGoal = activeGoalAfterPrompt
|
|
5099
6124
|
}
|
|
5100
6125
|
}
|
|
5101
6126
|
await logPluginError(client, message, response.error)
|
|
@@ -5119,6 +6144,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5119
6144
|
}
|
|
5120
6145
|
}
|
|
5121
6146
|
await persist(sessionID)
|
|
6147
|
+
if (promptFailurePausedGoal) {
|
|
6148
|
+
announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
|
|
6149
|
+
goal: promptFailurePausedGoal,
|
|
6150
|
+
transition: "prompt-failures-paused",
|
|
6151
|
+
reason: promptFailurePausedGoal.stopReason,
|
|
6152
|
+
expectedState: "paused",
|
|
6153
|
+
expectedStopReason: "auto-continue failures",
|
|
6154
|
+
})
|
|
6155
|
+
}
|
|
5122
6156
|
} catch (error) {
|
|
5123
6157
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
5124
6158
|
if (activeGoalAfterError) {
|
|
@@ -5139,6 +6173,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
5139
6173
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
5140
6174
|
}
|
|
5141
6175
|
await persist(sessionID)
|
|
6176
|
+
if (activeGoalAfterError.stopped && activeGoalAfterError.stopReason === "auto-continue failures") {
|
|
6177
|
+
announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
|
|
6178
|
+
goal: activeGoalAfterError,
|
|
6179
|
+
transition: "prompt-failures-paused",
|
|
6180
|
+
reason: activeGoalAfterError.stopReason,
|
|
6181
|
+
expectedState: "paused",
|
|
6182
|
+
expectedStopReason: "auto-continue failures",
|
|
6183
|
+
})
|
|
6184
|
+
}
|
|
5142
6185
|
}
|
|
5143
6186
|
await logPluginError(client, "Auto-continue failed", error)
|
|
5144
6187
|
} finally {
|
|
@@ -5357,6 +6400,7 @@ export const testInternals = {
|
|
|
5357
6400
|
ledgerPathFor,
|
|
5358
6401
|
setLedgerSink,
|
|
5359
6402
|
defaultAuditMessenger,
|
|
6403
|
+
defaultLifecycleMessenger,
|
|
5360
6404
|
buildAuditPrompt,
|
|
5361
6405
|
parseAuditVerdict,
|
|
5362
6406
|
createChildSessionAuditor,
|
|
@@ -5375,6 +6419,7 @@ export const testInternals = {
|
|
|
5375
6419
|
extractCompletionEvidence,
|
|
5376
6420
|
findLatestAssistantMessage,
|
|
5377
6421
|
formatArgumentErrors,
|
|
6422
|
+
goalDisplayState,
|
|
5378
6423
|
formatStatus,
|
|
5379
6424
|
getSessionID,
|
|
5380
6425
|
goalIsBlocked,
|