opencode-goal-plugin 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +17 -16
- package/docs/compatibility.md +2 -0
- package/index.d.ts +9 -7
- package/package.json +1 -1
- package/src/goal-plugin.js +460 -253
- package/src/persistence-lease.js +3 -3
package/src/goal-plugin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto"
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
2
|
import { AsyncLocalStorage } from "node:async_hooks"
|
|
3
3
|
import {
|
|
4
4
|
promises as fs,
|
|
@@ -51,6 +51,8 @@ const MAX_TRACKED_MESSAGE_IDS = 20_000
|
|
|
51
51
|
const DEFAULT_LEDGER_MAX_BYTES = 2 * 1024 * 1024
|
|
52
52
|
const DEFAULT_LEDGER_RETENTION_FILES = 3
|
|
53
53
|
const MAX_LEDGER_LINE_BYTES = 16 * 1024
|
|
54
|
+
const MIGRATION_LEASE_RETRIES = 200
|
|
55
|
+
const MIGRATION_LEASE_DELAY_MS = 25
|
|
54
56
|
|
|
55
57
|
const DEFAULT_OPTIONS = {
|
|
56
58
|
maxTurns: 10,
|
|
@@ -74,7 +76,7 @@ const DEFAULT_OPTIONS = {
|
|
|
74
76
|
// handler drives and that the system-prompt transform injects. `sessionGoals`
|
|
75
77
|
// is the full registry of live goals per session (focused + backgrounded);
|
|
76
78
|
// the focused goal is the same object reference held in both. `sessionArchive`
|
|
77
|
-
// keeps a capped list of
|
|
79
|
+
// keeps a capped list of achieved goals so completed work stays readable.
|
|
78
80
|
function createRuntimeState() {
|
|
79
81
|
return {
|
|
80
82
|
goalStates: new Map(),
|
|
@@ -93,9 +95,8 @@ function createRuntimeState() {
|
|
|
93
95
|
sessionExecutionContexts: new Map(),
|
|
94
96
|
readOnlyCommandGuards: new Set(),
|
|
95
97
|
ledgerSink: null,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
drainPersistence: null,
|
|
98
|
+
sessionPersistence: new Map(),
|
|
99
|
+
sessionLoadPromises: new Map(),
|
|
99
100
|
disposed: false,
|
|
100
101
|
}
|
|
101
102
|
}
|
|
@@ -129,7 +130,7 @@ function runtimeCollection(name) {
|
|
|
129
130
|
const goalStates = runtimeCollection("goalStates")
|
|
130
131
|
const sessionGoals = runtimeCollection("sessionGoals")
|
|
131
132
|
const sessionArchive = runtimeCollection("sessionArchive")
|
|
132
|
-
// Sessions running an ordered
|
|
133
|
+
// Sessions running an ordered sequence: when the focused goal
|
|
133
134
|
// completes, the next live goal (in creation order) is auto-promoted to focus
|
|
134
135
|
// so the sequence advances on its own.
|
|
135
136
|
const sessionOrdered = runtimeCollection("sessionOrdered")
|
|
@@ -147,6 +148,9 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
|
147
148
|
const activeContinues = runtimeCollection("activeContinues")
|
|
148
149
|
const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
|
|
149
150
|
const PAUSE_COMMANDS = new Set(["pause"])
|
|
151
|
+
// `sequence` is canonical. The former public spelling remains accepted at
|
|
152
|
+
// the parser boundary so existing scripts do not break.
|
|
153
|
+
const SEQUENCE_COMMANDS = ["sequence", "sisyphus"]
|
|
150
154
|
const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
|
|
151
155
|
const GOAL_FLAG_SPECS = {
|
|
152
156
|
"--max-turns": {
|
|
@@ -211,8 +215,8 @@ function messageHasToolCall(message) {
|
|
|
211
215
|
|
|
212
216
|
const GOAL_MODES = new Set(["normal", "ordered"])
|
|
213
217
|
|
|
214
|
-
// Goal mode: normal vs ordered
|
|
215
|
-
//
|
|
218
|
+
// Goal mode: normal vs ordered. The former public spelling remains accepted
|
|
219
|
+
// as an input alias, while stored state and output always use `ordered`.
|
|
216
220
|
// Returns the canonical mode or null when unrecognized.
|
|
217
221
|
function normalizeMode(value) {
|
|
218
222
|
const normalized = String(value || "").trim().toLowerCase()
|
|
@@ -681,12 +685,6 @@ function listSessionGoals(sessionID) {
|
|
|
681
685
|
return map ? [...map.values()] : []
|
|
682
686
|
}
|
|
683
687
|
|
|
684
|
-
function totalLiveGoals() {
|
|
685
|
-
let total = 0
|
|
686
|
-
for (const goals of sessionGoals.values()) total += goals.size
|
|
687
|
-
return total
|
|
688
|
-
}
|
|
689
|
-
|
|
690
688
|
function rememberMessageID(goal, messageID) {
|
|
691
689
|
goal.messageIDs.add(messageID)
|
|
692
690
|
while (goal.messageIDs.size > MAX_MESSAGE_IDS_PER_GOAL) {
|
|
@@ -727,7 +725,7 @@ function archiveSessionResult(sessionID, result) {
|
|
|
727
725
|
sessionArchive.set(sessionID, list.slice(-MAX_ARCHIVED_PER_SESSION))
|
|
728
726
|
}
|
|
729
727
|
|
|
730
|
-
// Advance an ordered
|
|
728
|
+
// Advance an ordered sequence: focus the next live goal in creation
|
|
731
729
|
// order, clearing any backgrounded state so the idle handler drives it. Returns
|
|
732
730
|
// the promoted goal, or null when the sequence is exhausted (which also clears
|
|
733
731
|
// the session's ordered flag).
|
|
@@ -743,7 +741,7 @@ function promoteNextOrderedGoal(sessionID) {
|
|
|
743
741
|
resumeGoalClock(next)
|
|
744
742
|
next.skipNextTerminalCheck = true
|
|
745
743
|
next.lastStatus = "Promoted as the next ordered goal."
|
|
746
|
-
pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered
|
|
744
|
+
pushHistory(next, "focused", "Auto-promoted as the next goal in the ordered sequence.")
|
|
747
745
|
focusGoal(sessionID, next)
|
|
748
746
|
return next
|
|
749
747
|
}
|
|
@@ -786,6 +784,29 @@ function clearRuntimeState() {
|
|
|
786
784
|
runtime.readOnlyCommandGuards.clear()
|
|
787
785
|
}
|
|
788
786
|
|
|
787
|
+
function clearSessionRuntimeState(sessionID) {
|
|
788
|
+
const runtime = currentRuntime()
|
|
789
|
+
for (const goal of sessionGoals.get(sessionID)?.values() || []) {
|
|
790
|
+
for (const messageID of goal.messageIDs || []) {
|
|
791
|
+
seenTokens.delete(messageID)
|
|
792
|
+
seenUsage.delete(messageID)
|
|
793
|
+
seenOutputTokens.delete(messageID)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
runtime.continuationControllers.get(sessionID)?.abort()
|
|
797
|
+
goalStates.delete(sessionID)
|
|
798
|
+
sessionGoals.delete(sessionID)
|
|
799
|
+
sessionArchive.delete(sessionID)
|
|
800
|
+
sessionOrdered.delete(sessionID)
|
|
801
|
+
lastGoalResults.delete(sessionID)
|
|
802
|
+
activeContinues.delete(sessionID)
|
|
803
|
+
runtime.continuationControllers.delete(sessionID)
|
|
804
|
+
runtime.promptInFlightSessions.delete(sessionID)
|
|
805
|
+
runtime.sessionStatuses.delete(sessionID)
|
|
806
|
+
runtime.sessionExecutionContexts.delete(sessionID)
|
|
807
|
+
runtime.readOnlyCommandGuards.delete(sessionID)
|
|
808
|
+
}
|
|
809
|
+
|
|
789
810
|
function pruneGoalResults(options) {
|
|
790
811
|
const retentionMs = options?.resultRetentionMs ?? DEFAULT_OPTIONS.resultRetentionMs
|
|
791
812
|
const maxStoredResults = options?.maxStoredResults ?? DEFAULT_OPTIONS.maxStoredResults
|
|
@@ -994,6 +1015,26 @@ function ledgerPathFor(stateFilePath) {
|
|
|
994
1015
|
return `${stateFilePath}.ledger.jsonl`
|
|
995
1016
|
}
|
|
996
1017
|
|
|
1018
|
+
// Persist each OpenCode session in its own directory. Session IDs are hashed so
|
|
1019
|
+
// arbitrary host-provided IDs cannot become path components, and the resulting
|
|
1020
|
+
// paths are portable across POSIX and Windows filesystems.
|
|
1021
|
+
function sessionDirectoryFor(stateFilePath) {
|
|
1022
|
+
return `${stateFilePath}.sessions`
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function sessionKey(sessionID) {
|
|
1026
|
+
return createHash("sha256").update(sessionID).digest("hex")
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function sessionPathsFor(persistenceOptions, sessionID) {
|
|
1030
|
+
const directory = join(persistenceOptions.sessionDirectory, sessionKey(sessionID))
|
|
1031
|
+
const stateFilePath = join(directory, "state.json")
|
|
1032
|
+
return {
|
|
1033
|
+
stateFilePath,
|
|
1034
|
+
ledgerFilePath: ledgerPathFor(stateFilePath),
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
997
1038
|
// XDG-style state path: $XDG_STATE_HOME/opencode-goal-plugin/state.json,
|
|
998
1039
|
// defaulting to ~/.local/state when XDG_STATE_HOME is unset.
|
|
999
1040
|
function xdgStateFilePath(env = process.env) {
|
|
@@ -1044,11 +1085,14 @@ function normalizePersistenceOptions(options = {}, { env = process.env, cwd } =
|
|
|
1044
1085
|
: ledgerPathFor(stateFilePath)
|
|
1045
1086
|
const ledgerMaxBytes = toPositiveInteger(options.ledgerMaxBytes, DEFAULT_LEDGER_MAX_BYTES)
|
|
1046
1087
|
const ledgerRetentionFiles = Number.isSafeInteger(options.ledgerRetentionFiles) && options.ledgerRetentionFiles >= 0
|
|
1047
|
-
|
|
1048
|
-
|
|
1088
|
+
? Math.min(options.ledgerRetentionFiles, 10)
|
|
1089
|
+
: DEFAULT_LEDGER_RETENTION_FILES
|
|
1090
|
+
const sessionDirectory = sessionDirectoryFor(stateFilePath)
|
|
1049
1091
|
return {
|
|
1050
1092
|
persistState,
|
|
1051
1093
|
stateFilePath,
|
|
1094
|
+
sessionDirectory,
|
|
1095
|
+
migrationMarkerPath: join(sessionDirectory, ".migration-v1-complete"),
|
|
1052
1096
|
fallbackPaths,
|
|
1053
1097
|
ledgerFilePath,
|
|
1054
1098
|
ledgerMaxBytes,
|
|
@@ -1280,7 +1324,7 @@ function deserializeGoal(goal) {
|
|
|
1280
1324
|
// Parse one state-file body and apply it to runtime state. Returns "loaded" on
|
|
1281
1325
|
// success or "invalid" when the version/shape is unsupported. Throws on
|
|
1282
1326
|
// JSON.parse failure (handled by the caller).
|
|
1283
|
-
async function applyParsedStateFile(raw, client) {
|
|
1327
|
+
async function applyParsedStateFile(raw, client, onlySessionID = null) {
|
|
1284
1328
|
const parsed = JSON.parse(raw)
|
|
1285
1329
|
if (parsed?.version !== STATE_FILE_VERSION) {
|
|
1286
1330
|
await logPluginError(
|
|
@@ -1300,6 +1344,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1300
1344
|
const loadedGoalCounts = new Map()
|
|
1301
1345
|
for (const rawGoal of parsed.goals.slice(0, MAX_PERSISTED_ENTRIES)) {
|
|
1302
1346
|
const normalizedGoal = normalizePersistedGoal(rawGoal)
|
|
1347
|
+
if (onlySessionID && normalizedGoal?.sessionID !== onlySessionID) continue
|
|
1303
1348
|
const sessionCount = normalizedGoal
|
|
1304
1349
|
? loadedGoalCounts.get(normalizedGoal.sessionID) || 0
|
|
1305
1350
|
: 0
|
|
@@ -1315,6 +1360,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1315
1360
|
let skippedResults = 0
|
|
1316
1361
|
for (const rawResult of parsed.results.slice(-MAX_PERSISTED_ENTRIES)) {
|
|
1317
1362
|
const normalizedResult = normalizePersistedResult(rawResult)
|
|
1363
|
+
if (onlySessionID && normalizedResult?.sessionID !== onlySessionID) continue
|
|
1318
1364
|
if (normalizedResult) {
|
|
1319
1365
|
loadedResults.push(normalizedResult)
|
|
1320
1366
|
} else {
|
|
@@ -1329,7 +1375,8 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1329
1375
|
)
|
|
1330
1376
|
}
|
|
1331
1377
|
|
|
1332
|
-
|
|
1378
|
+
if (onlySessionID) clearSessionRuntimeState(onlySessionID)
|
|
1379
|
+
else clearRuntimeState()
|
|
1333
1380
|
|
|
1334
1381
|
const focusBySession = new Map()
|
|
1335
1382
|
for (const { goal, focused } of loadedGoals) {
|
|
@@ -1342,6 +1389,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1342
1389
|
// Restore focus. Older single-goal state files have no `focused` flag, so
|
|
1343
1390
|
// fall back to focusing a session's first (typically only) goal.
|
|
1344
1391
|
for (const [sessionID, goalMap] of sessionGoals.entries()) {
|
|
1392
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1345
1393
|
const focusTarget = focusBySession.get(sessionID) || goalMap.values().next().value
|
|
1346
1394
|
if (focusTarget) focusGoal(sessionID, focusTarget)
|
|
1347
1395
|
}
|
|
@@ -1353,6 +1401,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1353
1401
|
if (Array.isArray(parsed.archives)) {
|
|
1354
1402
|
for (const entry of parsed.archives.slice(-MAX_PERSISTED_ENTRIES)) {
|
|
1355
1403
|
if (!isPlainObject(entry) || typeof entry.sessionID !== "string" || !entry.sessionID) continue
|
|
1404
|
+
if (onlySessionID && entry.sessionID !== onlySessionID) continue
|
|
1356
1405
|
const results = Array.isArray(entry.results)
|
|
1357
1406
|
? entry.results.map(normalizePersistedResult).filter(Boolean)
|
|
1358
1407
|
: []
|
|
@@ -1364,6 +1413,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1364
1413
|
|
|
1365
1414
|
if (Array.isArray(parsed.orderedSessions)) {
|
|
1366
1415
|
for (const sessionID of parsed.orderedSessions) {
|
|
1416
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1367
1417
|
// Only honor the ordered flag for sessions that still have goals loaded.
|
|
1368
1418
|
if (typeof sessionID === "string" && sessionGoals.has(sessionID)) {
|
|
1369
1419
|
sessionOrdered.add(sessionID)
|
|
@@ -1378,7 +1428,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1378
1428
|
// terminal events. If a goal has a "completed" or "cleared" entry in the ledger
|
|
1379
1429
|
// but still appears active in the state file (because the state write failed
|
|
1380
1430
|
// after the terminal ledger write), remove it so it is not re-driven.
|
|
1381
|
-
async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
1431
|
+
async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1382
1432
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1383
1433
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1384
1434
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
@@ -1392,6 +1442,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
|
1392
1442
|
typeof entry.sessionID === "string" && entry.sessionID &&
|
|
1393
1443
|
typeof entry.goalId === "string" && entry.goalId
|
|
1394
1444
|
) {
|
|
1445
|
+
if (onlySessionID && entry.sessionID !== onlySessionID) continue
|
|
1395
1446
|
terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
|
|
1396
1447
|
}
|
|
1397
1448
|
}
|
|
@@ -1399,6 +1450,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
|
1399
1450
|
|
|
1400
1451
|
let removed = 0
|
|
1401
1452
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1453
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1402
1454
|
for (const goal of [...goals.values()]) {
|
|
1403
1455
|
if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue
|
|
1404
1456
|
removeSessionGoal(sessionID, goal.goalId)
|
|
@@ -1417,114 +1469,250 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
|
1417
1469
|
}
|
|
1418
1470
|
}
|
|
1419
1471
|
|
|
1420
|
-
async function
|
|
1421
|
-
|
|
1472
|
+
async function pathExists(path) {
|
|
1473
|
+
try {
|
|
1474
|
+
await fs.lstat(path)
|
|
1475
|
+
return true
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
if (error?.code === "ENOENT") return false
|
|
1478
|
+
throw error
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1422
1481
|
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
const recoverInvalidPrimary = async () => {
|
|
1428
|
-
const status = await reconstructFromLedger(persistenceOptions, client)
|
|
1429
|
-
if (status !== "reconstructed") return "invalid"
|
|
1430
|
-
const quarantinePath = `${persistenceOptions.stateFilePath}.corrupt.${Date.now()}.${randomUUID()}`
|
|
1482
|
+
async function acquireMigrationLease(stateFilePath, migrationMarkerPath) {
|
|
1483
|
+
let lastError
|
|
1484
|
+
for (let attempt = 0; attempt < MIGRATION_LEASE_RETRIES; attempt += 1) {
|
|
1485
|
+
if (await pathExists(migrationMarkerPath)) return null
|
|
1431
1486
|
try {
|
|
1432
|
-
await
|
|
1487
|
+
return await acquirePersistenceLease(stateFilePath)
|
|
1488
|
+
} catch (error) {
|
|
1489
|
+
if (!String(error?.message || error).includes("goal persistence is already owned")) throw error
|
|
1490
|
+
lastError = error
|
|
1491
|
+
await new Promise((resolve) => setTimeout(resolve, MIGRATION_LEASE_DELAY_MS))
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
throw lastError || new Error("could not acquire goal migration lease")
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
async function readPersistedStateFile(path, client) {
|
|
1498
|
+
let raw
|
|
1499
|
+
try {
|
|
1500
|
+
const info = await fs.lstat(path)
|
|
1501
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_STATE_FILE_BYTES) {
|
|
1433
1502
|
await logPluginError(
|
|
1434
1503
|
client,
|
|
1435
|
-
`
|
|
1504
|
+
`Skipped persisted goal state: file is not regular or exceeds ${MAX_STATE_FILE_BYTES} bytes.`,
|
|
1436
1505
|
)
|
|
1437
|
-
|
|
1438
|
-
await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
|
|
1439
|
-
return "invalid"
|
|
1506
|
+
return { status: "invalid" }
|
|
1440
1507
|
}
|
|
1441
|
-
|
|
1508
|
+
raw = await fs.readFile(path, "utf8")
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
if (error?.code === "ENOENT") return { status: "missing" }
|
|
1511
|
+
await logPluginError(client, "Failed to load persisted goal state", error)
|
|
1512
|
+
return { status: "invalid" }
|
|
1442
1513
|
}
|
|
1443
1514
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
if (!
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
currentRuntime().migrationLease = migrationLease
|
|
1450
|
-
} catch (error) {
|
|
1451
|
-
await logPluginError(client, `Skipped legacy state migration because another process owns ${path}.`, error)
|
|
1452
|
-
continue
|
|
1453
|
-
}
|
|
1515
|
+
try {
|
|
1516
|
+
const parsed = JSON.parse(raw)
|
|
1517
|
+
if (parsed?.version !== STATE_FILE_VERSION || !Array.isArray(parsed.goals) || !Array.isArray(parsed.results)) {
|
|
1518
|
+
await logPluginError(client, `Skipped persisted goal state: unsupported or malformed state at ${path}.`)
|
|
1519
|
+
return { status: "invalid" }
|
|
1454
1520
|
}
|
|
1455
|
-
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
await logPluginError(client, "Failed to parse persisted goal state", error)
|
|
1523
|
+
return { status: "invalid" }
|
|
1524
|
+
}
|
|
1525
|
+
return { status: "loaded", raw }
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function migrationCandidates(persistenceOptions) {
|
|
1529
|
+
return [
|
|
1530
|
+
{
|
|
1531
|
+
stateFilePath: persistenceOptions.stateFilePath,
|
|
1532
|
+
ledgerFilePath: persistenceOptions.ledgerFilePath,
|
|
1533
|
+
},
|
|
1534
|
+
...(persistenceOptions.fallbackPaths || []).map((stateFilePath) => ({
|
|
1535
|
+
stateFilePath,
|
|
1536
|
+
ledgerFilePath: ledgerPathFor(stateFilePath),
|
|
1537
|
+
})),
|
|
1538
|
+
]
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function sessionStatePayload(sessionID, parsedState, ledgerEntries = []) {
|
|
1542
|
+
const goals = []
|
|
1543
|
+
const results = []
|
|
1544
|
+
const archives = []
|
|
1545
|
+
const orderedSessions = []
|
|
1546
|
+
|
|
1547
|
+
for (const rawGoal of parsedState?.goals || []) {
|
|
1548
|
+
const goal = normalizePersistedGoal(rawGoal)
|
|
1549
|
+
if (!goal || goal.sessionID !== sessionID) continue
|
|
1550
|
+
goals.push({ ...serializeGoal(goal), focused: rawGoal?.focused === true })
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
for (const rawResult of parsedState?.results || []) {
|
|
1554
|
+
const result = normalizePersistedResult(rawResult)
|
|
1555
|
+
if (result?.sessionID === sessionID) results.push(result)
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
for (const rawArchive of parsedState?.archives || []) {
|
|
1559
|
+
if (!isPlainObject(rawArchive) || rawArchive.sessionID !== sessionID) continue
|
|
1560
|
+
const archiveResults = Array.isArray(rawArchive.results)
|
|
1561
|
+
? rawArchive.results.map(normalizePersistedResult).filter((result) => result?.sessionID === sessionID)
|
|
1562
|
+
: []
|
|
1563
|
+
if (archiveResults.length) archives.push({ sessionID, results: archiveResults.slice(-MAX_ARCHIVED_PER_SESSION) })
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
if (parsedState?.orderedSessions?.includes(sessionID)) orderedSessions.push(sessionID)
|
|
1567
|
+
|
|
1568
|
+
const sessionLedger = ledgerEntries.filter((entry) => entry?.sessionID === sessionID)
|
|
1569
|
+
const knownGoalIDs = new Set(goals.map((goal) => goal.goalId))
|
|
1570
|
+
for (const reconstructed of reconstructGoalsFromLedger(sessionLedger)) {
|
|
1571
|
+
const goal = normalizePersistedGoal(reconstructed)
|
|
1572
|
+
if (!goal || knownGoalIDs.has(goal.goalId)) continue
|
|
1573
|
+
goals.push({ ...serializeGoal(goal), focused: true })
|
|
1574
|
+
knownGoalIDs.add(goal.goalId)
|
|
1575
|
+
if (reconstructed.ordered === true && !orderedSessions.includes(sessionID)) orderedSessions.push(sessionID)
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
version: STATE_FILE_VERSION,
|
|
1580
|
+
goals: goals.slice(-MAX_PERSISTED_ENTRIES),
|
|
1581
|
+
results: results.slice(-MAX_PERSISTED_ENTRIES),
|
|
1582
|
+
archives,
|
|
1583
|
+
orderedSessions,
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
async function writeStateSnapshot(stateFilePath, payload) {
|
|
1588
|
+
const tmpPath = `${stateFilePath}.${process.pid}.${randomUUID()}.tmp`
|
|
1589
|
+
try {
|
|
1590
|
+
await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
|
|
1591
|
+
await fs.writeFile(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 })
|
|
1592
|
+
await fs.rename(tmpPath, stateFilePath)
|
|
1593
|
+
await fs.chmod(stateFilePath, 0o600)
|
|
1594
|
+
return true
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
await fs.rm(tmpPath, { force: true }).catch(() => {})
|
|
1597
|
+
throw error
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function writeMigrationMarker(path) {
|
|
1602
|
+
await writeStateSnapshot(path, { version: 1, migratedAt: Date.now() })
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async function migrateLegacyState(persistenceOptions, client) {
|
|
1606
|
+
if (await pathExists(persistenceOptions.migrationMarkerPath)) return
|
|
1607
|
+
|
|
1608
|
+
for (const candidate of migrationCandidates(persistenceOptions)) {
|
|
1609
|
+
const sourceHasState = await pathExists(candidate.stateFilePath)
|
|
1610
|
+
const sourceHasLedger = await pathExists(candidate.ledgerFilePath)
|
|
1611
|
+
if (!sourceHasState && !sourceHasLedger) continue
|
|
1612
|
+
|
|
1613
|
+
const migrationLease = await acquireMigrationLease(
|
|
1614
|
+
candidate.stateFilePath,
|
|
1615
|
+
persistenceOptions.migrationMarkerPath,
|
|
1616
|
+
)
|
|
1617
|
+
if (!migrationLease) return
|
|
1456
1618
|
try {
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1619
|
+
if (await pathExists(persistenceOptions.migrationMarkerPath)) return
|
|
1620
|
+
|
|
1621
|
+
const state = await readPersistedStateFile(candidate.stateFilePath, client)
|
|
1622
|
+
const ledgerEntries = await readLedgerEntries(candidate.ledgerFilePath, {
|
|
1623
|
+
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1624
|
+
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1625
|
+
})
|
|
1626
|
+
if (state.status === "invalid" && ledgerEntries.length === 0) return
|
|
1627
|
+
if (state.status === "missing" && ledgerEntries.length === 0) return
|
|
1628
|
+
|
|
1629
|
+
const parsedState = state.status === "loaded" ? JSON.parse(state.raw) : null
|
|
1630
|
+
const sessionIDs = new Set(ledgerEntries.map((entry) => entry?.sessionID).filter(Boolean))
|
|
1631
|
+
for (const rawGoal of parsedState?.goals || []) if (rawGoal?.sessionID) sessionIDs.add(rawGoal.sessionID)
|
|
1632
|
+
for (const rawResult of parsedState?.results || []) if (rawResult?.sessionID) sessionIDs.add(rawResult.sessionID)
|
|
1633
|
+
for (const rawArchive of parsedState?.archives || []) if (rawArchive?.sessionID) sessionIDs.add(rawArchive.sessionID)
|
|
1634
|
+
for (const orderedSession of parsedState?.orderedSessions || []) if (orderedSession) sessionIDs.add(orderedSession)
|
|
1635
|
+
|
|
1636
|
+
for (const sessionID of [...sessionIDs].sort()) {
|
|
1637
|
+
const targetPaths = sessionPathsFor(persistenceOptions, sessionID)
|
|
1638
|
+
if (await pathExists(targetPaths.stateFilePath)) continue
|
|
1639
|
+
|
|
1640
|
+
const payload = sessionStatePayload(sessionID, parsedState, ledgerEntries)
|
|
1641
|
+
const sessionLedger = ledgerEntries.filter((entry) => entry?.sessionID === sessionID)
|
|
1642
|
+
if (sessionLedger.length && !(await pathExists(targetPaths.ledgerFilePath))) {
|
|
1643
|
+
for (const entry of sessionLedger) {
|
|
1644
|
+
if (!appendLedgerLine(targetPaths.ledgerFilePath, entry, {
|
|
1645
|
+
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1646
|
+
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1647
|
+
})) {
|
|
1648
|
+
throw new Error(`could not migrate the goal ledger for session ${sessionID}`)
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
await writeStateSnapshot(targetPaths.stateFilePath, payload)
|
|
1466
1653
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
await
|
|
1471
|
-
|
|
1654
|
+
|
|
1655
|
+
await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
|
|
1656
|
+
for (const sourcePath of [candidate.stateFilePath, candidate.ledgerFilePath]) {
|
|
1657
|
+
if (!(await pathExists(sourcePath))) continue
|
|
1658
|
+
const backupPath = `${sourcePath}.migrated.${Date.now()}.${randomUUID()}`
|
|
1659
|
+
try {
|
|
1660
|
+
await fs.rename(sourcePath, backupPath)
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
await logPluginError(client, `Could not retire migrated goal persistence at ${sourcePath}.`, error)
|
|
1663
|
+
}
|
|
1472
1664
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
await
|
|
1476
|
-
if (primary) return "invalid"
|
|
1477
|
-
await migrationLease?.release()
|
|
1478
|
-
continue
|
|
1665
|
+
return
|
|
1666
|
+
} finally {
|
|
1667
|
+
await migrationLease.release()
|
|
1479
1668
|
}
|
|
1669
|
+
}
|
|
1480
1670
|
|
|
1481
|
-
|
|
1671
|
+
// A fresh project has no aggregate or legacy state. Mark the namespace so a
|
|
1672
|
+
// later session does not repeatedly probe global fallback paths.
|
|
1673
|
+
await writeMigrationMarker(persistenceOptions.migrationMarkerPath)
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
async function loadPersistedSessionState(persistence, client, sessionID) {
|
|
1677
|
+
const state = await readPersistedStateFile(persistence.stateFilePath, client)
|
|
1678
|
+
if (state.status === "loaded") {
|
|
1679
|
+
await applyParsedStateFile(state.raw, client, sessionID)
|
|
1680
|
+
await reconcileLoadedStateWithLedger(persistence, client, sessionID)
|
|
1681
|
+
return "loaded"
|
|
1682
|
+
}
|
|
1683
|
+
const recovered = await reconstructFromLedger(persistence, client, sessionID)
|
|
1684
|
+
if (state.status === "invalid" && recovered === "reconstructed") {
|
|
1685
|
+
const quarantinePath = `${persistence.stateFilePath}.corrupt.${Date.now()}.${randomUUID()}`
|
|
1482
1686
|
try {
|
|
1483
|
-
|
|
1687
|
+
await fs.rename(persistence.stateFilePath, quarantinePath)
|
|
1688
|
+
await logPluginError(
|
|
1689
|
+
client,
|
|
1690
|
+
`Preserved invalid persisted goal state at ${quarantinePath} before ledger recovery.`,
|
|
1691
|
+
)
|
|
1484
1692
|
} catch (error) {
|
|
1485
|
-
await logPluginError(client, "
|
|
1486
|
-
if (primary) return recoverInvalidPrimary()
|
|
1487
|
-
await migrationLease?.release()
|
|
1488
|
-
continue
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
if (status === "loaded") {
|
|
1492
|
-
// Cross-check: the ledger is written before the state file for terminal
|
|
1493
|
-
// events (completed, cleared). If the terminal persist succeeded in the
|
|
1494
|
-
// ledger but the state file write failed (e.g. process killed between the
|
|
1495
|
-
// two writes), the reloaded state may still have the goal as active. Remove
|
|
1496
|
-
// any loaded active goals whose goalId has a terminal ledger entry.
|
|
1497
|
-
await reconcileLoadedStateWithLedger(persistenceOptions, client)
|
|
1498
|
-
if (primary) return "loaded"
|
|
1499
|
-
persistenceOptions.migrationClaim = { path, lease: migrationLease }
|
|
1500
|
-
currentRuntime().migrationLease = migrationLease
|
|
1501
|
-
return "migrated"
|
|
1693
|
+
await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
|
|
1502
1694
|
}
|
|
1503
|
-
// status === "invalid": preserve a present-but-corrupt primary; for a
|
|
1504
|
-
// fallback, keep trying the next candidate.
|
|
1505
|
-
if (primary) return recoverInvalidPrimary()
|
|
1506
|
-
await migrationLease?.release()
|
|
1507
1695
|
}
|
|
1508
|
-
|
|
1509
|
-
// No state file found at any candidate path → try reconstructing from the
|
|
1510
|
-
// append-only ledger before giving up.
|
|
1511
|
-
return reconstructFromLedger(persistenceOptions, client)
|
|
1696
|
+
return recovered
|
|
1512
1697
|
}
|
|
1513
1698
|
|
|
1514
1699
|
// Last-resort recovery: when the main state file is absent, rebuild still-active
|
|
1515
1700
|
// goals from the append-only ledger so a lost/rotated state file does not drop
|
|
1516
1701
|
// in-flight goals. Recovered goals are paused (via deserializeGoal).
|
|
1517
|
-
async function reconstructFromLedger(persistenceOptions, client) {
|
|
1702
|
+
async function reconstructFromLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1518
1703
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1519
1704
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1520
1705
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1521
1706
|
})
|
|
1522
1707
|
if (!entries.length) return "missing"
|
|
1523
1708
|
|
|
1524
|
-
const reconstructed = reconstructGoalsFromLedger(entries)
|
|
1709
|
+
const reconstructed = reconstructGoalsFromLedger(entries).filter(
|
|
1710
|
+
(goal) => !onlySessionID || goal.sessionID === onlySessionID,
|
|
1711
|
+
)
|
|
1525
1712
|
if (!reconstructed.length) return "missing"
|
|
1526
1713
|
|
|
1527
|
-
|
|
1714
|
+
if (onlySessionID) clearSessionRuntimeState(onlySessionID)
|
|
1715
|
+
else clearRuntimeState()
|
|
1528
1716
|
const focusCandidates = new Map()
|
|
1529
1717
|
for (const stub of reconstructed) {
|
|
1530
1718
|
const normalized = normalizePersistedGoal(stub)
|
|
@@ -1536,6 +1724,7 @@ async function reconstructFromLedger(persistenceOptions, client) {
|
|
|
1536
1724
|
}
|
|
1537
1725
|
}
|
|
1538
1726
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1727
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1539
1728
|
const preferred = focusCandidates.get(sessionID)
|
|
1540
1729
|
const focused = (preferred && goals.get(preferred)) || goals.values().next().value
|
|
1541
1730
|
if (focused) focusGoal(sessionID, focused)
|
|
@@ -1547,55 +1736,46 @@ async function reconstructFromLedger(persistenceOptions, client) {
|
|
|
1547
1736
|
return goalStates.size > 0 ? "reconstructed" : "missing"
|
|
1548
1737
|
}
|
|
1549
1738
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1739
|
+
function currentSessionStatePayload(sessionID) {
|
|
1740
|
+
return {
|
|
1741
|
+
version: STATE_FILE_VERSION,
|
|
1742
|
+
goals: (listSessionGoals(sessionID) || [])
|
|
1743
|
+
.slice(-MAX_LIVE_GOALS_PER_SESSION)
|
|
1744
|
+
.map((goal) => ({
|
|
1745
|
+
...serializeGoal(goal),
|
|
1746
|
+
focused: goalStates.get(sessionID)?.goalId === goal.goalId,
|
|
1747
|
+
})),
|
|
1748
|
+
results: lastGoalResults.has(sessionID)
|
|
1749
|
+
? [{
|
|
1750
|
+
...lastGoalResults.get(sessionID),
|
|
1751
|
+
sessionID,
|
|
1752
|
+
history: [...(lastGoalResults.get(sessionID).history || [])],
|
|
1753
|
+
checkpoints: [...(lastGoalResults.get(sessionID).checkpoints || [])],
|
|
1754
|
+
lastCheckpoint: lastGoalResults.get(sessionID).lastCheckpoint || null,
|
|
1755
|
+
}]
|
|
1756
|
+
: [],
|
|
1757
|
+
archives: sessionArchive.has(sessionID)
|
|
1758
|
+
? [{
|
|
1759
|
+
sessionID,
|
|
1760
|
+
results: sessionArchive.get(sessionID).map((result) => ({
|
|
1571
1761
|
...result,
|
|
1572
1762
|
sessionID,
|
|
1573
1763
|
history: [...(result.history || [])],
|
|
1574
1764
|
checkpoints: [...(result.checkpoints || [])],
|
|
1575
1765
|
lastCheckpoint: result.lastCheckpoint || null,
|
|
1576
1766
|
})),
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
orderedSessions: [...sessionOrdered].slice(-MAX_PERSISTED_ENTRIES),
|
|
1588
|
-
},
|
|
1589
|
-
null,
|
|
1590
|
-
2,
|
|
1591
|
-
),
|
|
1592
|
-
{ encoding: "utf8", mode: 0o600 },
|
|
1593
|
-
)
|
|
1594
|
-
await fs.rename(tmpPath, persistenceOptions.stateFilePath)
|
|
1595
|
-
await fs.chmod(persistenceOptions.stateFilePath, 0o600)
|
|
1767
|
+
}]
|
|
1768
|
+
: [],
|
|
1769
|
+
orderedSessions: sessionOrdered.has(sessionID) ? [sessionID] : [],
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
async function persistState(persistence, client, sessionID) {
|
|
1774
|
+
if (!persistence.persistState) return true
|
|
1775
|
+
try {
|
|
1776
|
+
await writeStateSnapshot(persistence.stateFilePath, currentSessionStatePayload(sessionID))
|
|
1596
1777
|
return true
|
|
1597
1778
|
} catch (error) {
|
|
1598
|
-
await fs.rm(tmpPath, { force: true }).catch(() => {})
|
|
1599
1779
|
await logPluginError(client, "Failed to persist goal state", error)
|
|
1600
1780
|
return false
|
|
1601
1781
|
}
|
|
@@ -2339,10 +2519,6 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2339
2519
|
return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
|
|
2340
2520
|
if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
|
|
2341
2521
|
return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
|
|
2342
|
-
if (!goalStates.has(sessionID) && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
|
|
2343
|
-
return `The plugin already tracks ${MAX_PERSISTED_ENTRIES} live goals; clear or complete one before creating another.`
|
|
2344
|
-
}
|
|
2345
|
-
|
|
2346
2522
|
const options = normalizeOptions({
|
|
2347
2523
|
...defaultGoalOptions,
|
|
2348
2524
|
...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
|
|
@@ -2368,7 +2544,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2368
2544
|
lastGoalResults.delete(sessionID)
|
|
2369
2545
|
registerSessionGoal(goal)
|
|
2370
2546
|
focusGoal(sessionID, goal)
|
|
2371
|
-
await persist()
|
|
2547
|
+
await persist(sessionID)
|
|
2372
2548
|
// Escape in the tool result only: goal.condition is stored raw so callers
|
|
2373
2549
|
// that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
|
|
2374
2550
|
// themselves. Escaping here prevents XML metacharacters in user-supplied
|
|
@@ -2450,7 +2626,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2450
2626
|
goal.stopReason = "audit rejected"
|
|
2451
2627
|
goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
2452
2628
|
pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
2453
|
-
await persist()
|
|
2629
|
+
await persist(sessionID)
|
|
2454
2630
|
return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
|
|
2455
2631
|
}
|
|
2456
2632
|
}
|
|
@@ -2463,9 +2639,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2463
2639
|
const ordered = sessionOrdered.has(sessionID)
|
|
2464
2640
|
rememberGoalResult(sessionID, goal, "achieved", "", evidence)
|
|
2465
2641
|
cleanupGoal(sessionID)
|
|
2466
|
-
// Advance an ordered
|
|
2642
|
+
// Advance an ordered sequence just like the marker path does.
|
|
2467
2643
|
if (ordered) promoteNextOrderedGoal(sessionID)
|
|
2468
|
-
const durable = await persistFinal("completion", ledgerDurable)
|
|
2644
|
+
const durable = await persistFinal(sessionID, "completion", ledgerDurable)
|
|
2469
2645
|
if (durable === false) {
|
|
2470
2646
|
restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })
|
|
2471
2647
|
return "Completion verified, but terminal state could not be persisted. Goal remains paused."
|
|
@@ -2509,7 +2685,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2509
2685
|
if (!messages.length) {
|
|
2510
2686
|
return "Nothing to update. Provide `objective` and/or `status`."
|
|
2511
2687
|
}
|
|
2512
|
-
await persist()
|
|
2688
|
+
await persist(sessionID)
|
|
2513
2689
|
return messages.join(" ")
|
|
2514
2690
|
}
|
|
2515
2691
|
|
|
@@ -2525,7 +2701,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2525
2701
|
sessionGoals.delete(sessionID)
|
|
2526
2702
|
cleanupGoal(sessionID)
|
|
2527
2703
|
lastGoalResults.delete(sessionID)
|
|
2528
|
-
await persistFinal("clear")
|
|
2704
|
+
await persistFinal(sessionID, "clear")
|
|
2529
2705
|
return "Goal cleared."
|
|
2530
2706
|
}
|
|
2531
2707
|
|
|
@@ -2550,11 +2726,12 @@ async function loadOpencodePluginModule() {
|
|
|
2550
2726
|
return opencodePluginModulePromise
|
|
2551
2727
|
}
|
|
2552
2728
|
|
|
2553
|
-
function buildAgentTools(toolHelper, handlers) {
|
|
2729
|
+
function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () => true) {
|
|
2554
2730
|
const schema = toolHelper.schema
|
|
2555
2731
|
const run = (handler) => async (args, ctx) => {
|
|
2556
2732
|
const sessionID = agentToolSessionID(ctx)
|
|
2557
2733
|
if (!sessionID) return "No session id available for the goal tool."
|
|
2734
|
+
await ensureSessionLoaded(sessionID)
|
|
2558
2735
|
return handler(sessionID, args || {})
|
|
2559
2736
|
}
|
|
2560
2737
|
// Canonical tools use a small, versioned machine-readable envelope. Keep the
|
|
@@ -2568,6 +2745,7 @@ function buildAgentTools(toolHelper, handlers) {
|
|
|
2568
2745
|
goalToolFailure("missing_session", "No session id available for the goal tool."),
|
|
2569
2746
|
)
|
|
2570
2747
|
}
|
|
2748
|
+
await ensureSessionLoaded(sessionID)
|
|
2571
2749
|
return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
|
|
2572
2750
|
}
|
|
2573
2751
|
|
|
@@ -2707,7 +2885,7 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
2707
2885
|
|
|
2708
2886
|
const lines = []
|
|
2709
2887
|
if (goals.length) {
|
|
2710
|
-
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered
|
|
2888
|
+
lines.push(`Goals (${goals.length})${sessionOrdered.has(sessionID) ? " — ordered sequence" : ""}:`)
|
|
2711
2889
|
goals.forEach((goal, index) => {
|
|
2712
2890
|
const marker = goal.goalId === focusedId ? "focused" : goal.stopped ? "background" : "idle"
|
|
2713
2891
|
const state = goal.stopped && goal.goalId !== focusedId ? ` — ${goal.stopReason || "stopped"}` : ""
|
|
@@ -2899,30 +3077,67 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2899
3077
|
env: pluginOptions.env,
|
|
2900
3078
|
cwd: pluginOptions.cwd || directory,
|
|
2901
3079
|
})
|
|
2902
|
-
if (persistenceOptions.persistState) {
|
|
2903
|
-
await assertSafeProjectPersistencePath(persistenceOptions)
|
|
2904
|
-
currentRuntime().persistenceLease = await acquirePersistenceLease(persistenceOptions.stateFilePath)
|
|
2905
|
-
}
|
|
2906
3080
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
2907
|
-
|
|
2908
|
-
//
|
|
2909
|
-
//
|
|
2910
|
-
|
|
2911
|
-
const persist = () => {
|
|
2912
|
-
|
|
2913
|
-
|
|
3081
|
+
|
|
3082
|
+
// Each session owns an independent snapshot, ledger, write chain, and
|
|
3083
|
+
// lifetime lease. A project can therefore host any number of unrelated goal
|
|
3084
|
+
// sessions without allowing two processes to drive the same session.
|
|
3085
|
+
const persist = (sessionID) => {
|
|
3086
|
+
const persistence = runtime.sessionPersistence.get(sessionID)
|
|
3087
|
+
if (runtime.disposed || !persistence) return Promise.resolve(false)
|
|
3088
|
+
persistence.persistChain = persistence.persistChain
|
|
2914
3089
|
.catch(() => false)
|
|
2915
|
-
.then(() => persistState(
|
|
2916
|
-
return persistChain
|
|
3090
|
+
.then(() => persistState(persistence, client, sessionID))
|
|
3091
|
+
return persistence.persistChain
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
const ensureSessionLoaded = async (sessionID) => {
|
|
3095
|
+
if (!persistenceOptions.persistState || !sessionID) return true
|
|
3096
|
+
const existingLoad = runtime.sessionLoadPromises.get(sessionID)
|
|
3097
|
+
if (existingLoad) return existingLoad
|
|
3098
|
+
if (runtime.sessionPersistence.has(sessionID)) return true
|
|
3099
|
+
|
|
3100
|
+
const load = (async () => {
|
|
3101
|
+
const paths = sessionPathsFor(persistenceOptions, sessionID)
|
|
3102
|
+
await assertSafeProjectPersistencePath({
|
|
3103
|
+
...persistenceOptions,
|
|
3104
|
+
stateFilePath: paths.stateFilePath,
|
|
3105
|
+
})
|
|
3106
|
+
const lease = await acquirePersistenceLease(paths.stateFilePath)
|
|
3107
|
+
const persistence = {
|
|
3108
|
+
...persistenceOptions,
|
|
3109
|
+
...paths,
|
|
3110
|
+
persistChain: Promise.resolve(true),
|
|
3111
|
+
lease,
|
|
3112
|
+
}
|
|
3113
|
+
runtime.sessionPersistence.set(sessionID, persistence)
|
|
3114
|
+
try {
|
|
3115
|
+
await migrateLegacyState(persistenceOptions, client)
|
|
3116
|
+
const status = await loadPersistedSessionState(persistence, client, sessionID)
|
|
3117
|
+
pruneGoalResults(defaultGoalOptions)
|
|
3118
|
+
if (status === "loaded" || status === "missing" || status === "reconstructed") await persist(sessionID)
|
|
3119
|
+
return true
|
|
3120
|
+
} catch (error) {
|
|
3121
|
+
runtime.sessionPersistence.delete(sessionID)
|
|
3122
|
+
await lease.release().catch(() => false)
|
|
3123
|
+
throw error
|
|
3124
|
+
}
|
|
3125
|
+
})()
|
|
3126
|
+
|
|
3127
|
+
runtime.sessionLoadPromises.set(sessionID, load)
|
|
3128
|
+
try {
|
|
3129
|
+
return await load
|
|
3130
|
+
} finally {
|
|
3131
|
+
runtime.sessionLoadPromises.delete(sessionID)
|
|
3132
|
+
}
|
|
2917
3133
|
}
|
|
2918
|
-
runtime.drainPersistence = () => persistChain.catch(() => false)
|
|
2919
3134
|
|
|
2920
3135
|
// Fail closed when persisting a terminal state (complete/blocked)
|
|
2921
3136
|
// fails, surface it loudly. The terminal event is already in the append-only
|
|
2922
3137
|
// ledger, so it stays recoverable across a restart even though the main state
|
|
2923
3138
|
// file write did not land.
|
|
2924
|
-
const persistTerminalState = async (label, ledgerDurable = false) => {
|
|
2925
|
-
const stateDurable = await persist()
|
|
3139
|
+
const persistTerminalState = async (sessionID, label, ledgerDurable = false) => {
|
|
3140
|
+
const stateDurable = await persist(sessionID)
|
|
2926
3141
|
if (!stateDurable && persistenceOptions.persistState) {
|
|
2927
3142
|
await logPluginError(
|
|
2928
3143
|
client,
|
|
@@ -2936,10 +3151,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2936
3151
|
|
|
2937
3152
|
// Route lifecycle events to the JSONL ledger only when persistence is on.
|
|
2938
3153
|
if (persistenceOptions.persistState) {
|
|
2939
|
-
setLedgerSink((entry) =>
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
3154
|
+
setLedgerSink((entry) => {
|
|
3155
|
+
const persistence = runtime.sessionPersistence.get(entry.sessionID)
|
|
3156
|
+
if (!persistence) return false
|
|
3157
|
+
return appendLedgerLine(persistence.ledgerFilePath, entry, {
|
|
3158
|
+
maxBytes: persistence.ledgerMaxBytes,
|
|
3159
|
+
retentionFiles: persistence.ledgerRetentionFiles,
|
|
3160
|
+
})
|
|
3161
|
+
})
|
|
2943
3162
|
} else {
|
|
2944
3163
|
setLedgerSink(null)
|
|
2945
3164
|
}
|
|
@@ -2982,34 +3201,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2982
3201
|
: null
|
|
2983
3202
|
|
|
2984
3203
|
clearRuntimeState()
|
|
2985
|
-
const persistedStateStatus = await loadPersistedState(persistenceOptions, client)
|
|
2986
|
-
pruneGoalResults(defaultGoalOptions)
|
|
2987
|
-
// "migrated" = loaded from a legacy/XDG fallback path; "reconstructed" =
|
|
2988
|
-
// rebuilt from the ledger. Both persist forward to the resolved path.
|
|
2989
|
-
if (
|
|
2990
|
-
persistedStateStatus === "loaded" ||
|
|
2991
|
-
persistedStateStatus === "missing" ||
|
|
2992
|
-
persistedStateStatus === "migrated" ||
|
|
2993
|
-
persistedStateStatus === "reconstructed"
|
|
2994
|
-
) {
|
|
2995
|
-
const initialPersisted = await persist()
|
|
2996
|
-
if (persistedStateStatus === "migrated" && persistenceOptions.migrationClaim) {
|
|
2997
|
-
const { path, lease } = persistenceOptions.migrationClaim
|
|
2998
|
-
if (initialPersisted) {
|
|
2999
|
-
const backupPath = `${path}.migrated.${Date.now()}.${randomUUID()}`
|
|
3000
|
-
try {
|
|
3001
|
-
await fs.rename(path, backupPath)
|
|
3002
|
-
} catch (error) {
|
|
3003
|
-
await logPluginError(client, `Could not retire migrated legacy goal state at ${path}.`, error)
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
await lease.release()
|
|
3007
|
-
runtime.migrationLease = null
|
|
3008
|
-
persistenceOptions.migrationClaim = null
|
|
3009
|
-
}
|
|
3010
|
-
}
|
|
3011
3204
|
|
|
3012
|
-
const agentToolHandlers = buildAgentToolHandlers({
|
|
3205
|
+
const agentToolHandlers = buildAgentToolHandlers({
|
|
3206
|
+
defaultGoalOptions,
|
|
3207
|
+
persist,
|
|
3208
|
+
persistTerminalState,
|
|
3209
|
+
completionAuditor,
|
|
3210
|
+
commandName,
|
|
3211
|
+
})
|
|
3013
3212
|
|
|
3014
3213
|
const abortAcceptedContinuation = async (sessionID) => {
|
|
3015
3214
|
const runtimeState = currentRuntime()
|
|
@@ -3040,7 +3239,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3040
3239
|
goal.continuationClaim = null
|
|
3041
3240
|
pushHistory(goal, "paused", history)
|
|
3042
3241
|
activeContinues.delete(sessionID)
|
|
3043
|
-
await persist()
|
|
3242
|
+
await persist(sessionID)
|
|
3044
3243
|
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3045
3244
|
return true
|
|
3046
3245
|
}
|
|
@@ -3110,7 +3309,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3110
3309
|
}
|
|
3111
3310
|
|
|
3112
3311
|
goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
|
|
3113
|
-
const claimPersisted = await persist()
|
|
3312
|
+
const claimPersisted = await persist(sessionID)
|
|
3114
3313
|
if (!claimPersisted && persistenceOptions.persistState) {
|
|
3115
3314
|
goal.continuationClaim = null
|
|
3116
3315
|
goal.stopped = true
|
|
@@ -3132,6 +3331,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3132
3331
|
},
|
|
3133
3332
|
"chat.params": async (input) => {
|
|
3134
3333
|
if (!input?.sessionID) return
|
|
3334
|
+
await ensureSessionLoaded(input.sessionID)
|
|
3135
3335
|
const context = normalizeExecutionContext({
|
|
3136
3336
|
agent: input.agent,
|
|
3137
3337
|
model: input.model,
|
|
@@ -3142,6 +3342,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3142
3342
|
"chat.message": async (input, output) => {
|
|
3143
3343
|
const sessionID = input?.sessionID
|
|
3144
3344
|
if (!sessionID) return
|
|
3345
|
+
await ensureSessionLoaded(sessionID)
|
|
3145
3346
|
const context = normalizeExecutionContext(input)
|
|
3146
3347
|
if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3147
3348
|
|
|
@@ -3162,7 +3363,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3162
3363
|
},
|
|
3163
3364
|
"tool.execute.before": async (input) => {
|
|
3164
3365
|
const sessionID = input?.sessionID
|
|
3165
|
-
if (!sessionID
|
|
3366
|
+
if (!sessionID) return
|
|
3367
|
+
await ensureSessionLoaded(sessionID)
|
|
3368
|
+
if (!currentRuntime().readOnlyCommandGuards.has(sessionID)) return
|
|
3166
3369
|
if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
|
|
3167
3370
|
throw new Error(
|
|
3168
3371
|
`This /${commandName} control command is read-only for the routed model turn. Tool "${input?.tool || "unknown"}" was blocked. Wait for a separate user turn; do not modify work or goal state now.`,
|
|
@@ -3181,6 +3384,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3181
3384
|
}
|
|
3182
3385
|
const args = input.arguments.trim()
|
|
3183
3386
|
const sessionID = input.sessionID
|
|
3387
|
+
await ensureSessionLoaded(sessionID)
|
|
3184
3388
|
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
3185
3389
|
pruneGoalResults(defaultGoalOptions)
|
|
3186
3390
|
|
|
@@ -3243,7 +3447,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3243
3447
|
sessionGoals.delete(sessionID)
|
|
3244
3448
|
cleanupGoal(sessionID)
|
|
3245
3449
|
lastGoalResults.delete(sessionID)
|
|
3246
|
-
await persist()
|
|
3450
|
+
await persist(sessionID)
|
|
3247
3451
|
output.parts = [makeTextPart("Goal cleared.")]
|
|
3248
3452
|
return
|
|
3249
3453
|
}
|
|
@@ -3262,7 +3466,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3262
3466
|
goal.continuationClaim = null
|
|
3263
3467
|
activeContinues.delete(sessionID)
|
|
3264
3468
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
3265
|
-
await persist()
|
|
3469
|
+
await persist(sessionID)
|
|
3266
3470
|
await abortAcceptedContinuation(sessionID)
|
|
3267
3471
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
3268
3472
|
return
|
|
@@ -3288,7 +3492,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3288
3492
|
goal.blockedReason = ""
|
|
3289
3493
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
3290
3494
|
pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
|
|
3291
|
-
await persist()
|
|
3495
|
+
await persist(sessionID)
|
|
3292
3496
|
output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
|
|
3293
3497
|
return
|
|
3294
3498
|
}
|
|
@@ -3328,7 +3532,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3328
3532
|
goal.continuationClaim = null
|
|
3329
3533
|
goal.lastStatus = "Goal objective updated."
|
|
3330
3534
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
3331
|
-
await persist()
|
|
3535
|
+
await persist(sessionID)
|
|
3332
3536
|
output.parts = [
|
|
3333
3537
|
makeTextPart(
|
|
3334
3538
|
[
|
|
@@ -3347,8 +3551,11 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3347
3551
|
return
|
|
3348
3552
|
}
|
|
3349
3553
|
|
|
3350
|
-
|
|
3351
|
-
|
|
3554
|
+
const sequenceCommand = SEQUENCE_COMMANDS.find(
|
|
3555
|
+
(command) => args.toLowerCase() === command || args.toLowerCase().startsWith(`${command} `),
|
|
3556
|
+
)
|
|
3557
|
+
if (sequenceCommand) {
|
|
3558
|
+
const rest = args.slice(sequenceCommand.length).trim()
|
|
3352
3559
|
const objectives = rest
|
|
3353
3560
|
.split(/\n|;/)
|
|
3354
3561
|
.map((part) => stripWrappingQuotes(part.trim()))
|
|
@@ -3356,7 +3563,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3356
3563
|
if (!objectives.length) {
|
|
3357
3564
|
output.parts = [
|
|
3358
3565
|
makeTextPart(
|
|
3359
|
-
`No objectives provided. Use \`/${commandName}
|
|
3566
|
+
`No objectives provided. Use \`/${commandName} sequence <objective 1>; <objective 2>; …\` (separate with \`;\` or newlines).`,
|
|
3360
3567
|
),
|
|
3361
3568
|
]
|
|
3362
3569
|
return
|
|
@@ -3365,11 +3572,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3365
3572
|
output.parts = [makeTextPart(`An ordered sequence may contain at most ${MAX_LIVE_GOALS_PER_SESSION} goals.`)]
|
|
3366
3573
|
return
|
|
3367
3574
|
}
|
|
3368
|
-
const existingCount = listSessionGoals(sessionID).length
|
|
3369
|
-
if (totalLiveGoals() - existingCount + objectives.length > MAX_PERSISTED_ENTRIES) {
|
|
3370
|
-
output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
|
|
3371
|
-
return
|
|
3372
|
-
}
|
|
3373
3575
|
if (objectives.some((objective) => objective.length > MAX_GOAL_OBJECTIVE_LENGTH)) {
|
|
3374
3576
|
output.parts = [makeTextPart(`Each goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`)]
|
|
3375
3577
|
return
|
|
@@ -3400,17 +3602,17 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3400
3602
|
pushHistory(
|
|
3401
3603
|
created,
|
|
3402
3604
|
"set",
|
|
3403
|
-
`Ordered goal ${index + 1}/${objectives.length} created
|
|
3605
|
+
`Ordered goal ${index + 1}/${objectives.length} created.`,
|
|
3404
3606
|
)
|
|
3405
3607
|
registerSessionGoal(created)
|
|
3406
3608
|
})
|
|
3407
3609
|
focusGoal(sessionID, firstGoal)
|
|
3408
3610
|
sessionOrdered.add(sessionID)
|
|
3409
|
-
await persist()
|
|
3611
|
+
await persist(sessionID)
|
|
3410
3612
|
output.parts = [
|
|
3411
3613
|
makeTextPart(
|
|
3412
3614
|
[
|
|
3413
|
-
`Started an ordered sequence of ${objectives.length} goal(s)
|
|
3615
|
+
`Started an ordered sequence of ${objectives.length} goal(s):`,
|
|
3414
3616
|
...objectives.map((objective, index) => `${index + 1}. ${objective}`),
|
|
3415
3617
|
"",
|
|
3416
3618
|
`Focused goal 1: ${firstGoal.condition}`,
|
|
@@ -3465,7 +3667,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3465
3667
|
resumeGoalClock(target)
|
|
3466
3668
|
pushHistory(target, "focused", "Brought into focus as the session's active goal.")
|
|
3467
3669
|
focusGoal(sessionID, target)
|
|
3468
|
-
await persist()
|
|
3670
|
+
await persist(sessionID)
|
|
3469
3671
|
output.parts = [
|
|
3470
3672
|
makeTextPart(
|
|
3471
3673
|
[
|
|
@@ -3505,10 +3707,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3505
3707
|
output.parts = [makeTextPart(`A session may contain at most ${MAX_LIVE_GOALS_PER_SESSION} live goals.`)]
|
|
3506
3708
|
return
|
|
3507
3709
|
}
|
|
3508
|
-
if (totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
|
|
3509
|
-
output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
|
|
3510
|
-
return
|
|
3511
|
-
}
|
|
3512
3710
|
// Keep the current goal (background it) and focus a new one.
|
|
3513
3711
|
const current = goalStates.get(sessionID)
|
|
3514
3712
|
if (current) {
|
|
@@ -3525,7 +3723,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3525
3723
|
)
|
|
3526
3724
|
registerSessionGoal(added)
|
|
3527
3725
|
focusGoal(sessionID, added)
|
|
3528
|
-
await persist()
|
|
3726
|
+
await persist(sessionID)
|
|
3529
3727
|
const total = listSessionGoals(sessionID).length
|
|
3530
3728
|
output.parts = [
|
|
3531
3729
|
makeTextPart(
|
|
@@ -3545,10 +3743,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3545
3743
|
}
|
|
3546
3744
|
|
|
3547
3745
|
const replacedGoal = goalStates.get(sessionID)
|
|
3548
|
-
if (!replacedGoal && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
|
|
3549
|
-
output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
|
|
3550
|
-
return
|
|
3551
|
-
}
|
|
3552
3746
|
const goal = buildGoalState(sessionID, parsed.condition, parsed.options, parsed.meta)
|
|
3553
3747
|
|
|
3554
3748
|
pushHistory(
|
|
@@ -3560,14 +3754,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3560
3754
|
// Replace the focused goal (cleanupGoal discards it); backgrounded goals
|
|
3561
3755
|
// for this session are preserved. Use `/goal add` to keep the current
|
|
3562
3756
|
// goal and add another. Clear any ordered-sequence flag so the new
|
|
3563
|
-
// standalone goal does not trigger
|
|
3757
|
+
// standalone goal does not trigger auto-promotion of the old sequence
|
|
3564
3758
|
// goals that may still be in the registry (matches the agent setGoal path).
|
|
3565
3759
|
sessionOrdered.delete(sessionID)
|
|
3566
3760
|
cleanupGoal(sessionID)
|
|
3567
3761
|
lastGoalResults.delete(sessionID)
|
|
3568
3762
|
registerSessionGoal(goal)
|
|
3569
3763
|
focusGoal(sessionID, goal)
|
|
3570
|
-
await persist()
|
|
3764
|
+
await persist(sessionID)
|
|
3571
3765
|
output.parts = [
|
|
3572
3766
|
makeTextPart(
|
|
3573
3767
|
[
|
|
@@ -3599,6 +3793,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3599
3793
|
},
|
|
3600
3794
|
|
|
3601
3795
|
event: async ({ event }) => {
|
|
3796
|
+
const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
|
|
3797
|
+
if (eventSessionID) await ensureSessionLoaded(eventSessionID)
|
|
3798
|
+
|
|
3602
3799
|
if (event?.type === "session.status") {
|
|
3603
3800
|
const sessionID = getSessionID(event)
|
|
3604
3801
|
const status = event?.properties?.status?.type || event?.data?.status?.type
|
|
@@ -3635,7 +3832,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3635
3832
|
if (!goal) return
|
|
3636
3833
|
goal.messageIDs = new Set()
|
|
3637
3834
|
goal.totalTokens = 0
|
|
3638
|
-
await persist()
|
|
3835
|
+
await persist(sessionID)
|
|
3639
3836
|
return
|
|
3640
3837
|
}
|
|
3641
3838
|
|
|
@@ -3694,7 +3891,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3694
3891
|
changed = true
|
|
3695
3892
|
}
|
|
3696
3893
|
|
|
3697
|
-
if (changed) await persist()
|
|
3894
|
+
if (changed) await persist(messageSessionID(message))
|
|
3698
3895
|
return
|
|
3699
3896
|
}
|
|
3700
3897
|
|
|
@@ -3829,7 +4026,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3829
4026
|
auditedGoal.stopReason = "audit rejected"
|
|
3830
4027
|
auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
3831
4028
|
pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
3832
|
-
await persist()
|
|
4029
|
+
await persist(sessionID)
|
|
3833
4030
|
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
3834
4031
|
return
|
|
3835
4032
|
}
|
|
@@ -3852,12 +4049,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3852
4049
|
const ordered = sessionOrdered.has(sessionID)
|
|
3853
4050
|
rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved", "", evidence)
|
|
3854
4051
|
cleanupGoal(sessionID)
|
|
3855
|
-
// Ordered
|
|
4052
|
+
// Ordered sequence: auto-promote the next goal so the
|
|
3856
4053
|
// session keeps working through the sequence without manual /goal focus.
|
|
3857
4054
|
if (ordered) {
|
|
3858
4055
|
promoteNextOrderedGoal(sessionID)
|
|
3859
4056
|
}
|
|
3860
|
-
const durable = await persistTerminalState("completion", ledgerDurable)
|
|
4057
|
+
const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
|
|
3861
4058
|
if (durable === false) {
|
|
3862
4059
|
restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered })
|
|
3863
4060
|
await announceAudit(
|
|
@@ -3891,7 +4088,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3891
4088
|
blockedGoal.stopped = true
|
|
3892
4089
|
blockedGoal.stopReason = "blocked"
|
|
3893
4090
|
const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
|
|
3894
|
-
const durable = await persistTerminalState("blocked", ledgerDurable)
|
|
4091
|
+
const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
|
|
3895
4092
|
if (durable === false) {
|
|
3896
4093
|
blockedGoal.stopReason = "terminal persistence failed"
|
|
3897
4094
|
blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
@@ -3931,7 +4128,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3931
4128
|
claimedGoal.stopReason = limitReason
|
|
3932
4129
|
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
3933
4130
|
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
3934
|
-
await persist()
|
|
4131
|
+
await persist(sessionID)
|
|
3935
4132
|
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
3936
4133
|
let response
|
|
3937
4134
|
try {
|
|
@@ -3952,7 +4149,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3952
4149
|
activeGoalAfterMessages.lastStatus = limitReason
|
|
3953
4150
|
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
3954
4151
|
}
|
|
3955
|
-
await persist()
|
|
4152
|
+
await persist(sessionID)
|
|
3956
4153
|
return
|
|
3957
4154
|
}
|
|
3958
4155
|
|
|
@@ -4007,7 +4204,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4007
4204
|
"paused",
|
|
4008
4205
|
`Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
|
|
4009
4206
|
)
|
|
4010
|
-
await persist()
|
|
4207
|
+
await persist(sessionID)
|
|
4011
4208
|
return
|
|
4012
4209
|
}
|
|
4013
4210
|
|
|
@@ -4052,7 +4249,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4052
4249
|
"paused",
|
|
4053
4250
|
`Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
|
|
4054
4251
|
)
|
|
4055
|
-
await persist()
|
|
4252
|
+
await persist(sessionID)
|
|
4056
4253
|
return
|
|
4057
4254
|
}
|
|
4058
4255
|
|
|
@@ -4101,7 +4298,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4101
4298
|
// promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
|
|
4102
4299
|
// the hard-limit path which also persists before its promptAsync call.
|
|
4103
4300
|
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
4104
|
-
await persist()
|
|
4301
|
+
await persist(sessionID)
|
|
4105
4302
|
}
|
|
4106
4303
|
|
|
4107
4304
|
activeGoalBeforePrompt.turnCount += 1
|
|
@@ -4141,7 +4338,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4141
4338
|
"paused",
|
|
4142
4339
|
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
4143
4340
|
)
|
|
4144
|
-
await persist()
|
|
4341
|
+
await persist(sessionID)
|
|
4145
4342
|
return
|
|
4146
4343
|
}
|
|
4147
4344
|
}
|
|
@@ -4202,7 +4399,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4202
4399
|
)
|
|
4203
4400
|
}
|
|
4204
4401
|
}
|
|
4205
|
-
await persist()
|
|
4402
|
+
await persist(sessionID)
|
|
4206
4403
|
} catch (error) {
|
|
4207
4404
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
4208
4405
|
if (activeGoalAfterError) {
|
|
@@ -4222,7 +4419,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4222
4419
|
activeGoalAfterError.stopReason = "auto-continue failures"
|
|
4223
4420
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
4224
4421
|
}
|
|
4225
|
-
await persist()
|
|
4422
|
+
await persist(sessionID)
|
|
4226
4423
|
}
|
|
4227
4424
|
await logPluginError(client, "Auto-continue failed", error)
|
|
4228
4425
|
} finally {
|
|
@@ -4239,6 +4436,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4239
4436
|
|
|
4240
4437
|
"experimental.chat.system.transform": async (input, output) => {
|
|
4241
4438
|
if (!input.sessionID) return
|
|
4439
|
+
await ensureSessionLoaded(input.sessionID)
|
|
4242
4440
|
|
|
4243
4441
|
const goal = goalStates.get(input.sessionID)
|
|
4244
4442
|
if (!goal) return
|
|
@@ -4288,6 +4486,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4288
4486
|
|
|
4289
4487
|
"experimental.session.compacting": async (input, output) => {
|
|
4290
4488
|
if (!input?.sessionID || !output) return
|
|
4489
|
+
await ensureSessionLoaded(input.sessionID)
|
|
4291
4490
|
const goal = goalStates.get(input.sessionID)
|
|
4292
4491
|
if (!goal) return
|
|
4293
4492
|
const context = buildCompactionContext(goal)
|
|
@@ -4307,6 +4506,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4307
4506
|
// auto-continue to avoid two continuations racing after a compaction.
|
|
4308
4507
|
// Paused/stopped goals leave the native behavior untouched.
|
|
4309
4508
|
if (!input?.sessionID || !output) return
|
|
4509
|
+
await ensureSessionLoaded(input.sessionID)
|
|
4310
4510
|
const goal = goalStates.get(input.sessionID)
|
|
4311
4511
|
if (!goal || goal.stopped) return
|
|
4312
4512
|
output.enabled = false
|
|
@@ -4328,7 +4528,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4328
4528
|
const toolModule = await loadOpencodePluginModule()
|
|
4329
4529
|
if (toolModule?.tool?.schema) {
|
|
4330
4530
|
try {
|
|
4331
|
-
hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers)
|
|
4531
|
+
hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers, ensureSessionLoaded)
|
|
4332
4532
|
} catch (error) {
|
|
4333
4533
|
await logPluginError(client, "Failed to register goal agent tools", error)
|
|
4334
4534
|
}
|
|
@@ -4370,13 +4570,17 @@ function bindHooksToRuntime(hooks, runtime) {
|
|
|
4370
4570
|
if (runtime.disposed) return
|
|
4371
4571
|
runtime.disposed = true
|
|
4372
4572
|
for (const controller of runtime.continuationControllers.values()) controller.abort()
|
|
4373
|
-
await runtime.
|
|
4573
|
+
await Promise.allSettled([...runtime.sessionLoadPromises.values()])
|
|
4574
|
+
for (const persistence of runtime.sessionPersistence.values()) {
|
|
4575
|
+
await persistence.persistChain.catch(() => false)
|
|
4576
|
+
}
|
|
4374
4577
|
clearRuntimeState()
|
|
4375
4578
|
setLedgerSink(null)
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
runtime.
|
|
4579
|
+
for (const persistence of runtime.sessionPersistence.values()) {
|
|
4580
|
+
await persistence.lease?.release().catch(() => false)
|
|
4581
|
+
}
|
|
4582
|
+
runtime.sessionPersistence.clear()
|
|
4583
|
+
runtime.sessionLoadPromises.clear()
|
|
4380
4584
|
})
|
|
4381
4585
|
return bound
|
|
4382
4586
|
}
|
|
@@ -4390,11 +4594,13 @@ export const GoalPlugin = async (context = {}, pluginOptions = {}) => {
|
|
|
4390
4594
|
return bindHooksToRuntime(hooks, runtime)
|
|
4391
4595
|
} catch (error) {
|
|
4392
4596
|
runtime.disposed = true
|
|
4393
|
-
await runtime.
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4597
|
+
await Promise.allSettled([...runtime.sessionLoadPromises.values()])
|
|
4598
|
+
for (const persistence of runtime.sessionPersistence.values()) {
|
|
4599
|
+
await persistence.persistChain.catch(() => false)
|
|
4600
|
+
await persistence.lease?.release().catch(() => false)
|
|
4601
|
+
}
|
|
4602
|
+
runtime.sessionPersistence.clear()
|
|
4603
|
+
runtime.sessionLoadPromises.clear()
|
|
4398
4604
|
throw error
|
|
4399
4605
|
}
|
|
4400
4606
|
})
|
|
@@ -4451,6 +4657,7 @@ export const testInternals = {
|
|
|
4451
4657
|
normalizeMessageUsage,
|
|
4452
4658
|
normalizeUsage,
|
|
4453
4659
|
normalizePersistenceOptions,
|
|
4660
|
+
sessionPathsFor,
|
|
4454
4661
|
userInterventionDetected,
|
|
4455
4662
|
outputTokensForMessage,
|
|
4456
4663
|
parseGoalArguments,
|