opencode-goal-plugin 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +31 -8
- package/docs/compatibility.md +29 -1
- package/index.d.ts +17 -0
- package/package.json +1 -1
- package/scripts/verify.mjs +9 -1
- package/src/goal-plugin.js +1263 -179
- package/src/persistence-lease.js +594 -59
package/src/goal-plugin.js
CHANGED
|
@@ -19,7 +19,10 @@ import { createOpenCodeSessionApi } from "./opencode-session-api.js"
|
|
|
19
19
|
import { applyNativeGoalConfig } from "./native-agent-config.js"
|
|
20
20
|
import { serializeCompletionClaim } from "./completion-claim.js"
|
|
21
21
|
import { goalToolFailure, goalToolSuccess, serializeGoalToolResult } from "./goal-tool-result.js"
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
acquirePersistenceLease,
|
|
24
|
+
isPersistenceLeaseContendedError,
|
|
25
|
+
} from "./persistence-lease.js"
|
|
23
26
|
|
|
24
27
|
const STATE_FILE_VERSION = 1
|
|
25
28
|
// Default state now follows the project: <cwd>/.opencode/goals/state.json.
|
|
@@ -56,6 +59,11 @@ const DEFAULT_LEDGER_RETENTION_FILES = 3
|
|
|
56
59
|
const MAX_LEDGER_LINE_BYTES = 16 * 1024
|
|
57
60
|
const MIGRATION_LEASE_RETRIES = 200
|
|
58
61
|
const MIGRATION_LEASE_DELAY_MS = 25
|
|
62
|
+
const PASSIVE_SESSION_RETRY_MS = 250
|
|
63
|
+
const SESSION_OWNED_ELSEWHERE = "session_owned_elsewhere"
|
|
64
|
+
const ACTIVE_PERSISTENCE_DISABLED = Object.freeze({ kind: "active", persistence: "disabled" })
|
|
65
|
+
const ACTIVE_PERSISTENCE_OWNED = Object.freeze({ kind: "active", persistence: "owned" })
|
|
66
|
+
const PLUGIN_DISPOSED = Object.freeze({ kind: "disposed" })
|
|
59
67
|
|
|
60
68
|
const DEFAULT_OPTIONS = {
|
|
61
69
|
maxTurns: 10,
|
|
@@ -87,6 +95,7 @@ function createRuntimeState() {
|
|
|
87
95
|
sessionArchive: new Map(),
|
|
88
96
|
sessionOrdered: new Set(),
|
|
89
97
|
lastGoalResults: new Map(),
|
|
98
|
+
sessionMutationVersions: new Map(),
|
|
90
99
|
seenTokens: new Map(),
|
|
91
100
|
seenUsage: new Map(),
|
|
92
101
|
seenOutputTokens: new Map(),
|
|
@@ -104,6 +113,7 @@ function createRuntimeState() {
|
|
|
104
113
|
ledgerSink: null,
|
|
105
114
|
sessionPersistence: new Map(),
|
|
106
115
|
sessionLoadPromises: new Map(),
|
|
116
|
+
passiveSessions: new Map(),
|
|
107
117
|
disposed: false,
|
|
108
118
|
}
|
|
109
119
|
}
|
|
@@ -115,6 +125,19 @@ function currentRuntime() {
|
|
|
115
125
|
return runtimeStorage.getStore() || lastRuntime
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
function runtimeSessionDiagnostics(sessionID) {
|
|
129
|
+
const runtime = currentRuntime()
|
|
130
|
+
return Object.freeze({
|
|
131
|
+
disposed: runtime.disposed,
|
|
132
|
+
loadInFlight: runtime.sessionLoadPromises.has(sessionID),
|
|
133
|
+
persistenceOwned: runtime.sessionPersistence.has(sessionID),
|
|
134
|
+
passive: runtime.passiveSessions.has(sessionID),
|
|
135
|
+
suppressedAssistantCount: [...runtime.suppressedCommandAssistants.values()]
|
|
136
|
+
.filter((ownerSessionID) => ownerSessionID === sessionID)
|
|
137
|
+
.length,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
118
141
|
// Route the existing domain helpers to the plugin instance associated with the
|
|
119
142
|
// current async hook/tool execution. OpenCode caches imported plugin modules but
|
|
120
143
|
// initializes their factories per workspace, so module-global Maps would let a
|
|
@@ -143,6 +166,7 @@ const sessionArchive = runtimeCollection("sessionArchive")
|
|
|
143
166
|
const sessionOrdered = runtimeCollection("sessionOrdered")
|
|
144
167
|
const MAX_ARCHIVED_PER_SESSION = 10
|
|
145
168
|
const lastGoalResults = runtimeCollection("lastGoalResults")
|
|
169
|
+
const sessionMutationVersions = runtimeCollection("sessionMutationVersions")
|
|
146
170
|
const seenTokens = runtimeCollection("seenTokens")
|
|
147
171
|
const seenUsage = runtimeCollection("seenUsage")
|
|
148
172
|
const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
@@ -343,6 +367,24 @@ function normalizeExecutionContext(value) {
|
|
|
343
367
|
}
|
|
344
368
|
}
|
|
345
369
|
|
|
370
|
+
function rememberSessionExecutionContext(sessionID, value, { replace = false } = {}) {
|
|
371
|
+
if (!sessionID) return null
|
|
372
|
+
const observed = normalizeExecutionContext(value)
|
|
373
|
+
if (!observed) return null
|
|
374
|
+
const runtime = currentRuntime()
|
|
375
|
+
if (replace) {
|
|
376
|
+
runtime.sessionExecutionContexts.set(sessionID, observed)
|
|
377
|
+
return observed
|
|
378
|
+
}
|
|
379
|
+
const previous = normalizeExecutionContext(runtime.sessionExecutionContexts.get(sessionID)) || {}
|
|
380
|
+
const merged = {
|
|
381
|
+
...previous,
|
|
382
|
+
...observed,
|
|
383
|
+
}
|
|
384
|
+
runtime.sessionExecutionContexts.set(sessionID, merged)
|
|
385
|
+
return merged
|
|
386
|
+
}
|
|
387
|
+
|
|
346
388
|
function continuationContextInput(goal) {
|
|
347
389
|
const context = normalizeExecutionContext(goal?.executionContext)
|
|
348
390
|
return context ? { ...context } : {}
|
|
@@ -450,6 +492,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
|
450
492
|
options: goal.options,
|
|
451
493
|
stopped: goal.stopped,
|
|
452
494
|
stopReason: goal.stopReason,
|
|
495
|
+
blockedReason: goal.blockedReason,
|
|
453
496
|
ordered: sessionOrdered.has(goal.sessionID),
|
|
454
497
|
},
|
|
455
498
|
type,
|
|
@@ -464,6 +507,7 @@ function emitLedgerEvent(goal, type, detail, timestamp) {
|
|
|
464
507
|
function pushHistory(goal, type, detail, timestamp = Date.now()) {
|
|
465
508
|
const entry = makeHistoryEntry(type, detail, timestamp)
|
|
466
509
|
goal.history = [...(goal.history || []), entry].slice(-MAX_HISTORY_ENTRIES)
|
|
510
|
+
markSessionMutation(goal.sessionID)
|
|
467
511
|
return emitLedgerEvent(goal, entry.type, entry.detail, entry.timestamp)
|
|
468
512
|
}
|
|
469
513
|
|
|
@@ -598,6 +642,7 @@ function reconstructGoalsFromLedger(entries) {
|
|
|
598
642
|
const condition = [...events].reverse().find((event) => typeof event.condition === "string" && event.condition.trim())?.condition?.trim()
|
|
599
643
|
if (!condition) continue
|
|
600
644
|
const snapshot = [...events].reverse().find((event) => isPlainObject(event.snapshot))?.snapshot || {}
|
|
645
|
+
const latestBlocked = [...events].reverse().find((event) => event.type === "blocked")
|
|
601
646
|
|
|
602
647
|
const history = events
|
|
603
648
|
.map((event) =>
|
|
@@ -619,6 +664,12 @@ function reconstructGoalsFromLedger(entries) {
|
|
|
619
664
|
options: isPlainObject(snapshot.options) ? snapshot.options : {},
|
|
620
665
|
stopped: snapshot.stopped === true,
|
|
621
666
|
stopReason: typeof snapshot.stopReason === "string" ? snapshot.stopReason : "",
|
|
667
|
+
blockedReason:
|
|
668
|
+
typeof snapshot.blockedReason === "string"
|
|
669
|
+
? snapshot.blockedReason
|
|
670
|
+
: snapshot.stopReason === "blocked" && typeof latestBlocked?.detail === "string"
|
|
671
|
+
? latestBlocked.detail
|
|
672
|
+
: "",
|
|
622
673
|
ordered: snapshot.ordered === true || events.some((event) => /ordered goal/i.test(String(event.detail || ""))),
|
|
623
674
|
startedAt: normalizeTimestamp(events[0]?.ts),
|
|
624
675
|
history,
|
|
@@ -635,9 +686,19 @@ function recordCheckpoint(goal, text, timestamp = Date.now()) {
|
|
|
635
686
|
const checkpoint = { summary, timestamp }
|
|
636
687
|
goal.lastCheckpoint = checkpoint
|
|
637
688
|
goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
|
|
689
|
+
markSessionMutation(goal.sessionID)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function goalDisplayState(goal) {
|
|
693
|
+
if (!goal?.stopped) return "active"
|
|
694
|
+
return goal.stopReason === "blocked" ? "blocked" : "paused"
|
|
638
695
|
}
|
|
639
696
|
|
|
640
|
-
function formatStatus(
|
|
697
|
+
function formatStatus(
|
|
698
|
+
goal,
|
|
699
|
+
commandName = "goal",
|
|
700
|
+
completionAuditLabel = "evidence gate only (independent verifier off)",
|
|
701
|
+
) {
|
|
641
702
|
const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
|
|
642
703
|
const lastProgress =
|
|
643
704
|
goal.lastProgressAt > 0
|
|
@@ -648,6 +709,8 @@ function formatStatus(goal, commandName = "goal") {
|
|
|
648
709
|
: "none yet"
|
|
649
710
|
const lines = [
|
|
650
711
|
`Active goal: ${goal.condition}`,
|
|
712
|
+
`State: ${goalDisplayState(goal)}`,
|
|
713
|
+
`Completion audit: ${completionAuditLabel}`,
|
|
651
714
|
]
|
|
652
715
|
if (goal.successCriteria) lines.push(`Success criteria: ${goal.successCriteria}`)
|
|
653
716
|
if (goal.constraints) lines.push(`Constraints: ${goal.constraints}`)
|
|
@@ -732,8 +795,16 @@ function sessionGoalMap(sessionID) {
|
|
|
732
795
|
return map
|
|
733
796
|
}
|
|
734
797
|
|
|
798
|
+
function markSessionMutation(sessionID) {
|
|
799
|
+
if (!sessionID) return 0
|
|
800
|
+
const next = (sessionMutationVersions.get(sessionID) || 0) + 1
|
|
801
|
+
sessionMutationVersions.set(sessionID, next)
|
|
802
|
+
return next
|
|
803
|
+
}
|
|
804
|
+
|
|
735
805
|
function registerSessionGoal(goal) {
|
|
736
806
|
sessionGoalMap(goal.sessionID).set(goal.goalId, goal)
|
|
807
|
+
markSessionMutation(goal.sessionID)
|
|
737
808
|
}
|
|
738
809
|
|
|
739
810
|
function listSessionGoals(sessionID) {
|
|
@@ -756,12 +827,13 @@ function setBoundedMessageValue(map, messageID, value) {
|
|
|
756
827
|
function removeSessionGoal(sessionID, goalId) {
|
|
757
828
|
const map = sessionGoals.get(sessionID)
|
|
758
829
|
if (!map) return
|
|
759
|
-
map.delete(goalId)
|
|
830
|
+
if (map.delete(goalId)) markSessionMutation(sessionID)
|
|
760
831
|
if (map.size === 0) sessionGoals.delete(sessionID)
|
|
761
832
|
}
|
|
762
833
|
|
|
763
834
|
function focusGoal(sessionID, goal) {
|
|
764
835
|
goalStates.set(sessionID, goal)
|
|
836
|
+
markSessionMutation(sessionID)
|
|
765
837
|
}
|
|
766
838
|
|
|
767
839
|
function pauseGoalClock(goal, timestamp = Date.now()) {
|
|
@@ -818,6 +890,10 @@ function cleanupGoal(sessionID) {
|
|
|
818
890
|
}
|
|
819
891
|
goalStates.delete(sessionID)
|
|
820
892
|
activeContinues.delete(sessionID)
|
|
893
|
+
// Increment even when no focused goal remains. A concurrent clear of a
|
|
894
|
+
// provisional completion is otherwise indistinguishable from unrelated
|
|
895
|
+
// global result-retention pruning while its terminal write is in flight.
|
|
896
|
+
markSessionMutation(sessionID)
|
|
821
897
|
}
|
|
822
898
|
|
|
823
899
|
function clearRuntimeState() {
|
|
@@ -828,6 +904,7 @@ function clearRuntimeState() {
|
|
|
828
904
|
sessionArchive.clear()
|
|
829
905
|
sessionOrdered.clear()
|
|
830
906
|
lastGoalResults.clear()
|
|
907
|
+
sessionMutationVersions.clear()
|
|
831
908
|
seenTokens.clear()
|
|
832
909
|
seenUsage.clear()
|
|
833
910
|
seenOutputTokens.clear()
|
|
@@ -841,9 +918,13 @@ function clearRuntimeState() {
|
|
|
841
918
|
runtime.activeCommandTurns.clear()
|
|
842
919
|
runtime.ownedPluginMessages.clear()
|
|
843
920
|
runtime.suppressedCommandAssistants.clear()
|
|
921
|
+
runtime.passiveSessions.clear()
|
|
844
922
|
}
|
|
845
923
|
|
|
846
|
-
function clearSessionRuntimeState(
|
|
924
|
+
function clearSessionRuntimeState(
|
|
925
|
+
sessionID,
|
|
926
|
+
{ preserveCommandSecurity = false, preserveExecutionContext = false } = {},
|
|
927
|
+
) {
|
|
847
928
|
const runtime = currentRuntime()
|
|
848
929
|
for (const goal of sessionGoals.get(sessionID)?.values() || []) {
|
|
849
930
|
for (const messageID of goal.messageIDs || []) {
|
|
@@ -862,14 +943,18 @@ function clearSessionRuntimeState(sessionID) {
|
|
|
862
943
|
runtime.continuationControllers.delete(sessionID)
|
|
863
944
|
runtime.promptInFlightSessions.delete(sessionID)
|
|
864
945
|
runtime.sessionStatuses.delete(sessionID)
|
|
865
|
-
runtime.sessionExecutionContexts.delete(sessionID)
|
|
866
|
-
runtime.
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
946
|
+
if (!preserveExecutionContext) runtime.sessionExecutionContexts.delete(sessionID)
|
|
947
|
+
runtime.passiveSessions.delete(sessionID)
|
|
948
|
+
markSessionMutation(sessionID)
|
|
949
|
+
if (!preserveCommandSecurity) {
|
|
950
|
+
runtime.pendingCommandTurns.delete(sessionID)
|
|
951
|
+
runtime.activeCommandTurns.delete(sessionID)
|
|
952
|
+
for (const [messageID, owner] of runtime.ownedPluginMessages) {
|
|
953
|
+
if (owner?.sessionID === sessionID) runtime.ownedPluginMessages.delete(messageID)
|
|
954
|
+
}
|
|
955
|
+
for (const [messageID, ownerSessionID] of runtime.suppressedCommandAssistants) {
|
|
956
|
+
if (ownerSessionID === sessionID) runtime.suppressedCommandAssistants.delete(messageID)
|
|
957
|
+
}
|
|
873
958
|
}
|
|
874
959
|
}
|
|
875
960
|
|
|
@@ -919,16 +1004,60 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
|
|
|
919
1004
|
lastGoalResults.delete(sessionID)
|
|
920
1005
|
lastGoalResults.set(sessionID, result)
|
|
921
1006
|
// Keep a per-session archive so completed goals stay readable via /goal list.
|
|
922
|
-
|
|
1007
|
+
const archivedResult = { ...result }
|
|
1008
|
+
archiveSessionResult(sessionID, archivedResult)
|
|
923
1009
|
pruneGoalResults(goal.options)
|
|
1010
|
+
markSessionMutation(sessionID)
|
|
1011
|
+
return { lastResult: result, archivedResult }
|
|
924
1012
|
}
|
|
925
1013
|
|
|
926
|
-
function
|
|
927
|
-
|
|
1014
|
+
function captureFocusedGoalSnapshot(sessionID) {
|
|
1015
|
+
const goal = goalStates.get(sessionID) || null
|
|
1016
|
+
return {
|
|
1017
|
+
goal,
|
|
1018
|
+
serialized: goal ? JSON.stringify(serializeGoal(goal)) : "",
|
|
1019
|
+
mutationVersion: sessionMutationVersions.get(sessionID) || 0,
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function focusedGoalSnapshotIsCurrent(sessionID, snapshot) {
|
|
1024
|
+
const current = goalStates.get(sessionID) || null
|
|
1025
|
+
if (current !== snapshot?.goal) return false
|
|
1026
|
+
if ((sessionMutationVersions.get(sessionID) || 0) !== snapshot?.mutationVersion) return false
|
|
1027
|
+
return !current || JSON.stringify(serializeGoal(current)) === snapshot.serialized
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function restoreAfterTerminalPersistenceFailure(
|
|
1031
|
+
sessionID,
|
|
1032
|
+
goal,
|
|
1033
|
+
{ ordered = false, expectedCurrentSnapshot, expectedResult } = {},
|
|
1034
|
+
) {
|
|
1035
|
+
// A terminal write can yield while another command replaces, edits, pauses,
|
|
1036
|
+
// resumes, clears, or advances the session. Never roll the old goal back over
|
|
1037
|
+
// that newer state. The per-session mutation version catches a concurrent
|
|
1038
|
+
// clear even when both the expected and current focused goal are null, while
|
|
1039
|
+
// remaining unaffected by result-retention pruning in a different session.
|
|
1040
|
+
const expectedLastResult = expectedResult?.lastResult || expectedResult
|
|
1041
|
+
const expectedArchivedResult = expectedResult?.archivedResult
|
|
1042
|
+
const canRestore =
|
|
1043
|
+
!expectedCurrentSnapshot ||
|
|
1044
|
+
focusedGoalSnapshotIsCurrent(sessionID, expectedCurrentSnapshot)
|
|
1045
|
+
|
|
1046
|
+
// Remove only this failed provisional completion record. A newer concurrent
|
|
1047
|
+
// result/archive entry belongs to the newer operation and must survive.
|
|
1048
|
+
if (expectedLastResult && lastGoalResults.get(sessionID) === expectedLastResult) {
|
|
1049
|
+
lastGoalResults.delete(sessionID)
|
|
1050
|
+
}
|
|
928
1051
|
const archived = sessionArchive.get(sessionID) || []
|
|
929
|
-
if (
|
|
1052
|
+
if (expectedArchivedResult) {
|
|
1053
|
+
const retained = archived.filter((entry) => entry !== expectedArchivedResult)
|
|
1054
|
+
if (retained.length) sessionArchive.set(sessionID, retained)
|
|
1055
|
+
else sessionArchive.delete(sessionID)
|
|
1056
|
+
} else if (archived.length) {
|
|
930
1057
|
sessionArchive.set(sessionID, archived.slice(0, -1))
|
|
931
1058
|
}
|
|
1059
|
+
|
|
1060
|
+
if (!canRestore) return false
|
|
932
1061
|
const prematurelyPromoted = goalStates.get(sessionID)
|
|
933
1062
|
if (prematurelyPromoted && prematurelyPromoted.goalId !== goal.goalId) {
|
|
934
1063
|
prematurelyPromoted.stopped = true
|
|
@@ -943,6 +1072,7 @@ function restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered = fal
|
|
|
943
1072
|
goal.lastStatus = "Terminal state could not be persisted. Goal kept paused; fix storage and retry."
|
|
944
1073
|
registerSessionGoal(goal)
|
|
945
1074
|
focusGoal(sessionID, goal)
|
|
1075
|
+
return true
|
|
946
1076
|
}
|
|
947
1077
|
|
|
948
1078
|
function resetGoalBudget(goal) {
|
|
@@ -1441,7 +1571,12 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
|
|
|
1441
1571
|
)
|
|
1442
1572
|
}
|
|
1443
1573
|
|
|
1444
|
-
if (onlySessionID)
|
|
1574
|
+
if (onlySessionID) {
|
|
1575
|
+
clearSessionRuntimeState(onlySessionID, {
|
|
1576
|
+
preserveCommandSecurity: true,
|
|
1577
|
+
preserveExecutionContext: true,
|
|
1578
|
+
})
|
|
1579
|
+
}
|
|
1445
1580
|
else clearRuntimeState()
|
|
1446
1581
|
|
|
1447
1582
|
const focusBySession = new Map()
|
|
@@ -1491,15 +1626,15 @@ async function applyParsedStateFile(raw, client, onlySessionID = null) {
|
|
|
1491
1626
|
}
|
|
1492
1627
|
|
|
1493
1628
|
// After applyParsedStateFile loads goals into goalStates, check the ledger for
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1629
|
+
// state transitions that landed after the snapshot. Completed/cleared goals are
|
|
1630
|
+
// removed so they cannot be re-driven, while a newer blocked event is overlaid
|
|
1631
|
+
// so its state and concrete reason survive a failed snapshot write.
|
|
1497
1632
|
async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1498
1633
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1499
1634
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1500
1635
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1501
1636
|
})
|
|
1502
|
-
if (!entries.length) return
|
|
1637
|
+
if (!entries.length) return { removed: 0, blocked: 0 }
|
|
1503
1638
|
|
|
1504
1639
|
const terminalGoals = new Set()
|
|
1505
1640
|
for (const entry of entries) {
|
|
@@ -1512,16 +1647,69 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
|
|
|
1512
1647
|
terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
|
|
1513
1648
|
}
|
|
1514
1649
|
}
|
|
1515
|
-
if (!terminalGoals.size) return
|
|
1516
|
-
|
|
1517
1650
|
let removed = 0
|
|
1651
|
+
let blocked = 0
|
|
1518
1652
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1519
1653
|
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1520
1654
|
for (const goal of [...goals.values()]) {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1655
|
+
const key = `${sessionID}\0${goal.goalId}`
|
|
1656
|
+
if (terminalGoals.has(key)) {
|
|
1657
|
+
removeSessionGoal(sessionID, goal.goalId)
|
|
1658
|
+
if (goalStates.get(sessionID)?.goalId === goal.goalId) goalStates.delete(sessionID)
|
|
1659
|
+
removed += 1
|
|
1660
|
+
continue
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const persistedHistory = (goal.history || []).filter((event) => event.type !== "recovered")
|
|
1664
|
+
const latestPersistedTimestamp = persistedHistory.reduce(
|
|
1665
|
+
(latest, event) => Math.max(latest, normalizeTimestamp(event.timestamp, 0)),
|
|
1666
|
+
0,
|
|
1667
|
+
)
|
|
1668
|
+
let latestLedgerState = null
|
|
1669
|
+
let latestLedgerTimestamp = -1
|
|
1670
|
+
for (const entry of entries) {
|
|
1671
|
+
if (entry.sessionID !== sessionID || entry.goalId !== goal.goalId || entry.type === "recovered") continue
|
|
1672
|
+
const timestamp = normalizeTimestamp(entry.ts, 0)
|
|
1673
|
+
if (timestamp < latestPersistedTimestamp) continue
|
|
1674
|
+
const detail = summarizeText(entry.detail, 400)
|
|
1675
|
+
const alreadyApplied = persistedHistory.some(
|
|
1676
|
+
(event) =>
|
|
1677
|
+
event.type === entry.type &&
|
|
1678
|
+
normalizeTimestamp(event.timestamp, 0) === timestamp &&
|
|
1679
|
+
event.detail === detail,
|
|
1680
|
+
)
|
|
1681
|
+
if (timestamp >= latestLedgerTimestamp) {
|
|
1682
|
+
latestLedgerState = { entry, alreadyApplied }
|
|
1683
|
+
latestLedgerTimestamp = timestamp
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
if (
|
|
1687
|
+
latestLedgerState?.alreadyApplied ||
|
|
1688
|
+
latestLedgerState?.entry?.type !== "blocked" ||
|
|
1689
|
+
latestLedgerState.entry.snapshot?.stopped !== true ||
|
|
1690
|
+
latestLedgerState.entry.snapshot?.stopReason !== "blocked"
|
|
1691
|
+
) continue
|
|
1692
|
+
|
|
1693
|
+
const reason = summarizeText(
|
|
1694
|
+
latestLedgerState.entry.snapshot?.blockedReason || latestLedgerState.entry.detail,
|
|
1695
|
+
MAX_GOAL_BLOCKER_LENGTH,
|
|
1696
|
+
)
|
|
1697
|
+
if (!reason) continue
|
|
1698
|
+
goal.stopped = true
|
|
1699
|
+
goal.stopReason = "blocked"
|
|
1700
|
+
goal.blockedReason = reason
|
|
1701
|
+
goal.lastStatus = "Recovered blocked goal state from the lifecycle ledger after the saved snapshot lagged behind."
|
|
1702
|
+
goal.continuationClaim = null
|
|
1703
|
+
goal.history = [
|
|
1704
|
+
...(goal.history || []),
|
|
1705
|
+
makeHistoryEntry(
|
|
1706
|
+
"blocked",
|
|
1707
|
+
reason,
|
|
1708
|
+
normalizeTimestamp(latestLedgerState.entry.ts),
|
|
1709
|
+
),
|
|
1710
|
+
].slice(-MAX_HISTORY_ENTRIES)
|
|
1711
|
+
pauseGoalClock(goal)
|
|
1712
|
+
blocked += 1
|
|
1525
1713
|
}
|
|
1526
1714
|
if (!goalStates.has(sessionID) && sessionOrdered.has(sessionID) && goals.size > 0) {
|
|
1527
1715
|
promoteNextOrderedGoal(sessionID)
|
|
@@ -1533,6 +1721,13 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySe
|
|
|
1533
1721
|
`Ledger cross-check: removed ${removed} goal(s) whose terminal state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`,
|
|
1534
1722
|
)
|
|
1535
1723
|
}
|
|
1724
|
+
if (blocked > 0) {
|
|
1725
|
+
await logPluginError(
|
|
1726
|
+
client,
|
|
1727
|
+
`Ledger cross-check: restored ${blocked} blocked goal(s) whose blocked state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`,
|
|
1728
|
+
)
|
|
1729
|
+
}
|
|
1730
|
+
return { removed, blocked }
|
|
1536
1731
|
}
|
|
1537
1732
|
|
|
1538
1733
|
async function pathExists(path) {
|
|
@@ -1552,7 +1747,7 @@ async function acquireMigrationLease(stateFilePath, migrationMarkerPath) {
|
|
|
1552
1747
|
try {
|
|
1553
1748
|
return await acquirePersistenceLease(stateFilePath)
|
|
1554
1749
|
} catch (error) {
|
|
1555
|
-
if (!
|
|
1750
|
+
if (!isPersistenceLeaseContendedError(error)) throw error
|
|
1556
1751
|
lastError = error
|
|
1557
1752
|
await new Promise((resolve) => setTimeout(resolve, MIGRATION_LEASE_DELAY_MS))
|
|
1558
1753
|
}
|
|
@@ -1682,6 +1877,7 @@ async function migrateLegacyState(persistenceOptions, client) {
|
|
|
1682
1877
|
)
|
|
1683
1878
|
if (!migrationLease) return
|
|
1684
1879
|
try {
|
|
1880
|
+
if (currentRuntime().disposed) return
|
|
1685
1881
|
if (await pathExists(persistenceOptions.migrationMarkerPath)) return
|
|
1686
1882
|
|
|
1687
1883
|
const state = await readPersistedStateFile(candidate.stateFilePath, client)
|
|
@@ -1735,16 +1931,30 @@ async function migrateLegacyState(persistenceOptions, client) {
|
|
|
1735
1931
|
}
|
|
1736
1932
|
|
|
1737
1933
|
// A fresh project has no aggregate or legacy state. Mark the namespace so a
|
|
1738
|
-
// later session does not repeatedly probe global fallback paths.
|
|
1739
|
-
|
|
1934
|
+
// later session does not repeatedly probe global fallback paths. Separate
|
|
1935
|
+
// session processes must still serialize this shared marker: POSIX rename
|
|
1936
|
+
// replaces an existing destination, while Windows can reject that race.
|
|
1937
|
+
if (currentRuntime().disposed) return
|
|
1938
|
+
const freshMigrationLease = await acquireMigrationLease(
|
|
1939
|
+
persistenceOptions.stateFilePath,
|
|
1940
|
+
persistenceOptions.migrationMarkerPath,
|
|
1941
|
+
)
|
|
1942
|
+
if (!freshMigrationLease) return
|
|
1943
|
+
try {
|
|
1944
|
+
if (currentRuntime().disposed) return
|
|
1945
|
+
if (await pathExists(persistenceOptions.migrationMarkerPath)) return
|
|
1946
|
+
await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
|
|
1947
|
+
} finally {
|
|
1948
|
+
await freshMigrationLease.release()
|
|
1949
|
+
}
|
|
1740
1950
|
}
|
|
1741
1951
|
|
|
1742
1952
|
async function loadPersistedSessionState(persistence, client, sessionID) {
|
|
1743
1953
|
const state = await readPersistedStateFile(persistence.stateFilePath, client)
|
|
1744
1954
|
if (state.status === "loaded") {
|
|
1745
1955
|
await applyParsedStateFile(state.raw, client, sessionID)
|
|
1746
|
-
await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1747
|
-
return "loaded"
|
|
1956
|
+
const reconciliation = await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1957
|
+
return reconciliation.blocked > 0 ? "reconciled-blocked" : "loaded"
|
|
1748
1958
|
}
|
|
1749
1959
|
const recovered = await reconstructFromLedger(persistence, client, sessionID)
|
|
1750
1960
|
if (state.status === "invalid" && recovered === "reconstructed") {
|
|
@@ -1777,7 +1987,12 @@ async function reconstructFromLedger(persistenceOptions, client, onlySessionID =
|
|
|
1777
1987
|
)
|
|
1778
1988
|
if (!reconstructed.length) return "missing"
|
|
1779
1989
|
|
|
1780
|
-
if (onlySessionID)
|
|
1990
|
+
if (onlySessionID) {
|
|
1991
|
+
clearSessionRuntimeState(onlySessionID, {
|
|
1992
|
+
preserveCommandSecurity: true,
|
|
1993
|
+
preserveExecutionContext: true,
|
|
1994
|
+
})
|
|
1995
|
+
}
|
|
1781
1996
|
else clearRuntimeState()
|
|
1782
1997
|
const focusCandidates = new Map()
|
|
1783
1998
|
for (const stub of reconstructed) {
|
|
@@ -1847,24 +2062,46 @@ async function persistState(persistence, client, sessionID) {
|
|
|
1847
2062
|
}
|
|
1848
2063
|
}
|
|
1849
2064
|
|
|
1850
|
-
|
|
2065
|
+
function dispatchAdvisoryHostCall(call, onFailure = () => {}) {
|
|
2066
|
+
try {
|
|
2067
|
+
// Host notices are diagnostic only. Start the SDK request immediately,
|
|
2068
|
+
// contain both synchronous and asynchronous failures, and never let a
|
|
2069
|
+
// stalled host promise retain a persistence lease or block goal controls.
|
|
2070
|
+
void Promise.resolve(call()).catch(onFailure)
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
onFailure(error)
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function logPluginMessage(client, level, message, error) {
|
|
2077
|
+
const fallback = () => {
|
|
2078
|
+
const logger = level === "warn" ? console.warn : console.error
|
|
2079
|
+
logger("[goal-plugin]", message, error || "")
|
|
2080
|
+
}
|
|
1851
2081
|
if (client?.app?.log) {
|
|
1852
|
-
|
|
1853
|
-
|
|
2082
|
+
return dispatchAdvisoryHostCall(
|
|
2083
|
+
() => client.app.log({
|
|
1854
2084
|
body: {
|
|
1855
2085
|
service: "opencode-goal-plugin",
|
|
1856
|
-
level
|
|
2086
|
+
level,
|
|
1857
2087
|
message,
|
|
1858
|
-
|
|
2088
|
+
...(error === undefined
|
|
2089
|
+
? {}
|
|
2090
|
+
: { extra: { error: error?.message || error?.name || String(error) } }),
|
|
1859
2091
|
},
|
|
1860
|
-
})
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
// Logging must never poison persistence or leak an acquired lease.
|
|
1864
|
-
}
|
|
2092
|
+
}),
|
|
2093
|
+
fallback,
|
|
2094
|
+
)
|
|
1865
2095
|
}
|
|
2096
|
+
fallback()
|
|
2097
|
+
}
|
|
1866
2098
|
|
|
1867
|
-
|
|
2099
|
+
async function logPluginError(client, message, error) {
|
|
2100
|
+
return logPluginMessage(client, "error", message, error)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
async function logPluginWarning(client, message) {
|
|
2104
|
+
return logPluginMessage(client, "warn", message)
|
|
1868
2105
|
}
|
|
1869
2106
|
|
|
1870
2107
|
function parseGoalArguments(args, defaults) {
|
|
@@ -2459,7 +2696,14 @@ function pluginMessageMatches(message, kind, correlationID) {
|
|
|
2459
2696
|
return Boolean(correlationID) && pluginMessageCorrelationID(message, kind) === correlationID
|
|
2460
2697
|
}
|
|
2461
2698
|
|
|
2462
|
-
function rememberOwnedPluginMessage(
|
|
2699
|
+
function rememberOwnedPluginMessage(
|
|
2700
|
+
message,
|
|
2701
|
+
sessionID,
|
|
2702
|
+
kind,
|
|
2703
|
+
correlationID,
|
|
2704
|
+
policy = "",
|
|
2705
|
+
passive = false,
|
|
2706
|
+
) {
|
|
2463
2707
|
const id = messageID(message)
|
|
2464
2708
|
if (!id) return
|
|
2465
2709
|
setBoundedMessageValue(currentRuntime().ownedPluginMessages, id, {
|
|
@@ -2467,9 +2711,34 @@ function rememberOwnedPluginMessage(message, sessionID, kind, correlationID, pol
|
|
|
2467
2711
|
kind,
|
|
2468
2712
|
correlationID,
|
|
2469
2713
|
...(policy ? { policy } : {}),
|
|
2714
|
+
...(passive ? { passive: true } : {}),
|
|
2470
2715
|
})
|
|
2471
2716
|
}
|
|
2472
2717
|
|
|
2718
|
+
function suppressControlCommandAssistant(message) {
|
|
2719
|
+
const currentMessageID = messageID(message)
|
|
2720
|
+
const currentSessionID = messageSessionID(message)
|
|
2721
|
+
if (!currentMessageID || !currentSessionID) return false
|
|
2722
|
+
const runtime = currentRuntime()
|
|
2723
|
+
const parentOwner = runtime.ownedPluginMessages.get(messageParentID(message))
|
|
2724
|
+
const isControlCommandAssistant =
|
|
2725
|
+
messageRole(message) === "assistant" &&
|
|
2726
|
+
parentOwner?.kind === "command" &&
|
|
2727
|
+
parentOwner?.policy === "control" &&
|
|
2728
|
+
parentOwner?.sessionID === currentSessionID
|
|
2729
|
+
if (!isControlCommandAssistant) return false
|
|
2730
|
+
// A control command may produce several assistant messages (for example, a
|
|
2731
|
+
// blocked tool-call step followed by a final report). Authenticate each
|
|
2732
|
+
// response through its owned parent user message and suppress it immediately
|
|
2733
|
+
// so later idle processing cannot treat it as goal progress or completion.
|
|
2734
|
+
setBoundedMessageValue(
|
|
2735
|
+
runtime.suppressedCommandAssistants,
|
|
2736
|
+
currentMessageID,
|
|
2737
|
+
currentSessionID,
|
|
2738
|
+
)
|
|
2739
|
+
return parentOwner?.passive === true ? "passive" : "control"
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2473
2742
|
function isOwnedPluginMessage(message, kind, ownedMessages = currentRuntime().ownedPluginMessages) {
|
|
2474
2743
|
const id = messageID(message)
|
|
2475
2744
|
const correlationID = pluginMessageCorrelationID(message, kind)
|
|
@@ -2522,17 +2791,27 @@ function isPluginGeneratedMessage(message, ownedMessages = currentRuntime().owne
|
|
|
2522
2791
|
)
|
|
2523
2792
|
}
|
|
2524
2793
|
|
|
2794
|
+
function pruneExpiredPendingCommandTurns(sessionID, now = Date.now()) {
|
|
2795
|
+
const runtime = currentRuntime()
|
|
2796
|
+
const pending = runtime.pendingCommandTurns.get(sessionID)
|
|
2797
|
+
if (pending) {
|
|
2798
|
+
for (const [id, turn] of pending) {
|
|
2799
|
+
if (now - turn.createdAt > COMMAND_TURN_TTL_MS) pending.delete(id)
|
|
2800
|
+
}
|
|
2801
|
+
if (pending.size === 0) runtime.pendingCommandTurns.delete(sessionID)
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2525
2806
|
function registerPendingCommandTurn(sessionID, output) {
|
|
2526
2807
|
const runtime = currentRuntime()
|
|
2527
2808
|
const now = Date.now()
|
|
2809
|
+
pruneExpiredPendingCommandTurns(sessionID, now)
|
|
2528
2810
|
let pending = runtime.pendingCommandTurns.get(sessionID)
|
|
2529
2811
|
if (!pending) {
|
|
2530
2812
|
pending = new Map()
|
|
2531
2813
|
runtime.pendingCommandTurns.set(sessionID, pending)
|
|
2532
2814
|
}
|
|
2533
|
-
for (const [id, turn] of pending) {
|
|
2534
|
-
if (now - turn.createdAt > COMMAND_TURN_TTL_MS) pending.delete(id)
|
|
2535
|
-
}
|
|
2536
2815
|
while (pending.size >= MAX_PENDING_COMMAND_TURNS_PER_SESSION) {
|
|
2537
2816
|
pending.delete(pending.keys().next().value)
|
|
2538
2817
|
}
|
|
@@ -2666,6 +2945,8 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2666
2945
|
}
|
|
2667
2946
|
|
|
2668
2947
|
const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
|
|
2948
|
+
const AGENT_COMPLETE_SUCCESS = "Goal marked complete and archived."
|
|
2949
|
+
const AGENT_BLOCK_SUCCESS = "Goal marked blocked."
|
|
2669
2950
|
|
|
2670
2951
|
// Programmatic equivalents of the /goal command, exposed to the agent as tools
|
|
2671
2952
|
// Each handler operates on a session id and mutates
|
|
@@ -2674,14 +2955,24 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
|
|
|
2674
2955
|
// result. Goal creation/replacement routes through the multi-goal registry
|
|
2675
2956
|
// (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
|
|
2676
2957
|
// path, so tool-created goals persist and are driven by the idle handler.
|
|
2677
|
-
function buildAgentToolHandlers({
|
|
2958
|
+
function buildAgentToolHandlers({
|
|
2959
|
+
defaultGoalOptions,
|
|
2960
|
+
persist,
|
|
2961
|
+
persistTerminalState = null,
|
|
2962
|
+
completionAuditor = null,
|
|
2963
|
+
completionAuditLabel = "evidence gate only (independent verifier off)",
|
|
2964
|
+
announceAudit = async () => {},
|
|
2965
|
+
auditMessagesEnabled = false,
|
|
2966
|
+
announceLifecycle = () => {},
|
|
2967
|
+
commandName = "goal",
|
|
2968
|
+
}) {
|
|
2678
2969
|
// Use persistTerminalState (which logs on failure) for terminal operations when
|
|
2679
2970
|
// available; fall back to plain persist for callers that don't wire it up (e.g.
|
|
2680
2971
|
// tests using buildAgentToolHandlers directly).
|
|
2681
2972
|
const persistFinal = persistTerminalState || persist
|
|
2682
2973
|
async function getGoal(sessionID) {
|
|
2683
2974
|
const goal = goalStates.get(sessionID)
|
|
2684
|
-
if (goal) return formatStatus(goal)
|
|
2975
|
+
if (goal) return formatStatus(goal, commandName, completionAuditLabel)
|
|
2685
2976
|
const lastResult = lastGoalResults.get(sessionID)
|
|
2686
2977
|
if (lastResult) return formatGoalResult(lastResult)
|
|
2687
2978
|
return "No active goal."
|
|
@@ -2751,12 +3042,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2751
3042
|
// Mirror the `/goal <condition>` replace path: discard the focused goal and
|
|
2752
3043
|
// its saved result, drop any ordered sequence, then register + focus the new
|
|
2753
3044
|
// goal so it persists and the idle handler drives it.
|
|
3045
|
+
const replacedGoal = goalStates.get(sessionID)
|
|
2754
3046
|
sessionOrdered.delete(sessionID)
|
|
2755
3047
|
cleanupGoal(sessionID)
|
|
2756
3048
|
lastGoalResults.delete(sessionID)
|
|
2757
3049
|
registerSessionGoal(goal)
|
|
2758
3050
|
focusGoal(sessionID, goal)
|
|
2759
3051
|
await persist(sessionID)
|
|
3052
|
+
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
|
|
3053
|
+
goal,
|
|
3054
|
+
transition: replacedGoal ? "replaced-active" : "active",
|
|
3055
|
+
expectedState: "active",
|
|
3056
|
+
})
|
|
2760
3057
|
// Escape in the tool result only: goal.condition is stored raw so callers
|
|
2761
3058
|
// that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
|
|
2762
3059
|
// themselves. Escaping here prevents XML metacharacters in user-supplied
|
|
@@ -2784,6 +3081,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2784
3081
|
}
|
|
2785
3082
|
|
|
2786
3083
|
const messages = []
|
|
3084
|
+
let lifecycleNotice = null
|
|
2787
3085
|
|
|
2788
3086
|
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
2789
3087
|
if (args.objective.trim().length > MAX_GOAL_OBJECTIVE_LENGTH) {
|
|
@@ -2802,6 +3100,13 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2802
3100
|
goal.lastStatus = "Goal objective updated."
|
|
2803
3101
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
|
|
2804
3102
|
messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
|
|
3103
|
+
lifecycleNotice = {
|
|
3104
|
+
text: `Goal updated; state remains ${goalDisplayState(goal)}.`,
|
|
3105
|
+
transition: "updated",
|
|
3106
|
+
reason: goalDisplayState(goal),
|
|
3107
|
+
expectedState: goalDisplayState(goal),
|
|
3108
|
+
expectedStopReason: goal.stopped ? goal.stopReason : "",
|
|
3109
|
+
}
|
|
2805
3110
|
}
|
|
2806
3111
|
|
|
2807
3112
|
if (args.status !== undefined) {
|
|
@@ -2814,13 +3119,24 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2814
3119
|
if (!evidence) return "Completion evidence is required before a goal can be archived."
|
|
2815
3120
|
if (evidence.length > MAX_LEGACY_EVIDENCE_LENGTH)
|
|
2816
3121
|
return `Completion evidence must be ${MAX_LEGACY_EVIDENCE_LENGTH} characters or fewer.`
|
|
3122
|
+
const auditedGoalID = goal.goalId
|
|
3123
|
+
const auditedRunID = goal.runId
|
|
3124
|
+
if (auditMessagesEnabled) {
|
|
3125
|
+
await announceAudit(
|
|
3126
|
+
sessionID,
|
|
3127
|
+
"Auditing goal completion: checking submitted evidence before archiving.",
|
|
3128
|
+
)
|
|
3129
|
+
const goalAfterAnnouncement = activeGoal(sessionID, auditedGoalID, auditedRunID)
|
|
3130
|
+
if (!goalAfterAnnouncement) {
|
|
3131
|
+
return "Completion audit finished after the goal changed; completion was not recorded."
|
|
3132
|
+
}
|
|
3133
|
+
goal = goalAfterAnnouncement
|
|
3134
|
+
}
|
|
2817
3135
|
// If a completion auditor is configured, run it before archiving so the
|
|
2818
3136
|
// agent tool path has the same integrity gate as the [goal:complete] marker
|
|
2819
3137
|
// path. Without this, an autonomous agent could bypass the auditor by
|
|
2820
3138
|
// calling update_goal({status:"complete"}) instead of using the marker.
|
|
2821
3139
|
if (completionAuditor) {
|
|
2822
|
-
const auditedGoalID = goal.goalId
|
|
2823
|
-
const auditedRunID = goal.runId
|
|
2824
3140
|
let verdict
|
|
2825
3141
|
try {
|
|
2826
3142
|
verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
|
|
@@ -2839,6 +3155,25 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2839
3155
|
goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
2840
3156
|
pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
2841
3157
|
await persist(sessionID)
|
|
3158
|
+
const rejectedGoalAfterPersist = currentGoal(sessionID, auditedGoalID, auditedRunID)
|
|
3159
|
+
if (
|
|
3160
|
+
rejectedGoalAfterPersist !== goal ||
|
|
3161
|
+
!goal.stopped ||
|
|
3162
|
+
goal.stopReason !== "audit rejected"
|
|
3163
|
+
) {
|
|
3164
|
+
return "Completion audit was rejected, but the goal changed while that state was persisted; current state was left untouched."
|
|
3165
|
+
}
|
|
3166
|
+
if (auditMessagesEnabled) {
|
|
3167
|
+
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
3168
|
+
} else {
|
|
3169
|
+
announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
|
|
3170
|
+
goal,
|
|
3171
|
+
transition: "audit-rejected",
|
|
3172
|
+
reason,
|
|
3173
|
+
expectedState: "paused",
|
|
3174
|
+
expectedStopReason: "audit rejected",
|
|
3175
|
+
})
|
|
3176
|
+
}
|
|
2842
3177
|
return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
|
|
2843
3178
|
}
|
|
2844
3179
|
}
|
|
@@ -2849,16 +3184,72 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2849
3184
|
evidence ? `Marked complete via tool: ${summarizeText(evidence, 400)}` : "Marked complete via agent tool.",
|
|
2850
3185
|
)
|
|
2851
3186
|
const ordered = sessionOrdered.has(sessionID)
|
|
2852
|
-
rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
3187
|
+
const completedResult = rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
2853
3188
|
cleanupGoal(sessionID)
|
|
2854
3189
|
// Advance an ordered sequence just like the marker path does.
|
|
2855
|
-
|
|
3190
|
+
const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
|
|
3191
|
+
const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
|
|
2856
3192
|
const durable = await persistFinal(sessionID, "completion", ledgerDurable)
|
|
2857
3193
|
if (durable === false) {
|
|
2858
|
-
restoreAfterTerminalPersistenceFailure(sessionID, goal, {
|
|
2859
|
-
|
|
3194
|
+
const restored = restoreAfterTerminalPersistenceFailure(sessionID, goal, {
|
|
3195
|
+
ordered,
|
|
3196
|
+
expectedCurrentSnapshot: postCompletionSnapshot,
|
|
3197
|
+
expectedResult: completedResult,
|
|
3198
|
+
})
|
|
3199
|
+
if (auditMessagesEnabled) {
|
|
3200
|
+
await announceAudit(
|
|
3201
|
+
sessionID,
|
|
3202
|
+
restored
|
|
3203
|
+
? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
|
|
3204
|
+
: "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
|
|
3205
|
+
)
|
|
3206
|
+
} else {
|
|
3207
|
+
announceLifecycle(
|
|
3208
|
+
sessionID,
|
|
3209
|
+
restored
|
|
3210
|
+
? "Goal paused — completion could not be recorded durably."
|
|
3211
|
+
: "Previous goal completion could not be confirmed durably after goal state changed.",
|
|
3212
|
+
restored
|
|
3213
|
+
? {
|
|
3214
|
+
goal,
|
|
3215
|
+
transition: "terminal-persistence-failed",
|
|
3216
|
+
reason: goal.stopReason,
|
|
3217
|
+
expectedState: "paused",
|
|
3218
|
+
expectedStopReason: "terminal persistence failed",
|
|
3219
|
+
}
|
|
3220
|
+
: {
|
|
3221
|
+
transition: "terminal-persistence-raced",
|
|
3222
|
+
requireCurrent: false,
|
|
3223
|
+
},
|
|
3224
|
+
)
|
|
3225
|
+
}
|
|
3226
|
+
return restored
|
|
3227
|
+
? "Completion verified, but terminal state could not be persisted. Goal remains paused."
|
|
3228
|
+
: "Completion verified, but its terminal state could not be persisted before the goal changed. Current state was left untouched."
|
|
2860
3229
|
}
|
|
2861
|
-
|
|
3230
|
+
const activePromoted = promoted
|
|
3231
|
+
? activeGoal(sessionID, promoted.goalId, promoted.runId)
|
|
3232
|
+
: null
|
|
3233
|
+
if (auditMessagesEnabled) {
|
|
3234
|
+
await announceAudit(
|
|
3235
|
+
sessionID,
|
|
3236
|
+
activePromoted
|
|
3237
|
+
? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
|
|
3238
|
+
: "Audit result: completion accepted — goal archived as achieved.",
|
|
3239
|
+
)
|
|
3240
|
+
} else {
|
|
3241
|
+
announceLifecycle(
|
|
3242
|
+
sessionID,
|
|
3243
|
+
activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
|
|
3244
|
+
{
|
|
3245
|
+
goal: activePromoted || goal,
|
|
3246
|
+
transition: activePromoted ? "achieved-promoted" : "achieved",
|
|
3247
|
+
requireCurrent: Boolean(activePromoted),
|
|
3248
|
+
expectedState: activePromoted ? "active" : "",
|
|
3249
|
+
},
|
|
3250
|
+
)
|
|
3251
|
+
}
|
|
3252
|
+
return AGENT_COMPLETE_SUCCESS
|
|
2862
3253
|
}
|
|
2863
3254
|
if (status === "blocked") {
|
|
2864
3255
|
const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
|
|
@@ -2866,18 +3257,80 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2866
3257
|
return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
|
|
2867
3258
|
if (blockerText.length > MAX_GOAL_BLOCKER_LENGTH)
|
|
2868
3259
|
return `Blocker must be ${MAX_GOAL_BLOCKER_LENGTH} characters or fewer.`
|
|
3260
|
+
const blockedGoalID = goal.goalId
|
|
3261
|
+
const blockedRunID = goal.runId
|
|
3262
|
+
if (auditMessagesEnabled) {
|
|
3263
|
+
await announceAudit(
|
|
3264
|
+
sessionID,
|
|
3265
|
+
"Auditing goal blocker: checking the submitted blocker before pausing.",
|
|
3266
|
+
)
|
|
3267
|
+
const goalAfterAnnouncement = activeGoal(sessionID, blockedGoalID, blockedRunID)
|
|
3268
|
+
if (!goalAfterAnnouncement) {
|
|
3269
|
+
return "Blocker audit finished after the goal changed; blocked state was not recorded."
|
|
3270
|
+
}
|
|
3271
|
+
goal = goalAfterAnnouncement
|
|
3272
|
+
}
|
|
2869
3273
|
goal.blockedReason = blockerText
|
|
2870
3274
|
goal.stopped = true
|
|
2871
3275
|
goal.stopReason = "blocked"
|
|
2872
3276
|
goal.lastStatus = "Assistant reported blocked."
|
|
2873
|
-
pushHistory(goal, "blocked", goal.blockedReason)
|
|
2874
|
-
messages.push(
|
|
3277
|
+
const ledgerDurable = pushHistory(goal, "blocked", goal.blockedReason)
|
|
3278
|
+
messages.push(AGENT_BLOCK_SUCCESS)
|
|
3279
|
+
const durable = await persistFinal(sessionID, "blocked", ledgerDurable)
|
|
3280
|
+
const blockedGoalAfterPersist = currentGoal(sessionID, blockedGoalID, blockedRunID)
|
|
3281
|
+
if (blockedGoalAfterPersist !== goal || goal.stopReason !== "blocked") {
|
|
3282
|
+
return "Blocked state changed while persistence completed; blocked state was not reported."
|
|
3283
|
+
}
|
|
3284
|
+
if (durable === false) {
|
|
3285
|
+
goal.stopReason = "terminal persistence failed"
|
|
3286
|
+
goal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
3287
|
+
if (auditMessagesEnabled) {
|
|
3288
|
+
await announceAudit(
|
|
3289
|
+
sessionID,
|
|
3290
|
+
"Audit result: blocker recognized, but storage failed; goal remains paused.",
|
|
3291
|
+
)
|
|
3292
|
+
} else {
|
|
3293
|
+
announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
|
|
3294
|
+
goal,
|
|
3295
|
+
transition: "terminal-persistence-failed",
|
|
3296
|
+
expectedState: "paused",
|
|
3297
|
+
expectedStopReason: "terminal persistence failed",
|
|
3298
|
+
})
|
|
3299
|
+
}
|
|
3300
|
+
return "Blocker recognized, but terminal state could not be persisted. Goal remains paused."
|
|
3301
|
+
}
|
|
3302
|
+
if (auditMessagesEnabled) {
|
|
3303
|
+
await announceAudit(
|
|
3304
|
+
sessionID,
|
|
3305
|
+
`Audit result: goal paused as blocked — ${summarizeText(blockerText, 160)}. Run /${commandName} resume after addressing it.`,
|
|
3306
|
+
)
|
|
3307
|
+
} else {
|
|
3308
|
+
announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
|
|
3309
|
+
goal,
|
|
3310
|
+
transition: "blocked",
|
|
3311
|
+
expectedState: "blocked",
|
|
3312
|
+
expectedStopReason: "blocked",
|
|
3313
|
+
})
|
|
3314
|
+
}
|
|
3315
|
+
return messages.join(" ")
|
|
2875
3316
|
} else if (status === "paused") {
|
|
2876
|
-
goal.stopped
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
3317
|
+
if (goal.stopped && goal.stopReason === "paused") {
|
|
3318
|
+
if (!messages.length) return "Goal is already paused."
|
|
3319
|
+
messages.push("Goal is already paused.")
|
|
3320
|
+
} else {
|
|
3321
|
+
goal.stopped = true
|
|
3322
|
+
goal.stopReason = "paused"
|
|
3323
|
+
goal.lastStatus = "Goal paused."
|
|
3324
|
+
pushHistory(goal, "paused", "Paused via agent tool.")
|
|
3325
|
+
messages.push("Goal paused.")
|
|
3326
|
+
lifecycleNotice = {
|
|
3327
|
+
text: "Goal paused.",
|
|
3328
|
+
transition: "paused",
|
|
3329
|
+
reason: goal.stopReason,
|
|
3330
|
+
expectedState: "paused",
|
|
3331
|
+
expectedStopReason: "paused",
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
2881
3334
|
} else if (status === "resumed") {
|
|
2882
3335
|
if (!goal.stopped)
|
|
2883
3336
|
return "Goal is already running. Pause or stop it first if you want to reset the budget window."
|
|
@@ -2891,6 +3344,11 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2891
3344
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
2892
3345
|
pushHistory(goal, "resumed", "Resumed via agent tool with a fresh local budget window.")
|
|
2893
3346
|
messages.push("Goal resumed with fresh limits.")
|
|
3347
|
+
lifecycleNotice = {
|
|
3348
|
+
text: "Goal resumed with fresh limits.",
|
|
3349
|
+
transition: "resumed",
|
|
3350
|
+
expectedState: "active",
|
|
3351
|
+
}
|
|
2894
3352
|
}
|
|
2895
3353
|
}
|
|
2896
3354
|
|
|
@@ -2898,6 +3356,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2898
3356
|
return "Nothing to update. Provide `objective` and/or `status`."
|
|
2899
3357
|
}
|
|
2900
3358
|
await persist(sessionID)
|
|
3359
|
+
if (lifecycleNotice) {
|
|
3360
|
+
announceLifecycle(sessionID, lifecycleNotice.text, {
|
|
3361
|
+
goal,
|
|
3362
|
+
transition: lifecycleNotice.transition,
|
|
3363
|
+
reason: lifecycleNotice.reason,
|
|
3364
|
+
expectedState: lifecycleNotice.expectedState,
|
|
3365
|
+
expectedStopReason: lifecycleNotice.expectedStopReason,
|
|
3366
|
+
})
|
|
3367
|
+
}
|
|
2901
3368
|
return messages.join(" ")
|
|
2902
3369
|
}
|
|
2903
3370
|
|
|
@@ -2906,15 +3373,33 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2906
3373
|
// focused goal + result. Without sessionGoals.delete, background goals added via
|
|
2907
3374
|
// `/goal add` survive clear and resurrect as the focused goal on restart.
|
|
2908
3375
|
// Record the clear in the ledger before cleanupGoal removes the goal object.
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
3376
|
+
const goals = listSessionGoals(sessionID)
|
|
3377
|
+
const clearedGoal = goalStates.get(sessionID) || goals[0] || null
|
|
3378
|
+
const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
|
|
3379
|
+
const ledgerDurable =
|
|
3380
|
+
goals.length > 0 &&
|
|
3381
|
+
goals.map((goal) => pushHistory(goal, "cleared", "Cleared via agent tool.")).every(Boolean)
|
|
2912
3382
|
sessionOrdered.delete(sessionID)
|
|
2913
3383
|
sessionGoals.delete(sessionID)
|
|
2914
3384
|
cleanupGoal(sessionID)
|
|
2915
3385
|
lastGoalResults.delete(sessionID)
|
|
2916
|
-
await persistFinal(sessionID, "clear")
|
|
2917
|
-
|
|
3386
|
+
const durable = await persistFinal(sessionID, "clear", ledgerDurable)
|
|
3387
|
+
const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
|
|
3388
|
+
if (hadState && clearStillCurrent) {
|
|
3389
|
+
announceLifecycle(sessionID, durable === false
|
|
3390
|
+
? "Goal cleared in memory, but storage failed; it may reappear after restart."
|
|
3391
|
+
: "Goal cleared.", {
|
|
3392
|
+
goal: clearedGoal,
|
|
3393
|
+
transition: durable === false ? "clear-persistence-failed" : "cleared",
|
|
3394
|
+
requireCurrent: false,
|
|
3395
|
+
})
|
|
3396
|
+
}
|
|
3397
|
+
if (!clearStillCurrent) {
|
|
3398
|
+
return "Clear persistence finished after goal state changed; current state was left untouched."
|
|
3399
|
+
}
|
|
3400
|
+
return durable === false
|
|
3401
|
+
? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
|
|
3402
|
+
: "Goal cleared."
|
|
2918
3403
|
}
|
|
2919
3404
|
|
|
2920
3405
|
return { getGoal, getGoalHistory, setGoal, updateGoal, clearGoal }
|
|
@@ -2930,12 +3415,70 @@ function agentToolSessionID(ctx) {
|
|
|
2930
3415
|
// helper's unrelated SDK/effect dependency graph in every consumer project.
|
|
2931
3416
|
const bundledToolHelper = Object.assign((definition) => definition, { schema: z })
|
|
2932
3417
|
|
|
2933
|
-
function
|
|
3418
|
+
function sessionOwnedElsewhereMessage(
|
|
3419
|
+
commandName = "goal",
|
|
3420
|
+
commandRegistered = true,
|
|
3421
|
+
reason = "owned_elsewhere",
|
|
3422
|
+
) {
|
|
3423
|
+
const retryTarget = commandRegistered
|
|
3424
|
+
? `\`/${commandName} status\``
|
|
3425
|
+
: "the `goal_status` tool"
|
|
3426
|
+
if (reason === "legacy_lock") {
|
|
3427
|
+
return (
|
|
3428
|
+
"Goal controls are unavailable because this session has an older or incomplete persistence lease. " +
|
|
3429
|
+
"No goal state was read or changed here. Ordinary chat remains available. " +
|
|
3430
|
+
"Close every OpenCode process using this session and upgrade them first. If the report persists, remove only the affected session shard's adjacent lease artifacts (`.lock` and `.lock.claims-v2`) or open a fork with `opencode --continue --fork`, " +
|
|
3431
|
+
`then retry ${retryTarget}.`
|
|
3432
|
+
)
|
|
3433
|
+
}
|
|
3434
|
+
return (
|
|
3435
|
+
"Goal controls are unavailable in this OpenCode instance because another process owns this session's goal workflow. " +
|
|
3436
|
+
"No goal state was read or changed here. Ordinary chat remains available. " +
|
|
3437
|
+
`Close the owning process or open a fork with \`opencode --continue --fork\`, then retry ${retryTarget}.`
|
|
3438
|
+
)
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
function inactiveGoalToolResult(
|
|
3442
|
+
loadResult,
|
|
3443
|
+
commandName = "goal",
|
|
3444
|
+
disposed = false,
|
|
3445
|
+
commandRegistered = true,
|
|
3446
|
+
) {
|
|
3447
|
+
if (disposed || loadResult?.kind === "disposed") {
|
|
3448
|
+
return goalToolFailure("plugin_disposed", "The goal plugin is no longer active in this process.")
|
|
3449
|
+
}
|
|
3450
|
+
if (loadResult?.kind === "passive") {
|
|
3451
|
+
return goalToolFailure(
|
|
3452
|
+
SESSION_OWNED_ELSEWHERE,
|
|
3453
|
+
sessionOwnedElsewhereMessage(commandName, commandRegistered, loadResult.reason),
|
|
3454
|
+
)
|
|
3455
|
+
}
|
|
3456
|
+
return null
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
function buildAgentTools(
|
|
3460
|
+
toolHelper,
|
|
3461
|
+
handlers,
|
|
3462
|
+
ensureSessionLoaded = async () => ACTIVE_PERSISTENCE_DISABLED,
|
|
3463
|
+
commandName = "goal",
|
|
3464
|
+
isDisposed = () => false,
|
|
3465
|
+
commandRegistered = true,
|
|
3466
|
+
) {
|
|
2934
3467
|
const schema = toolHelper.schema
|
|
2935
3468
|
const run = (handler) => async (args, ctx) => {
|
|
2936
3469
|
const sessionID = agentToolSessionID(ctx)
|
|
2937
3470
|
if (!sessionID) return "No session id available for the goal tool."
|
|
2938
|
-
await ensureSessionLoaded(sessionID
|
|
3471
|
+
const loadResult = await ensureSessionLoaded(sessionID, {
|
|
3472
|
+
retryPassive: true,
|
|
3473
|
+
executionContext: ctx,
|
|
3474
|
+
})
|
|
3475
|
+
const unavailable = inactiveGoalToolResult(
|
|
3476
|
+
loadResult,
|
|
3477
|
+
commandName,
|
|
3478
|
+
isDisposed(),
|
|
3479
|
+
commandRegistered,
|
|
3480
|
+
)
|
|
3481
|
+
if (unavailable) return unavailable.message
|
|
2939
3482
|
return handler(sessionID, args || {})
|
|
2940
3483
|
}
|
|
2941
3484
|
// Canonical tools use a small, versioned machine-readable envelope. Keep the
|
|
@@ -2949,7 +3492,17 @@ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () =>
|
|
|
2949
3492
|
goalToolFailure("missing_session", "No session id available for the goal tool."),
|
|
2950
3493
|
)
|
|
2951
3494
|
}
|
|
2952
|
-
await ensureSessionLoaded(sessionID
|
|
3495
|
+
const loadResult = await ensureSessionLoaded(sessionID, {
|
|
3496
|
+
retryPassive: true,
|
|
3497
|
+
executionContext: ctx,
|
|
3498
|
+
})
|
|
3499
|
+
const unavailable = inactiveGoalToolResult(
|
|
3500
|
+
loadResult,
|
|
3501
|
+
commandName,
|
|
3502
|
+
isDisposed(),
|
|
3503
|
+
commandRegistered,
|
|
3504
|
+
)
|
|
3505
|
+
if (unavailable) return serializeGoalToolResult(operation, unavailable)
|
|
2953
3506
|
return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
|
|
2954
3507
|
}
|
|
2955
3508
|
|
|
@@ -2971,8 +3524,22 @@ function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () =>
|
|
|
2971
3524
|
return goalToolFailure("already_running", "Goal is already running.")
|
|
2972
3525
|
}
|
|
2973
3526
|
const message = await handlers.updateGoal(sessionID, args)
|
|
2974
|
-
if (args.status === "complete"
|
|
2975
|
-
|
|
3527
|
+
if (args.status === "complete") {
|
|
3528
|
+
if (
|
|
3529
|
+
message !== AGENT_COMPLETE_SUCCESS ||
|
|
3530
|
+
currentGoal(sessionID, before.goalId, before.runId)
|
|
3531
|
+
) {
|
|
3532
|
+
return goalToolFailure("completion_rejected", message)
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
if (args.status === "blocked") {
|
|
3536
|
+
const after = currentGoal(sessionID, before.goalId, before.runId)
|
|
3537
|
+
if (after !== before) {
|
|
3538
|
+
return goalToolFailure("goal_changed", message)
|
|
3539
|
+
}
|
|
3540
|
+
if (message !== AGENT_BLOCK_SUCCESS || !after.stopped || after.stopReason !== "blocked") {
|
|
3541
|
+
return goalToolFailure("block_rejected", message)
|
|
3542
|
+
}
|
|
2976
3543
|
}
|
|
2977
3544
|
return goalToolSuccess(message)
|
|
2978
3545
|
},
|
|
@@ -3092,8 +3659,14 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
3092
3659
|
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
|
|
3093
3660
|
goals.forEach((goal, index) => {
|
|
3094
3661
|
const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
|
|
3095
|
-
const state = goal
|
|
3096
|
-
|
|
3662
|
+
const state = goalDisplayState(goal)
|
|
3663
|
+
const reason = state === "blocked"
|
|
3664
|
+
? goal.blockedReason || goal.stopReason
|
|
3665
|
+
: goal.stopped
|
|
3666
|
+
? goal.stopReason
|
|
3667
|
+
: ""
|
|
3668
|
+
const reasonText = reason ? ` (${summarizeText(reason, 160)})` : ""
|
|
3669
|
+
lines.push(`${index + 1}. [${marker}] ${goal.condition} — state: ${state}${reasonText}`)
|
|
3097
3670
|
})
|
|
3098
3671
|
lines.push(`Switch with \`/${commandName} focus <number>\`.`)
|
|
3099
3672
|
} else {
|
|
@@ -3118,24 +3691,55 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
3118
3691
|
// once a non-prompting message API is available.
|
|
3119
3692
|
async function defaultAuditMessenger(client, sessionID, text) {
|
|
3120
3693
|
if (client?.app?.log) {
|
|
3121
|
-
|
|
3694
|
+
dispatchAdvisoryHostCall(() => client.app.log({
|
|
3122
3695
|
body: {
|
|
3123
3696
|
service: "opencode-goal-plugin",
|
|
3124
3697
|
level: "info",
|
|
3125
3698
|
message: text,
|
|
3126
3699
|
extra: { sessionID, kind: "goal-audit" },
|
|
3127
3700
|
},
|
|
3128
|
-
})
|
|
3701
|
+
}))
|
|
3129
3702
|
}
|
|
3130
3703
|
if (client?.tui?.showToast) {
|
|
3131
|
-
|
|
3704
|
+
dispatchAdvisoryHostCall(() => client.tui.showToast({
|
|
3132
3705
|
body: {
|
|
3133
3706
|
title: "Goal workflow",
|
|
3134
3707
|
message: summarizeText(text, 500),
|
|
3135
3708
|
variant: /rejected|failed|blocked/i.test(text) ? "warning" : "info",
|
|
3136
3709
|
duration: 6000,
|
|
3137
3710
|
},
|
|
3138
|
-
})
|
|
3711
|
+
}))
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// High-signal lifecycle feedback uses the same non-blocking host surfaces as
|
|
3716
|
+
// audit notices, but remains a separate channel so callers can configure each
|
|
3717
|
+
// independently. Messages are normalized and bounded before they reach either
|
|
3718
|
+
// host API; goal objectives, evidence, and workspace paths are deliberately
|
|
3719
|
+
// excluded by transition call sites.
|
|
3720
|
+
async function defaultLifecycleMessenger(client, sessionID, text) {
|
|
3721
|
+
const message = summarizeText(text, 500)
|
|
3722
|
+
const warning = /\b(?:paused|blocked|recovered|failed|passive)\b/i.test(message)
|
|
3723
|
+
const success = /\b(?:achieved|completed)\b/i.test(message)
|
|
3724
|
+
if (client?.app?.log) {
|
|
3725
|
+
dispatchAdvisoryHostCall(() => client.app.log({
|
|
3726
|
+
body: {
|
|
3727
|
+
service: "opencode-goal-plugin",
|
|
3728
|
+
level: warning ? "warn" : "info",
|
|
3729
|
+
message,
|
|
3730
|
+
extra: { sessionID, kind: "goal-lifecycle" },
|
|
3731
|
+
},
|
|
3732
|
+
}))
|
|
3733
|
+
}
|
|
3734
|
+
if (client?.tui?.showToast) {
|
|
3735
|
+
dispatchAdvisoryHostCall(() => client.tui.showToast({
|
|
3736
|
+
body: {
|
|
3737
|
+
title: "Goal workflow",
|
|
3738
|
+
message,
|
|
3739
|
+
variant: warning ? "warning" : success ? "success" : "info",
|
|
3740
|
+
duration: 6000,
|
|
3741
|
+
},
|
|
3742
|
+
}))
|
|
3139
3743
|
}
|
|
3140
3744
|
}
|
|
3141
3745
|
|
|
@@ -3295,11 +3899,104 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3295
3899
|
return persistence.persistChain
|
|
3296
3900
|
}
|
|
3297
3901
|
|
|
3298
|
-
const
|
|
3299
|
-
|
|
3902
|
+
const lifecycleMessagesEnabled = pluginOptions.lifecycleMessages !== false
|
|
3903
|
+
const lifecycleMessenger =
|
|
3904
|
+
typeof pluginOptions.lifecycleMessenger === "function"
|
|
3905
|
+
? pluginOptions.lifecycleMessenger
|
|
3906
|
+
: (sessionID, text) => defaultLifecycleMessenger(client, sessionID, text)
|
|
3907
|
+
const announceLifecycle = (
|
|
3908
|
+
sessionID,
|
|
3909
|
+
text,
|
|
3910
|
+
{
|
|
3911
|
+
goal,
|
|
3912
|
+
transition = "state",
|
|
3913
|
+
reason = "",
|
|
3914
|
+
requireCurrent = true,
|
|
3915
|
+
expectedState = "",
|
|
3916
|
+
expectedStopReason = "",
|
|
3917
|
+
} = {},
|
|
3918
|
+
) => {
|
|
3919
|
+
if (!lifecycleMessagesEnabled || !sessionID) return false
|
|
3920
|
+
if (requireCurrent && goal) {
|
|
3921
|
+
const current = goalStates.get(sessionID)
|
|
3922
|
+
if (current !== goal) return false
|
|
3923
|
+
if (expectedState && goalDisplayState(current) !== expectedState) return false
|
|
3924
|
+
if (expectedStopReason && current.stopReason !== expectedStopReason) return false
|
|
3925
|
+
}
|
|
3926
|
+
const message = summarizeText(text, 500)
|
|
3927
|
+
if (!message) return false
|
|
3928
|
+
dispatchAdvisoryHostCall(
|
|
3929
|
+
() => lifecycleMessenger(sessionID, message),
|
|
3930
|
+
(error) => {
|
|
3931
|
+
void logPluginError(client, "Failed to deliver goal lifecycle message", error).catch(() => {})
|
|
3932
|
+
},
|
|
3933
|
+
)
|
|
3934
|
+
return true
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
const passiveLoadResult = (entry) => ({
|
|
3938
|
+
kind: "passive",
|
|
3939
|
+
code: SESSION_OWNED_ELSEWHERE,
|
|
3940
|
+
reason: entry.reason,
|
|
3941
|
+
owner: entry.owner,
|
|
3942
|
+
retryAt: entry.retryAt,
|
|
3943
|
+
})
|
|
3944
|
+
|
|
3945
|
+
const enterPassiveSession = async (sessionID, error) => {
|
|
3946
|
+
const previous = runtime.passiveSessions.get(sessionID)
|
|
3947
|
+
clearSessionRuntimeState(sessionID, {
|
|
3948
|
+
preserveCommandSecurity: Boolean(previous),
|
|
3949
|
+
preserveExecutionContext: true,
|
|
3950
|
+
})
|
|
3951
|
+
const entry = {
|
|
3952
|
+
code: SESSION_OWNED_ELSEWHERE,
|
|
3953
|
+
reason: error.reason,
|
|
3954
|
+
owner: error.owner,
|
|
3955
|
+
firstObservedAt: previous?.firstObservedAt || Date.now(),
|
|
3956
|
+
retryAt: Date.now() + PASSIVE_SESSION_RETRY_MS,
|
|
3957
|
+
warned: true,
|
|
3958
|
+
}
|
|
3959
|
+
runtime.passiveSessions.set(sessionID, entry)
|
|
3960
|
+
if (!previous?.warned) {
|
|
3961
|
+
const owner = entry.owner?.pid && entry.owner?.hostname
|
|
3962
|
+
? `pid ${entry.owner.pid} on ${entry.owner.hostname}`
|
|
3963
|
+
: "another process"
|
|
3964
|
+
const warning = entry.reason === "legacy_lock"
|
|
3965
|
+
? "Goal controls are passive for this session because its persistence lease is from an older release or is incomplete. Ordinary chat remains available. Close every OpenCode process using this session and upgrade them; if the report persists, remove only the affected session shard's adjacent lease artifacts (`.lock` and `.lock.claims-v2`) or fork the session before retrying goal controls."
|
|
3966
|
+
: `Goal controls are passive for this session because ${owner} owns its persistence lease. Ordinary chat remains available; close the owner or fork the session before retrying goal controls.`
|
|
3967
|
+
// Host logging is advisory. A broken or backpressured logger must not
|
|
3968
|
+
// turn passive mode back into the session-wide hang it is meant to
|
|
3969
|
+
// prevent, and the contained rejection avoids an unhandled promise.
|
|
3970
|
+
void logPluginWarning(
|
|
3971
|
+
client,
|
|
3972
|
+
warning,
|
|
3973
|
+
).catch(() => {})
|
|
3974
|
+
}
|
|
3975
|
+
return passiveLoadResult(entry)
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
const ensureSessionLoaded = async (
|
|
3979
|
+
sessionID,
|
|
3980
|
+
{ retryPassive = false, executionContext, freshCommandBoundary = false } = {},
|
|
3981
|
+
) => {
|
|
3982
|
+
if (runtime.disposed) return PLUGIN_DISPOSED
|
|
3983
|
+
rememberSessionExecutionContext(sessionID, executionContext)
|
|
3984
|
+
if (!persistenceOptions.persistState || !sessionID) return ACTIVE_PERSISTENCE_DISABLED
|
|
3300
3985
|
const existingLoad = runtime.sessionLoadPromises.get(sessionID)
|
|
3301
3986
|
if (existingLoad) return existingLoad
|
|
3302
|
-
if (runtime.sessionPersistence.has(sessionID)) return
|
|
3987
|
+
if (runtime.sessionPersistence.has(sessionID)) return ACTIVE_PERSISTENCE_OWNED
|
|
3988
|
+
|
|
3989
|
+
const passive = runtime.passiveSessions.get(sessionID)
|
|
3990
|
+
pruneExpiredPendingCommandTurns(sessionID)
|
|
3991
|
+
const commandTurnInFlight =
|
|
3992
|
+
runtime.pendingCommandTurns.has(sessionID) ||
|
|
3993
|
+
(!freshCommandBoundary && runtime.activeCommandTurns.has(sessionID))
|
|
3994
|
+
if (
|
|
3995
|
+
passive &&
|
|
3996
|
+
(!retryPassive || commandTurnInFlight || Date.now() < passive.retryAt)
|
|
3997
|
+
) {
|
|
3998
|
+
return passiveLoadResult(passive)
|
|
3999
|
+
}
|
|
3303
4000
|
|
|
3304
4001
|
const load = (async () => {
|
|
3305
4002
|
const paths = sessionPathsFor(persistenceOptions, sessionID)
|
|
@@ -3307,20 +4004,69 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3307
4004
|
...persistenceOptions,
|
|
3308
4005
|
stateFilePath: paths.stateFilePath,
|
|
3309
4006
|
})
|
|
3310
|
-
|
|
4007
|
+
let lease
|
|
4008
|
+
try {
|
|
4009
|
+
lease = await acquirePersistenceLease(paths.stateFilePath)
|
|
4010
|
+
} catch (error) {
|
|
4011
|
+
if (!isPersistenceLeaseContendedError(error)) throw error
|
|
4012
|
+
return enterPassiveSession(sessionID, error)
|
|
4013
|
+
}
|
|
4014
|
+
const releaseDisposedSession = async () => {
|
|
4015
|
+
runtime.sessionPersistence.delete(sessionID)
|
|
4016
|
+
await lease.release().catch(() => false)
|
|
4017
|
+
return PLUGIN_DISPOSED
|
|
4018
|
+
}
|
|
4019
|
+
if (runtime.disposed) return releaseDisposedSession()
|
|
3311
4020
|
const persistence = {
|
|
3312
4021
|
...persistenceOptions,
|
|
3313
4022
|
...paths,
|
|
3314
4023
|
persistChain: Promise.resolve(true),
|
|
3315
4024
|
lease,
|
|
3316
4025
|
}
|
|
4026
|
+
runtime.passiveSessions.delete(sessionID)
|
|
3317
4027
|
runtime.sessionPersistence.set(sessionID, persistence)
|
|
3318
4028
|
try {
|
|
3319
4029
|
await migrateLegacyState(persistenceOptions, client)
|
|
4030
|
+
if (runtime.disposed) return releaseDisposedSession()
|
|
3320
4031
|
const status = await loadPersistedSessionState(persistence, client, sessionID)
|
|
4032
|
+
if (runtime.disposed) return releaseDisposedSession()
|
|
3321
4033
|
pruneGoalResults(defaultGoalOptions)
|
|
3322
|
-
if (
|
|
3323
|
-
|
|
4034
|
+
if (
|
|
4035
|
+
status === "loaded" ||
|
|
4036
|
+
status === "missing" ||
|
|
4037
|
+
status === "reconstructed" ||
|
|
4038
|
+
status === "reconciled-blocked"
|
|
4039
|
+
) await persist(sessionID)
|
|
4040
|
+
const recoveredGoal = goalStates.get(sessionID)
|
|
4041
|
+
if (recoveredGoal?.stopped && recoveredGoal.stopReason === "recovered after restart") {
|
|
4042
|
+
announceLifecycle(sessionID, `Goal recovered and paused. Run /${commandName} status, then /${commandName} resume when ready.`, {
|
|
4043
|
+
goal: recoveredGoal,
|
|
4044
|
+
transition: "recovered-paused",
|
|
4045
|
+
reason: recoveredGoal.stopReason,
|
|
4046
|
+
expectedState: "paused",
|
|
4047
|
+
expectedStopReason: "recovered after restart",
|
|
4048
|
+
})
|
|
4049
|
+
} else if (
|
|
4050
|
+
status === "reconciled-blocked" &&
|
|
4051
|
+
recoveredGoal?.stopped &&
|
|
4052
|
+
recoveredGoal.stopReason === "blocked"
|
|
4053
|
+
) {
|
|
4054
|
+
announceLifecycle(sessionID, `Goal recovered as blocked. Run /${commandName} status for the reason.`, {
|
|
4055
|
+
goal: recoveredGoal,
|
|
4056
|
+
transition: "recovered-blocked",
|
|
4057
|
+
reason: recoveredGoal.blockedReason,
|
|
4058
|
+
expectedState: "blocked",
|
|
4059
|
+
expectedStopReason: "blocked",
|
|
4060
|
+
})
|
|
4061
|
+
} else if (recoveredGoal?.lastStatus === "Promoted as the next ordered goal.") {
|
|
4062
|
+
announceLifecycle(sessionID, "Goal state recovered; the next ordered goal is active.", {
|
|
4063
|
+
goal: recoveredGoal,
|
|
4064
|
+
transition: "recovered-promoted",
|
|
4065
|
+
expectedState: "active",
|
|
4066
|
+
})
|
|
4067
|
+
}
|
|
4068
|
+
if (runtime.disposed) return releaseDisposedSession()
|
|
4069
|
+
return ACTIVE_PERSISTENCE_OWNED
|
|
3324
4070
|
} catch (error) {
|
|
3325
4071
|
runtime.sessionPersistence.delete(sessionID)
|
|
3326
4072
|
await lease.release().catch(() => false)
|
|
@@ -3403,6 +4149,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3403
4149
|
reason: "owned verifier agent registration was not confirmed",
|
|
3404
4150
|
})
|
|
3405
4151
|
: null
|
|
4152
|
+
const completionAuditLabel =
|
|
4153
|
+
typeof pluginOptions.auditor === "function"
|
|
4154
|
+
? "custom completion auditor"
|
|
4155
|
+
: pluginOptions.completionAudit
|
|
4156
|
+
? "built-in independent verifier"
|
|
4157
|
+
: "evidence gate only (independent verifier off)"
|
|
3406
4158
|
|
|
3407
4159
|
clearRuntimeState()
|
|
3408
4160
|
|
|
@@ -3411,6 +4163,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3411
4163
|
persist,
|
|
3412
4164
|
persistTerminalState,
|
|
3413
4165
|
completionAuditor,
|
|
4166
|
+
completionAuditLabel,
|
|
4167
|
+
announceAudit,
|
|
4168
|
+
auditMessagesEnabled,
|
|
4169
|
+
announceLifecycle,
|
|
3414
4170
|
commandName,
|
|
3415
4171
|
})
|
|
3416
4172
|
|
|
@@ -3436,6 +4192,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3436
4192
|
) => {
|
|
3437
4193
|
const goal = goalStates.get(sessionID)
|
|
3438
4194
|
if (!goal) return false
|
|
4195
|
+
if (goal.stopped && goal.stopReason === reason) return false
|
|
3439
4196
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
3440
4197
|
goal.stopped = true
|
|
3441
4198
|
goal.stopReason = reason
|
|
@@ -3444,6 +4201,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3444
4201
|
pushHistory(goal, "paused", history)
|
|
3445
4202
|
activeContinues.delete(sessionID)
|
|
3446
4203
|
await persist(sessionID)
|
|
4204
|
+
announceLifecycle(sessionID, `Goal paused — ${summarizeText(reason, 160)}.`, {
|
|
4205
|
+
goal,
|
|
4206
|
+
transition: "paused",
|
|
4207
|
+
reason,
|
|
4208
|
+
expectedState: "paused",
|
|
4209
|
+
expectedStopReason: reason,
|
|
4210
|
+
})
|
|
3447
4211
|
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3448
4212
|
return true
|
|
3449
4213
|
}
|
|
@@ -3520,11 +4284,54 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3520
4284
|
goal.stopReason = "continuation claim persistence failed"
|
|
3521
4285
|
goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.`
|
|
3522
4286
|
pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.")
|
|
4287
|
+
announceLifecycle(sessionID, "Goal paused — continuation state could not be persisted.", {
|
|
4288
|
+
goal,
|
|
4289
|
+
transition: "continuation-persistence-failed",
|
|
4290
|
+
reason: goal.stopReason,
|
|
4291
|
+
expectedState: "paused",
|
|
4292
|
+
expectedStopReason: "continuation claim persistence failed",
|
|
4293
|
+
})
|
|
3523
4294
|
return null
|
|
3524
4295
|
}
|
|
3525
4296
|
return goal
|
|
3526
4297
|
}
|
|
3527
4298
|
|
|
4299
|
+
const retireCompletedCommandTurnOnIdle = async (sessionID, messageLimit) => {
|
|
4300
|
+
const runtime = currentRuntime()
|
|
4301
|
+
const activeCommandTurn = runtime.activeCommandTurns.get(sessionID)
|
|
4302
|
+
if (!activeCommandTurn) return { ready: true, messages: null }
|
|
4303
|
+
|
|
4304
|
+
const commandHostMessages = await sessionApi.messages(sessionID, {
|
|
4305
|
+
limit: messageLimit,
|
|
4306
|
+
})
|
|
4307
|
+
if (runtime.disposed) return { ready: false, messages: null }
|
|
4308
|
+
const commandMessages = Array.isArray(commandHostMessages)
|
|
4309
|
+
? commandHostMessages.slice(-messageLimit)
|
|
4310
|
+
: []
|
|
4311
|
+
if (runtime.activeCommandTurns.get(sessionID) !== activeCommandTurn) {
|
|
4312
|
+
return { ready: false, messages: commandMessages }
|
|
4313
|
+
}
|
|
4314
|
+
const commandAssistant = findLatestAssistantMessage(commandMessages)
|
|
4315
|
+
if (
|
|
4316
|
+
!commandAssistant ||
|
|
4317
|
+
messageParentID(commandAssistant) !== activeCommandTurn.messageID
|
|
4318
|
+
) {
|
|
4319
|
+
return { ready: false, messages: commandMessages }
|
|
4320
|
+
}
|
|
4321
|
+
if (activeCommandTurn.policy === "control") {
|
|
4322
|
+
const commandAssistantID = messageID(commandAssistant)
|
|
4323
|
+
if (commandAssistantID) {
|
|
4324
|
+
setBoundedMessageValue(
|
|
4325
|
+
runtime.suppressedCommandAssistants,
|
|
4326
|
+
commandAssistantID,
|
|
4327
|
+
sessionID,
|
|
4328
|
+
)
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
runtime.activeCommandTurns.delete(sessionID)
|
|
4332
|
+
return { ready: true, messages: commandMessages }
|
|
4333
|
+
}
|
|
4334
|
+
|
|
3528
4335
|
const hooks = {
|
|
3529
4336
|
config: async (config) => {
|
|
3530
4337
|
applyNativeGoalConfig(config, {
|
|
@@ -3535,20 +4342,29 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3535
4342
|
},
|
|
3536
4343
|
"chat.params": async (input) => {
|
|
3537
4344
|
if (!input?.sessionID) return
|
|
3538
|
-
await ensureSessionLoaded(input.sessionID
|
|
3539
|
-
|
|
3540
|
-
agent: input.agent,
|
|
3541
|
-
model: input.model,
|
|
3542
|
-
variant: input?.message?.model?.variant,
|
|
4345
|
+
const loadResult = await ensureSessionLoaded(input.sessionID, {
|
|
4346
|
+
executionContext: input,
|
|
3543
4347
|
})
|
|
3544
|
-
if (
|
|
4348
|
+
if (currentRuntime().disposed || loadResult.kind === "disposed") return
|
|
4349
|
+
rememberSessionExecutionContext(
|
|
4350
|
+
input.sessionID,
|
|
4351
|
+
{
|
|
4352
|
+
agent: input.agent,
|
|
4353
|
+
model: input.model,
|
|
4354
|
+
variant:
|
|
4355
|
+
input.variant ?? input?.model?.variant ?? input?.message?.model?.variant,
|
|
4356
|
+
},
|
|
4357
|
+
{ replace: true },
|
|
4358
|
+
)
|
|
3545
4359
|
},
|
|
3546
4360
|
"chat.message": async (input, output) => {
|
|
3547
4361
|
const sessionID = input?.sessionID
|
|
3548
4362
|
if (!sessionID) return
|
|
3549
|
-
await ensureSessionLoaded(sessionID
|
|
3550
|
-
|
|
3551
|
-
|
|
4363
|
+
const loadResult = await ensureSessionLoaded(sessionID, {
|
|
4364
|
+
executionContext: input,
|
|
4365
|
+
})
|
|
4366
|
+
if (currentRuntime().disposed) return
|
|
4367
|
+
rememberSessionExecutionContext(sessionID, input, { replace: true })
|
|
3552
4368
|
|
|
3553
4369
|
const message = {
|
|
3554
4370
|
info: isPlainObject(output?.message)
|
|
@@ -3581,6 +4397,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3581
4397
|
"command",
|
|
3582
4398
|
commandTurn.id,
|
|
3583
4399
|
commandTurn.policy,
|
|
4400
|
+
commandTurn.passive === true,
|
|
3584
4401
|
)
|
|
3585
4402
|
return
|
|
3586
4403
|
}
|
|
@@ -3590,6 +4407,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3590
4407
|
// in flight; public synthetic/metadata fields alone are never trusted.
|
|
3591
4408
|
runtime.pendingCommandTurns.delete(sessionID)
|
|
3592
4409
|
runtime.activeCommandTurns.delete(sessionID)
|
|
4410
|
+
if (loadResult.kind !== "active") return
|
|
3593
4411
|
const continuationID = activeContinues.get(sessionID)
|
|
3594
4412
|
if (
|
|
3595
4413
|
currentMessageID &&
|
|
@@ -3615,6 +4433,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3615
4433
|
const sessionID = input?.sessionID
|
|
3616
4434
|
if (!sessionID) return
|
|
3617
4435
|
await ensureSessionLoaded(sessionID)
|
|
4436
|
+
if (currentRuntime().disposed) return
|
|
3618
4437
|
if (currentRuntime().activeCommandTurns.get(sessionID)?.policy !== "control") return
|
|
3619
4438
|
throw new Error(
|
|
3620
4439
|
`This /${commandName} control command has already been handled. Tool "${input?.tool || "unknown"}" was blocked because no tool calls are allowed while its result is being reported. Wait for a separate user turn before using tools or modifying work or goal state.`,
|
|
@@ -3625,8 +4444,26 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3625
4444
|
|
|
3626
4445
|
const sessionID = input.sessionID
|
|
3627
4446
|
if (!sessionID) return
|
|
3628
|
-
|
|
3629
|
-
|
|
4447
|
+
// A fresh slash command is an authenticated boundary that may retry a
|
|
4448
|
+
// passive lease without waiting forever for an orphaned older reply.
|
|
4449
|
+
// Keep the old active guard installed during the asynchronous load so
|
|
4450
|
+
// tools from that older turn remain blocked; accepting this new command
|
|
4451
|
+
// in chat.message atomically replaces the guard.
|
|
4452
|
+
const loadResult = await ensureSessionLoaded(sessionID, {
|
|
4453
|
+
retryPassive: true,
|
|
4454
|
+
freshCommandBoundary: true,
|
|
4455
|
+
})
|
|
4456
|
+
if (currentRuntime().disposed || loadResult.kind === "disposed") return
|
|
4457
|
+
const commandTurn = registerPendingCommandTurn(sessionID, output)
|
|
4458
|
+
|
|
4459
|
+
if (loadResult.kind === "passive") {
|
|
4460
|
+
commandTurn.passive = true
|
|
4461
|
+
replaceCommandOutputText(
|
|
4462
|
+
output,
|
|
4463
|
+
sessionOwnedElsewhereMessage(commandName, true, loadResult.reason),
|
|
4464
|
+
)
|
|
4465
|
+
return
|
|
4466
|
+
}
|
|
3630
4467
|
|
|
3631
4468
|
if (typeof input.arguments !== "string") {
|
|
3632
4469
|
replaceCommandOutputText(output, "Goal command arguments must be text.")
|
|
@@ -3648,7 +4485,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3648
4485
|
replaceCommandOutputText(
|
|
3649
4486
|
output,
|
|
3650
4487
|
goal
|
|
3651
|
-
? formatStatus(goal, commandName)
|
|
4488
|
+
? formatStatus(goal, commandName, completionAuditLabel)
|
|
3652
4489
|
: lastResult
|
|
3653
4490
|
? formatGoalResult(lastResult)
|
|
3654
4491
|
: `No active goal. Set one with \`/${commandName} <condition>\`.`,
|
|
@@ -3689,15 +4526,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3689
4526
|
// sessionGoals.delete clears ALL backgrounded goals so they do not
|
|
3690
4527
|
// resurrect as the focused goal on restart (cleanupGoal only removes the
|
|
3691
4528
|
// focused one; background goals from `/goal add` would survive otherwise).
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
4529
|
+
const goals = listSessionGoals(sessionID)
|
|
4530
|
+
const clearedGoal = goalStates.get(sessionID) || goals[0] || null
|
|
4531
|
+
const hadState = goals.length > 0 || lastGoalResults.has(sessionID)
|
|
4532
|
+
const ledgerDurable =
|
|
4533
|
+
goals.length > 0 &&
|
|
4534
|
+
goals.map((goal) => pushHistory(goal, "cleared", "User cleared the goal.")).every(Boolean)
|
|
3695
4535
|
sessionOrdered.delete(sessionID)
|
|
3696
4536
|
sessionGoals.delete(sessionID)
|
|
3697
4537
|
cleanupGoal(sessionID)
|
|
3698
4538
|
lastGoalResults.delete(sessionID)
|
|
3699
|
-
await
|
|
3700
|
-
|
|
4539
|
+
const durable = await persistTerminalState(sessionID, "clear", ledgerDurable)
|
|
4540
|
+
const clearStillCurrent = !goalStates.has(sessionID) && listSessionGoals(sessionID).length === 0
|
|
4541
|
+
if (hadState && clearStillCurrent) {
|
|
4542
|
+
announceLifecycle(sessionID, durable === false
|
|
4543
|
+
? "Goal cleared in memory, but storage failed; it may reappear after restart."
|
|
4544
|
+
: "Goal cleared.", {
|
|
4545
|
+
goal: clearedGoal,
|
|
4546
|
+
transition: durable === false ? "clear-persistence-failed" : "cleared",
|
|
4547
|
+
requireCurrent: false,
|
|
4548
|
+
})
|
|
4549
|
+
}
|
|
4550
|
+
replaceCommandOutputText(
|
|
4551
|
+
output,
|
|
4552
|
+
!clearStillCurrent
|
|
4553
|
+
? "Clear persistence finished after goal state changed; current state was left untouched."
|
|
4554
|
+
: durable === false
|
|
4555
|
+
? "Goal cleared in memory, but terminal state could not be persisted. It may reappear after restart."
|
|
4556
|
+
: "Goal cleared.",
|
|
4557
|
+
)
|
|
3701
4558
|
return
|
|
3702
4559
|
}
|
|
3703
4560
|
|
|
@@ -3707,6 +4564,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3707
4564
|
replaceCommandOutputText(output, `No active goal. Set one with \`/${commandName} <condition>\`.`)
|
|
3708
4565
|
return
|
|
3709
4566
|
}
|
|
4567
|
+
if (goal.stopped && goal.stopReason === "paused") {
|
|
4568
|
+
replaceCommandOutputText(output, "Goal is already paused.")
|
|
4569
|
+
return
|
|
4570
|
+
}
|
|
3710
4571
|
currentRuntime().continuationControllers.get(sessionID)?.abort()
|
|
3711
4572
|
goal.stopped = true
|
|
3712
4573
|
goal.stopReason = "paused"
|
|
@@ -3715,6 +4576,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3715
4576
|
activeContinues.delete(sessionID)
|
|
3716
4577
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
3717
4578
|
await persist(sessionID)
|
|
4579
|
+
announceLifecycle(sessionID, "Goal paused.", {
|
|
4580
|
+
goal,
|
|
4581
|
+
transition: "paused",
|
|
4582
|
+
reason: goal.stopReason,
|
|
4583
|
+
expectedState: "paused",
|
|
4584
|
+
expectedStopReason: "paused",
|
|
4585
|
+
})
|
|
3718
4586
|
await abortAcceptedContinuation(sessionID)
|
|
3719
4587
|
replaceCommandOutputText(output, `Goal paused: ${goal.condition}`)
|
|
3720
4588
|
return
|
|
@@ -3741,6 +4609,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3741
4609
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
3742
4610
|
pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
|
|
3743
4611
|
await persist(sessionID)
|
|
4612
|
+
announceLifecycle(sessionID, "Goal resumed with fresh limits.", {
|
|
4613
|
+
goal,
|
|
4614
|
+
transition: "resumed",
|
|
4615
|
+
expectedState: "active",
|
|
4616
|
+
})
|
|
3744
4617
|
replaceCommandOutputText(output, `Goal resumed with fresh limits: ${goal.condition}`, {
|
|
3745
4618
|
startsWork: true,
|
|
3746
4619
|
})
|
|
@@ -3788,6 +4661,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3788
4661
|
goal.lastStatus = "Goal objective updated."
|
|
3789
4662
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
3790
4663
|
await persist(sessionID)
|
|
4664
|
+
announceLifecycle(sessionID, "Goal updated and active.", {
|
|
4665
|
+
goal,
|
|
4666
|
+
transition: "updated-active",
|
|
4667
|
+
expectedState: "active",
|
|
4668
|
+
})
|
|
3791
4669
|
replaceCommandOutputText(
|
|
3792
4670
|
output,
|
|
3793
4671
|
[
|
|
@@ -3868,6 +4746,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3868
4746
|
focusGoal(sessionID, firstGoal)
|
|
3869
4747
|
sessionOrdered.add(sessionID)
|
|
3870
4748
|
await persist(sessionID)
|
|
4749
|
+
announceLifecycle(sessionID, `Ordered goal sequence active (${objectives.length} goals).`, {
|
|
4750
|
+
goal: firstGoal,
|
|
4751
|
+
transition: "sequence-active",
|
|
4752
|
+
reason: String(objectives.length),
|
|
4753
|
+
expectedState: "active",
|
|
4754
|
+
})
|
|
3871
4755
|
replaceCommandOutputText(
|
|
3872
4756
|
output,
|
|
3873
4757
|
[
|
|
@@ -3933,6 +4817,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3933
4817
|
pushHistory(target, "focused", "Brought into focus as the session's active goal.")
|
|
3934
4818
|
focusGoal(sessionID, target)
|
|
3935
4819
|
await persist(sessionID)
|
|
4820
|
+
announceLifecycle(sessionID, "Goal focus changed; selected goal active.", {
|
|
4821
|
+
goal: target,
|
|
4822
|
+
transition: "focused-active",
|
|
4823
|
+
expectedState: "active",
|
|
4824
|
+
})
|
|
3936
4825
|
replaceCommandOutputText(
|
|
3937
4826
|
output,
|
|
3938
4827
|
[
|
|
@@ -3991,6 +4880,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3991
4880
|
registerSessionGoal(added)
|
|
3992
4881
|
focusGoal(sessionID, added)
|
|
3993
4882
|
await persist(sessionID)
|
|
4883
|
+
announceLifecycle(sessionID, current
|
|
4884
|
+
? "Goal added and active; previous goal backgrounded."
|
|
4885
|
+
: "Goal added and active.", {
|
|
4886
|
+
goal: added,
|
|
4887
|
+
transition: current ? "added-active-backgrounded" : "added-active",
|
|
4888
|
+
expectedState: "active",
|
|
4889
|
+
})
|
|
3994
4890
|
const total = listSessionGoals(sessionID).length
|
|
3995
4891
|
replaceCommandOutputText(
|
|
3996
4892
|
output,
|
|
@@ -4029,6 +4925,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4029
4925
|
registerSessionGoal(goal)
|
|
4030
4926
|
focusGoal(sessionID, goal)
|
|
4031
4927
|
await persist(sessionID)
|
|
4928
|
+
announceLifecycle(sessionID, replacedGoal ? "Goal replaced and active." : "Goal active.", {
|
|
4929
|
+
goal,
|
|
4930
|
+
transition: replacedGoal ? "replaced-active" : "active",
|
|
4931
|
+
expectedState: "active",
|
|
4932
|
+
})
|
|
4032
4933
|
replaceCommandOutputText(
|
|
4033
4934
|
output,
|
|
4034
4935
|
[
|
|
@@ -4061,9 +4962,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4061
4962
|
|
|
4062
4963
|
event: async ({ event }) => {
|
|
4063
4964
|
const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
|
|
4064
|
-
|
|
4965
|
+
const loadResult = eventSessionID
|
|
4966
|
+
? await ensureSessionLoaded(eventSessionID)
|
|
4967
|
+
: ACTIVE_PERSISTENCE_DISABLED
|
|
4968
|
+
if (currentRuntime().disposed || loadResult.kind === "disposed") return
|
|
4969
|
+
const passive = loadResult.kind === "passive"
|
|
4065
4970
|
|
|
4066
|
-
if (event?.type === "session.status") {
|
|
4971
|
+
if (!passive && event?.type === "session.status") {
|
|
4067
4972
|
const sessionID = getSessionID(event)
|
|
4068
4973
|
const status = event?.properties?.status?.type || event?.data?.status?.type
|
|
4069
4974
|
if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status)
|
|
@@ -4071,22 +4976,42 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4071
4976
|
|
|
4072
4977
|
if (event?.type === "session.updated") {
|
|
4073
4978
|
const sessionID = getSessionID(event)
|
|
4074
|
-
|
|
4075
|
-
|
|
4979
|
+
rememberSessionExecutionContext(
|
|
4980
|
+
sessionID,
|
|
4981
|
+
event?.properties?.info || event?.data?.info,
|
|
4982
|
+
)
|
|
4076
4983
|
}
|
|
4077
4984
|
|
|
4078
|
-
if (event?.type === "message.updated") {
|
|
4985
|
+
if (!passive && event?.type === "message.updated") {
|
|
4079
4986
|
const message = messageInfoFromEvent(event)
|
|
4080
4987
|
if (messageRole(message) === "user") {
|
|
4081
|
-
const context = normalizeExecutionContext(message)
|
|
4082
4988
|
const sessionID = messageSessionID(message) || getSessionID(event)
|
|
4083
|
-
|
|
4989
|
+
rememberSessionExecutionContext(sessionID, message)
|
|
4084
4990
|
}
|
|
4085
4991
|
}
|
|
4086
4992
|
|
|
4993
|
+
const updatedMessage = event?.type === "message.updated"
|
|
4994
|
+
? messageInfoFromEvent(event)
|
|
4995
|
+
: null
|
|
4996
|
+
const controlCommandAssistant = updatedMessage
|
|
4997
|
+
? suppressControlCommandAssistant(updatedMessage)
|
|
4998
|
+
: false
|
|
4999
|
+
|
|
4087
5000
|
const terminal = terminalEvent(event)
|
|
4088
5001
|
if (terminal?.sessionID) {
|
|
4089
5002
|
const runtime = currentRuntime()
|
|
5003
|
+
if (controlCommandAssistant) {
|
|
5004
|
+
// A provider error on a plugin-owned control reply belongs to that
|
|
5005
|
+
// read-only command turn, not to whichever goal may be active now.
|
|
5006
|
+
// This is especially important after passive takeover: a delayed
|
|
5007
|
+
// denial reply from the old lease epoch must not pause a newly
|
|
5008
|
+
// resumed goal. Retire only the exact active guard it answers.
|
|
5009
|
+
const activeCommandTurn = runtime.activeCommandTurns.get(terminal.sessionID)
|
|
5010
|
+
if (activeCommandTurn?.messageID === messageParentID(updatedMessage)) {
|
|
5011
|
+
runtime.activeCommandTurns.delete(terminal.sessionID)
|
|
5012
|
+
}
|
|
5013
|
+
return
|
|
5014
|
+
}
|
|
4090
5015
|
const pendingTurns = runtime.pendingCommandTurns.get(terminal.sessionID)
|
|
4091
5016
|
const resolvingCommandTurn = [...(pendingTurns?.values() || [])].reverse().find(
|
|
4092
5017
|
(turn) => turn.preservedFileCount > 0,
|
|
@@ -4110,6 +5035,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4110
5035
|
}
|
|
4111
5036
|
if (!resolvingCommandAttachments) runtime.pendingCommandTurns.delete(terminal.sessionID)
|
|
4112
5037
|
runtime.activeCommandTurns.delete(terminal.sessionID)
|
|
5038
|
+
if (passive) return
|
|
4113
5039
|
await pauseActiveGoal(terminal.sessionID, {
|
|
4114
5040
|
...(resolvingCommandAttachments
|
|
4115
5041
|
? {
|
|
@@ -4126,6 +5052,24 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4126
5052
|
return
|
|
4127
5053
|
}
|
|
4128
5054
|
|
|
5055
|
+
if (event?.type === "message.updated") {
|
|
5056
|
+
if (passive || controlCommandAssistant === "passive") return
|
|
5057
|
+
}
|
|
5058
|
+
|
|
5059
|
+
if (passive) {
|
|
5060
|
+
if (isIdleEvent(event) && eventSessionID) {
|
|
5061
|
+
// A session-scoped idle can be stale or unrelated. Keep the passive
|
|
5062
|
+
// command guard until the latest assistant is proven to answer the
|
|
5063
|
+
// plugin-owned denial turn, matching the active-mode correlation
|
|
5064
|
+
// contract below.
|
|
5065
|
+
await retireCompletedCommandTurnOnIdle(
|
|
5066
|
+
eventSessionID,
|
|
5067
|
+
defaultGoalOptions.maxRecentMessages,
|
|
5068
|
+
)
|
|
5069
|
+
}
|
|
5070
|
+
return
|
|
5071
|
+
}
|
|
5072
|
+
|
|
4129
5073
|
if (event?.type === "session.compacted") {
|
|
4130
5074
|
const sessionID = getSessionID(event)
|
|
4131
5075
|
const goal = goalStates.get(sessionID)
|
|
@@ -4144,25 +5088,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4144
5088
|
if (!currentMessageID) return
|
|
4145
5089
|
const currentSessionID = messageSessionID(message)
|
|
4146
5090
|
const runtime = currentRuntime()
|
|
4147
|
-
const parentOwner = runtime.ownedPluginMessages.get(messageParentID(message))
|
|
4148
|
-
const isControlCommandAssistant =
|
|
4149
|
-
messageRole(message) === "assistant" &&
|
|
4150
|
-
parentOwner?.kind === "command" &&
|
|
4151
|
-
parentOwner?.policy === "control" &&
|
|
4152
|
-
parentOwner?.sessionID === currentSessionID
|
|
4153
|
-
if (isControlCommandAssistant) {
|
|
4154
|
-
// A control command may produce several assistant messages (for
|
|
4155
|
-
// example, a blocked tool-call step followed by a final report), and
|
|
4156
|
-
// another plugin turn may overlap before all message.updated events
|
|
4157
|
-
// arrive. Authenticate each response through its owned parent user
|
|
4158
|
-
// message instead of relying on the session's single latest-command
|
|
4159
|
-
// slot, then suppress it immediately for later idle processing.
|
|
4160
|
-
setBoundedMessageValue(
|
|
4161
|
-
runtime.suppressedCommandAssistants,
|
|
4162
|
-
currentMessageID,
|
|
4163
|
-
currentSessionID,
|
|
4164
|
-
)
|
|
4165
|
-
}
|
|
4166
5091
|
|
|
4167
5092
|
const goal = goalStates.get(currentSessionID)
|
|
4168
5093
|
if (!goal) return
|
|
@@ -4247,37 +5172,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4247
5172
|
// remain suppressed in a bounded map so a later duplicate idle cannot
|
|
4248
5173
|
// reinterpret the same report as goal progress or completion.
|
|
4249
5174
|
const runtime = currentRuntime()
|
|
4250
|
-
const
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
commandMessages = Array.isArray(commandHostMessages)
|
|
4260
|
-
? commandHostMessages.slice(-commandMessageLimit)
|
|
4261
|
-
: []
|
|
4262
|
-
const commandAssistant = findLatestAssistantMessage(commandMessages)
|
|
4263
|
-
if (
|
|
4264
|
-
!commandAssistant ||
|
|
4265
|
-
messageParentID(commandAssistant) !== activeCommandTurn.messageID
|
|
4266
|
-
) {
|
|
4267
|
-
return
|
|
4268
|
-
}
|
|
4269
|
-
if (activeCommandTurn.policy === "control") {
|
|
4270
|
-
const commandAssistantID = messageID(commandAssistant)
|
|
4271
|
-
if (commandAssistantID) {
|
|
4272
|
-
setBoundedMessageValue(
|
|
4273
|
-
runtime.suppressedCommandAssistants,
|
|
4274
|
-
commandAssistantID,
|
|
4275
|
-
sessionID,
|
|
4276
|
-
)
|
|
4277
|
-
}
|
|
4278
|
-
}
|
|
4279
|
-
runtime.activeCommandTurns.delete(sessionID)
|
|
4280
|
-
}
|
|
5175
|
+
const commandMessageLimit =
|
|
5176
|
+
goalStates.get(sessionID)?.options.maxRecentMessages ||
|
|
5177
|
+
defaultGoalOptions.maxRecentMessages
|
|
5178
|
+
const commandTurnState = await retireCompletedCommandTurnOnIdle(
|
|
5179
|
+
sessionID,
|
|
5180
|
+
commandMessageLimit,
|
|
5181
|
+
)
|
|
5182
|
+
if (!commandTurnState.ready) return
|
|
5183
|
+
const commandMessages = commandTurnState.messages
|
|
4281
5184
|
|
|
4282
5185
|
const goal = goalStates.get(sessionID)
|
|
4283
5186
|
if (!goal || goal.stopped || activeContinues.has(sessionID)) return
|
|
@@ -4394,7 +5297,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4394
5297
|
auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
4395
5298
|
pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
4396
5299
|
await persist(sessionID)
|
|
4397
|
-
|
|
5300
|
+
const rejectedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
|
|
5301
|
+
if (
|
|
5302
|
+
rejectedGoalAfterPersist !== auditedGoal ||
|
|
5303
|
+
!auditedGoal.stopped ||
|
|
5304
|
+
auditedGoal.stopReason !== "audit rejected"
|
|
5305
|
+
) return
|
|
5306
|
+
if (auditMessagesEnabled) {
|
|
5307
|
+
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
5308
|
+
} else {
|
|
5309
|
+
announceLifecycle(sessionID, "Goal paused — completion audit rejected. Run status for details.", {
|
|
5310
|
+
goal: auditedGoal,
|
|
5311
|
+
transition: "audit-rejected",
|
|
5312
|
+
reason,
|
|
5313
|
+
expectedState: "paused",
|
|
5314
|
+
expectedStopReason: "audit rejected",
|
|
5315
|
+
})
|
|
5316
|
+
}
|
|
4398
5317
|
return
|
|
4399
5318
|
}
|
|
4400
5319
|
pushHistory(
|
|
@@ -4414,23 +5333,80 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4414
5333
|
`Assistant marked the goal complete with evidence: ${summarizeText(evidence, 400)}`,
|
|
4415
5334
|
)
|
|
4416
5335
|
const ordered = sessionOrdered.has(sessionID)
|
|
4417
|
-
|
|
5336
|
+
const completedResult = rememberGoalResult(
|
|
5337
|
+
sessionID,
|
|
5338
|
+
activeGoalAfterMessages,
|
|
5339
|
+
"achieved",
|
|
5340
|
+
"",
|
|
5341
|
+
evidence,
|
|
5342
|
+
)
|
|
4418
5343
|
cleanupGoal(sessionID)
|
|
4419
5344
|
// Ordered sequence: auto-promote the next goal so the
|
|
4420
5345
|
// session keeps working through the sequence without manual /goal focus.
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
}
|
|
5346
|
+
const promoted = ordered ? promoteNextOrderedGoal(sessionID) : null
|
|
5347
|
+
const postCompletionSnapshot = captureFocusedGoalSnapshot(sessionID)
|
|
4424
5348
|
const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
|
|
4425
5349
|
if (durable === false) {
|
|
4426
|
-
|
|
4427
|
-
await announceAudit(
|
|
5350
|
+
const restored = restoreAfterTerminalPersistenceFailure(
|
|
4428
5351
|
sessionID,
|
|
4429
|
-
|
|
5352
|
+
activeGoalAfterMessages,
|
|
5353
|
+
{
|
|
5354
|
+
ordered,
|
|
5355
|
+
expectedCurrentSnapshot: postCompletionSnapshot,
|
|
5356
|
+
expectedResult: completedResult,
|
|
5357
|
+
},
|
|
4430
5358
|
)
|
|
5359
|
+
if (auditMessagesEnabled) {
|
|
5360
|
+
await announceAudit(
|
|
5361
|
+
sessionID,
|
|
5362
|
+
restored
|
|
5363
|
+
? "Audit result: completion verified, but storage failed; goal remains paused and was not archived."
|
|
5364
|
+
: "Audit result: completion verified, but its terminal write failed after goal state changed; current state was left untouched.",
|
|
5365
|
+
)
|
|
5366
|
+
} else {
|
|
5367
|
+
announceLifecycle(
|
|
5368
|
+
sessionID,
|
|
5369
|
+
restored
|
|
5370
|
+
? "Goal paused — completion could not be recorded durably."
|
|
5371
|
+
: "Previous goal completion could not be confirmed durably after goal state changed.",
|
|
5372
|
+
restored
|
|
5373
|
+
? {
|
|
5374
|
+
goal: activeGoalAfterMessages,
|
|
5375
|
+
transition: "terminal-persistence-failed",
|
|
5376
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5377
|
+
expectedState: "paused",
|
|
5378
|
+
expectedStopReason: "terminal persistence failed",
|
|
5379
|
+
}
|
|
5380
|
+
: {
|
|
5381
|
+
transition: "terminal-persistence-raced",
|
|
5382
|
+
requireCurrent: false,
|
|
5383
|
+
},
|
|
5384
|
+
)
|
|
5385
|
+
}
|
|
4431
5386
|
return
|
|
4432
5387
|
}
|
|
4433
|
-
|
|
5388
|
+
const activePromoted = promoted
|
|
5389
|
+
? activeGoal(sessionID, promoted.goalId, promoted.runId)
|
|
5390
|
+
: null
|
|
5391
|
+
if (auditMessagesEnabled) {
|
|
5392
|
+
await announceAudit(
|
|
5393
|
+
sessionID,
|
|
5394
|
+
activePromoted
|
|
5395
|
+
? "Audit result: completion accepted — goal archived as achieved; next ordered goal active."
|
|
5396
|
+
: "Audit result: completion accepted — goal archived as achieved.",
|
|
5397
|
+
)
|
|
5398
|
+
} else {
|
|
5399
|
+
announceLifecycle(
|
|
5400
|
+
sessionID,
|
|
5401
|
+
activePromoted ? "Goal achieved; next ordered goal active." : "Goal achieved.",
|
|
5402
|
+
{
|
|
5403
|
+
goal: activePromoted || activeGoalAfterMessages,
|
|
5404
|
+
transition: activePromoted ? "achieved-promoted" : "achieved",
|
|
5405
|
+
requireCurrent: Boolean(activePromoted),
|
|
5406
|
+
expectedState: activePromoted ? "active" : "",
|
|
5407
|
+
},
|
|
5408
|
+
)
|
|
5409
|
+
}
|
|
4434
5410
|
return
|
|
4435
5411
|
}
|
|
4436
5412
|
completionUnverified = true
|
|
@@ -4456,16 +5432,41 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4456
5432
|
blockedGoal.stopReason = "blocked"
|
|
4457
5433
|
const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
|
|
4458
5434
|
const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
|
|
5435
|
+
const blockedGoalAfterPersist = currentGoal(sessionID, goalID, runID)
|
|
5436
|
+
if (
|
|
5437
|
+
blockedGoalAfterPersist !== blockedGoal ||
|
|
5438
|
+
!blockedGoal.stopped ||
|
|
5439
|
+
blockedGoal.stopReason !== "blocked"
|
|
5440
|
+
) return
|
|
4459
5441
|
if (durable === false) {
|
|
4460
5442
|
blockedGoal.stopReason = "terminal persistence failed"
|
|
4461
5443
|
blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
4462
|
-
|
|
5444
|
+
if (auditMessagesEnabled) {
|
|
5445
|
+
await announceAudit(sessionID, "Audit result: blocker recognized, but storage failed; goal remains paused.")
|
|
5446
|
+
} else {
|
|
5447
|
+
announceLifecycle(sessionID, "Goal paused — blocked state could not be recorded durably.", {
|
|
5448
|
+
goal: blockedGoal,
|
|
5449
|
+
transition: "terminal-persistence-failed",
|
|
5450
|
+
reason: blockedGoal.stopReason,
|
|
5451
|
+
expectedState: "paused",
|
|
5452
|
+
expectedStopReason: "terminal persistence failed",
|
|
5453
|
+
})
|
|
5454
|
+
}
|
|
4463
5455
|
return
|
|
4464
5456
|
}
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
5457
|
+
if (auditMessagesEnabled) {
|
|
5458
|
+
await announceAudit(
|
|
5459
|
+
sessionID,
|
|
5460
|
+
`Audit result: goal paused as blocked — ${summarizeText(reason, 160)}. Run /${commandName} resume after addressing it.`,
|
|
5461
|
+
)
|
|
5462
|
+
} else {
|
|
5463
|
+
announceLifecycle(sessionID, `Goal blocked. Run /${commandName} status for the reason.`, {
|
|
5464
|
+
goal: blockedGoal,
|
|
5465
|
+
transition: "blocked",
|
|
5466
|
+
expectedState: "blocked",
|
|
5467
|
+
expectedStopReason: "blocked",
|
|
5468
|
+
})
|
|
5469
|
+
}
|
|
4469
5470
|
return
|
|
4470
5471
|
}
|
|
4471
5472
|
blockerUnstated = true
|
|
@@ -4480,6 +5481,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4480
5481
|
|
|
4481
5482
|
const limitReason = stopReason(activeGoalAfterMessages)
|
|
4482
5483
|
if (limitReason) {
|
|
5484
|
+
let lifecycleAnnounced = false
|
|
4483
5485
|
if (!activeGoalAfterMessages.budgetWrapupSent) {
|
|
4484
5486
|
const claimedGoal = await claimContinuationSource(
|
|
4485
5487
|
sessionID,
|
|
@@ -4496,6 +5498,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4496
5498
|
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
4497
5499
|
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
4498
5500
|
await persist(sessionID)
|
|
5501
|
+
lifecycleAnnounced = announceLifecycle(
|
|
5502
|
+
sessionID,
|
|
5503
|
+
`Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`,
|
|
5504
|
+
{
|
|
5505
|
+
goal: claimedGoal,
|
|
5506
|
+
transition: "limit-paused",
|
|
5507
|
+
reason: limitReason,
|
|
5508
|
+
expectedState: "paused",
|
|
5509
|
+
expectedStopReason: limitReason,
|
|
5510
|
+
},
|
|
5511
|
+
)
|
|
4499
5512
|
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
4500
5513
|
let response
|
|
4501
5514
|
try {
|
|
@@ -4522,6 +5535,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4522
5535
|
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
4523
5536
|
}
|
|
4524
5537
|
await persist(sessionID)
|
|
5538
|
+
if (!lifecycleAnnounced) {
|
|
5539
|
+
announceLifecycle(sessionID, `Goal paused — ${summarizeText(limitReason, 160)}; final handoff requested.`, {
|
|
5540
|
+
goal: activeGoalAfterMessages,
|
|
5541
|
+
transition: "limit-paused",
|
|
5542
|
+
reason: limitReason,
|
|
5543
|
+
expectedState: "paused",
|
|
5544
|
+
expectedStopReason: limitReason,
|
|
5545
|
+
})
|
|
5546
|
+
}
|
|
4525
5547
|
return
|
|
4526
5548
|
}
|
|
4527
5549
|
|
|
@@ -4577,6 +5599,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4577
5599
|
`Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
|
|
4578
5600
|
)
|
|
4579
5601
|
await persist(sessionID)
|
|
5602
|
+
announceLifecycle(sessionID, "Goal paused — no progress threshold reached.", {
|
|
5603
|
+
goal: activeGoalAfterMessages,
|
|
5604
|
+
transition: "no-progress-paused",
|
|
5605
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5606
|
+
expectedState: "paused",
|
|
5607
|
+
expectedStopReason: "no progress",
|
|
5608
|
+
})
|
|
4580
5609
|
return
|
|
4581
5610
|
}
|
|
4582
5611
|
|
|
@@ -4622,6 +5651,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4622
5651
|
`Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
|
|
4623
5652
|
)
|
|
4624
5653
|
await persist(sessionID)
|
|
5654
|
+
announceLifecycle(sessionID, "Goal paused — no-tool-call threshold reached.", {
|
|
5655
|
+
goal: activeGoalAfterMessages,
|
|
5656
|
+
transition: "no-tool-calls-paused",
|
|
5657
|
+
reason: activeGoalAfterMessages.stopReason,
|
|
5658
|
+
expectedState: "paused",
|
|
5659
|
+
expectedStopReason: "no tool calls",
|
|
5660
|
+
})
|
|
4625
5661
|
return
|
|
4626
5662
|
}
|
|
4627
5663
|
|
|
@@ -4671,6 +5707,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4671
5707
|
// the hard-limit path which also persists before its promptAsync call.
|
|
4672
5708
|
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
4673
5709
|
await persist(sessionID)
|
|
5710
|
+
announceLifecycle(sessionID, "Goal paused — budget threshold reached; final handoff requested.", {
|
|
5711
|
+
goal: activeGoalBeforePrompt,
|
|
5712
|
+
transition: "budget-wrapup-paused",
|
|
5713
|
+
reason: activeGoalBeforePrompt.stopReason,
|
|
5714
|
+
expectedState: "paused",
|
|
5715
|
+
expectedStopReason: "budget wrap-up requested",
|
|
5716
|
+
})
|
|
4674
5717
|
}
|
|
4675
5718
|
|
|
4676
5719
|
activeGoalBeforePrompt.turnCount += 1
|
|
@@ -4711,6 +5754,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4711
5754
|
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
4712
5755
|
)
|
|
4713
5756
|
await persist(sessionID)
|
|
5757
|
+
announceLifecycle(sessionID, "Goal paused — repeated completion/blocker format failures.", {
|
|
5758
|
+
goal: activeGoalBeforePrompt,
|
|
5759
|
+
transition: "format-failures-paused",
|
|
5760
|
+
reason: activeGoalBeforePrompt.stopReason,
|
|
5761
|
+
expectedState: "paused",
|
|
5762
|
+
expectedStopReason: "format validation failures",
|
|
5763
|
+
})
|
|
4714
5764
|
return
|
|
4715
5765
|
}
|
|
4716
5766
|
}
|
|
@@ -4735,6 +5785,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4735
5785
|
currentRuntime().promptInFlightSessions.delete(sessionID)
|
|
4736
5786
|
}
|
|
4737
5787
|
|
|
5788
|
+
let promptFailurePausedGoal = null
|
|
4738
5789
|
if (response.error) {
|
|
4739
5790
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID)
|
|
4740
5791
|
const message = `Auto-continue failed: ${response.error.name || "unknown error"}`
|
|
@@ -4750,6 +5801,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4750
5801
|
activeGoalAfterPrompt.stopped = true
|
|
4751
5802
|
activeGoalAfterPrompt.stopReason = "auto-continue failures"
|
|
4752
5803
|
activeGoalAfterPrompt.lastStatus = `${message}; paused after ${activeGoalAfterPrompt.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
5804
|
+
promptFailurePausedGoal = activeGoalAfterPrompt
|
|
4753
5805
|
}
|
|
4754
5806
|
}
|
|
4755
5807
|
await logPluginError(client, message, response.error)
|
|
@@ -4773,6 +5825,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4773
5825
|
}
|
|
4774
5826
|
}
|
|
4775
5827
|
await persist(sessionID)
|
|
5828
|
+
if (promptFailurePausedGoal) {
|
|
5829
|
+
announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
|
|
5830
|
+
goal: promptFailurePausedGoal,
|
|
5831
|
+
transition: "prompt-failures-paused",
|
|
5832
|
+
reason: promptFailurePausedGoal.stopReason,
|
|
5833
|
+
expectedState: "paused",
|
|
5834
|
+
expectedStopReason: "auto-continue failures",
|
|
5835
|
+
})
|
|
5836
|
+
}
|
|
4776
5837
|
} catch (error) {
|
|
4777
5838
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
4778
5839
|
if (activeGoalAfterError) {
|
|
@@ -4793,6 +5854,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4793
5854
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
4794
5855
|
}
|
|
4795
5856
|
await persist(sessionID)
|
|
5857
|
+
if (activeGoalAfterError.stopped && activeGoalAfterError.stopReason === "auto-continue failures") {
|
|
5858
|
+
announceLifecycle(sessionID, "Goal paused — repeated auto-continue failures.", {
|
|
5859
|
+
goal: activeGoalAfterError,
|
|
5860
|
+
transition: "prompt-failures-paused",
|
|
5861
|
+
reason: activeGoalAfterError.stopReason,
|
|
5862
|
+
expectedState: "paused",
|
|
5863
|
+
expectedStopReason: "auto-continue failures",
|
|
5864
|
+
})
|
|
5865
|
+
}
|
|
4796
5866
|
}
|
|
4797
5867
|
await logPluginError(client, "Auto-continue failed", error)
|
|
4798
5868
|
} finally {
|
|
@@ -4809,11 +5879,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4809
5879
|
|
|
4810
5880
|
"experimental.chat.system.transform": async (input, output) => {
|
|
4811
5881
|
if (!input.sessionID) return
|
|
4812
|
-
await ensureSessionLoaded(input.sessionID)
|
|
5882
|
+
const loadResult = await ensureSessionLoaded(input.sessionID)
|
|
5883
|
+
if (currentRuntime().disposed || loadResult.kind === "disposed") return
|
|
4813
5884
|
|
|
4814
5885
|
const activeCommandTurn = currentRuntime().activeCommandTurns.get(input.sessionID)
|
|
4815
5886
|
const commandGuarded = activeCommandTurn?.policy === "control"
|
|
4816
|
-
const goal = goalStates.get(input.sessionID)
|
|
5887
|
+
const goal = loadResult.kind === "active" ? goalStates.get(input.sessionID) : null
|
|
4817
5888
|
if (!goal && !commandGuarded) return
|
|
4818
5889
|
const blockID = goal?.goalId || `command-${activeCommandTurn.id}`
|
|
4819
5890
|
const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
|
|
@@ -4870,7 +5941,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4870
5941
|
|
|
4871
5942
|
"experimental.session.compacting": async (input, output) => {
|
|
4872
5943
|
if (!input?.sessionID || !output) return
|
|
4873
|
-
await ensureSessionLoaded(input.sessionID)
|
|
5944
|
+
const loadResult = await ensureSessionLoaded(input.sessionID)
|
|
5945
|
+
if (currentRuntime().disposed || loadResult.kind !== "active") return
|
|
4874
5946
|
const goal = goalStates.get(input.sessionID)
|
|
4875
5947
|
if (!goal) return
|
|
4876
5948
|
const context = buildCompactionContext(goal)
|
|
@@ -4890,7 +5962,8 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4890
5962
|
// auto-continue to avoid two continuations racing after a compaction.
|
|
4891
5963
|
// Paused/stopped goals leave the native behavior untouched.
|
|
4892
5964
|
if (!input?.sessionID || !output) return
|
|
4893
|
-
await ensureSessionLoaded(input.sessionID)
|
|
5965
|
+
const loadResult = await ensureSessionLoaded(input.sessionID)
|
|
5966
|
+
if (currentRuntime().disposed || loadResult.kind !== "active") return
|
|
4894
5967
|
const goal = goalStates.get(input.sessionID)
|
|
4895
5968
|
if (!goal || goal.stopped) return
|
|
4896
5969
|
output.enabled = false
|
|
@@ -4907,7 +5980,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4907
5980
|
// makes this deterministic for normal npm installs; `registerTools: false`
|
|
4908
5981
|
// remains the explicit opt-out.
|
|
4909
5982
|
if (pluginOptions.registerTools !== false) {
|
|
4910
|
-
hooks.tool = buildAgentTools(
|
|
5983
|
+
hooks.tool = buildAgentTools(
|
|
5984
|
+
bundledToolHelper,
|
|
5985
|
+
agentToolHandlers,
|
|
5986
|
+
ensureSessionLoaded,
|
|
5987
|
+
commandName,
|
|
5988
|
+
() => runtime.disposed,
|
|
5989
|
+
registerCommand,
|
|
5990
|
+
)
|
|
4911
5991
|
}
|
|
4912
5992
|
|
|
4913
5993
|
return hooks
|
|
@@ -4987,6 +6067,7 @@ export default {
|
|
|
4987
6067
|
}
|
|
4988
6068
|
|
|
4989
6069
|
export const testInternals = {
|
|
6070
|
+
commandTurnTtlMs: COMMAND_TURN_TTL_MS,
|
|
4990
6071
|
activeGoal,
|
|
4991
6072
|
agentToolSessionID,
|
|
4992
6073
|
buildAgentToolHandlers,
|
|
@@ -5000,6 +6081,7 @@ export const testInternals = {
|
|
|
5000
6081
|
ledgerPathFor,
|
|
5001
6082
|
setLedgerSink,
|
|
5002
6083
|
defaultAuditMessenger,
|
|
6084
|
+
defaultLifecycleMessenger,
|
|
5003
6085
|
buildAuditPrompt,
|
|
5004
6086
|
parseAuditVerdict,
|
|
5005
6087
|
createChildSessionAuditor,
|
|
@@ -5018,6 +6100,7 @@ export const testInternals = {
|
|
|
5018
6100
|
extractCompletionEvidence,
|
|
5019
6101
|
findLatestAssistantMessage,
|
|
5020
6102
|
formatArgumentErrors,
|
|
6103
|
+
goalDisplayState,
|
|
5021
6104
|
formatStatus,
|
|
5022
6105
|
getSessionID,
|
|
5023
6106
|
goalIsBlocked,
|
|
@@ -5042,6 +6125,7 @@ export const testInternals = {
|
|
|
5042
6125
|
parseTokenBudget,
|
|
5043
6126
|
pruneGoalResults,
|
|
5044
6127
|
resolveStateFilePath,
|
|
6128
|
+
runtimeSessionDiagnostics,
|
|
5045
6129
|
stopReason,
|
|
5046
6130
|
xdgStateFilePath,
|
|
5047
6131
|
}
|