opencode-goal-plugin 0.6.5 → 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 +4 -0
- package/README.md +5 -5
- package/docs/compatibility.md +2 -0
- package/index.d.ts +8 -6
- package/package.json +1 -1
- package/src/goal-plugin.js +439 -238
- package/src/persistence-lease.js +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.6 — 2026-07-29
|
|
4
|
+
|
|
5
|
+
- Shard persisted goal state and lifecycle ledgers by hashed OpenCode session ID so independent sessions can run concurrently in one project. Keep same-session single-writer leases, lazy session loading, crash recovery, and safe migration of aggregate, legacy, and XDG state.
|
|
6
|
+
|
|
3
7
|
## 0.6.5 — 2026-07-12
|
|
4
8
|
|
|
5
9
|
- Make `/goal sequence` the canonical ordered multi-goal command, retain the previous command and mode spelling as input-only compatibility aliases, correct the public auditor snapshot mode type to `"normal" | "ordered"`, and align README archive and compatibility claims with verified behavior.
|
package/README.md
CHANGED
|
@@ -239,7 +239,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
|
|
|
239
239
|
|
|
240
240
|
**Wrap-up vs. hard stop.** When a limit is reached, the plugin sends one final prompt asking the assistant to summarize what is done, what remains, and the next concrete step — rather than stopping silently. Use `/goal resume` to continue after any stop, including limit stops and no-progress pauses.
|
|
241
241
|
|
|
242
|
-
Goal state is persisted by default to a **project-local**
|
|
242
|
+
Goal state is persisted by default to a **project-local** namespace rooted at `.opencode/goals/state.json` relative to the working directory, so goals follow the project rather than your home directory. Each OpenCode session gets a separate hashed shard at `<stateFilePath>.sessions/<sha256(sessionID)>/state.json`, allowing unrelated sessions in the same project to run concurrently. The state is local and is not synchronized across machines. You may want to add `.opencode/goals/` to your `.gitignore`.
|
|
243
243
|
|
|
244
244
|
The state-file location is resolved with this precedence:
|
|
245
245
|
|
|
@@ -247,11 +247,11 @@ The state-file location is resolved with this precedence:
|
|
|
247
247
|
2. the `OPENCODE_GOAL_STATE_PATH` environment variable, if set;
|
|
248
248
|
3. the project-local default `<cwd>/.opencode/goals/state.json`.
|
|
249
249
|
|
|
250
|
-
When
|
|
250
|
+
When a project has no shard namespace yet, the plugin migrates all sessions from older locations on first session access: the legacy `~/.opencode-goal-plugin/state.json` and the XDG path `${XDG_STATE_HOME:-~/.local/state}/opencode-goal-plugin/state.json`. Migration is exclusively claimed; after every session shard is written, the source files are retired to timestamped `.migrated…` backups so another project cannot import the same private goal state. An explicit `stateFilePath` or `OPENCODE_GOAL_STATE_PATH` is used as the shard namespace root and has no migration fallback.
|
|
251
251
|
|
|
252
252
|
The state directory is created with owner-only permissions, and the JSON state file is written as `0600` because it may contain goal text, assistant checkpoints, and workflow history.
|
|
253
253
|
|
|
254
|
-
Alongside
|
|
254
|
+
Alongside each session shard the plugin keeps an **append-only lifecycle ledger** (`<shard>/state.json.ledger.jsonl`, also `0600`). Every lifecycle event — set, edit, auto-continue, pause, resume, blocked, completed, limit — is appended as one JSON line. Because the in-memory history is capped, the ledger is the durable record: if a session state file is missing or corrupted, the plugin reconstructs still-active (non-completed) goals from that session's ledger on startup and reloads them in the paused recovery state. Terminal events (complete/blocked) are written to the ledger *before* the state write, so a goal's terminal outcome survives even if that write fails (**fail-closed**); such a failure is logged at error level.
|
|
255
255
|
|
|
256
256
|
Recovered active goals are loaded in a **paused** state with a recovery note, so unattended auto-continue does not resume blindly after a restart. Set `"persistState": false` to keep purely in-memory behavior (this also disables the ledger).
|
|
257
257
|
|
|
@@ -328,7 +328,7 @@ Additional plugin-level options:
|
|
|
328
328
|
- `goalAgentName` / `verifierAgentName` — customize the registered native agent names (defaults `goal` and `goal-verify`). The verifier is a hidden subagent with a default-deny tool policy; only `read`, `glob`, and `grep` are allowed.
|
|
329
329
|
- `sdkShape` — OpenCode session-client argument shape: `legacy` (the default generated `PluginInput` client using `{ path, body, query }`) or `flat` (clients using `{ sessionID, ... }`). Read-only `messages`/`get` calls may probe the alternate shape after an argument/schema `TypeError`; mutating calls are never replayed, so set this option correctly for embedded clients.
|
|
330
330
|
- `persistState` — whether to persist active goals and recent goal results to disk.
|
|
331
|
-
- `stateFilePath` —
|
|
331
|
+
- `stateFilePath` — root path for the persisted session-shard namespace. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default root is `<cwd>/.opencode/goals/state.json`; shards are written below `<stateFilePath>.sessions/` (see the persistence section above).
|
|
332
332
|
- `ledgerMaxBytes` / `ledgerRetentionFiles` — bound the lifecycle ledger to 2 MiB per generation and three rotated generations by default. Set retention to `0` to discard the active ledger when it reaches the size ceiling.
|
|
333
333
|
- `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
|
|
334
334
|
- `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
|
|
@@ -389,7 +389,7 @@ OpenCode's current `command.execute.before` hook does not fully intercept comman
|
|
|
389
389
|
|
|
390
390
|
The plugin depends on `experimental.chat.system.transform` and other OpenCode plugin hooks that may change between OpenCode versions.
|
|
391
391
|
|
|
392
|
-
|
|
392
|
+
Distinct OpenCode sessions may own shards under the same `stateFilePath` concurrently. A second process using the same session shard is still rejected with the owning PID/host instead of risking last-writer-wins state loss; use the original session or wait for its owner to dispose.
|
|
393
393
|
|
|
394
394
|
## Diagnostics and recovery
|
|
395
395
|
|
package/docs/compatibility.md
CHANGED
|
@@ -10,6 +10,8 @@ The latest published release is the supported line. Public compatibility covers:
|
|
|
10
10
|
- the documented OpenCode hook names
|
|
11
11
|
- the six canonical goal tools and five legacy tool aliases
|
|
12
12
|
- persisted-state recovery from versions documented in the changelog
|
|
13
|
+
- concurrent persistence for distinct OpenCode sessions in one project, with
|
|
14
|
+
single-writer protection retained per session
|
|
13
15
|
|
|
14
16
|
The package requires Node.js 18 or newer and OpenCode 1.17.15 through the latest
|
|
15
17
|
compatible 1.x release. CI runs the complete unit suite on Node 18, 20, 22, and
|
package/index.d.ts
CHANGED
|
@@ -222,17 +222,19 @@ export interface GoalPluginOptions {
|
|
|
222
222
|
persistState?: boolean
|
|
223
223
|
|
|
224
224
|
/**
|
|
225
|
-
*
|
|
226
|
-
* `persistState` is enabled.
|
|
227
|
-
*
|
|
225
|
+
* Root filesystem path for persisted session-shard state when
|
|
226
|
+
* `persistState` is enabled. Each session is written below
|
|
227
|
+
* `<stateFilePath>.sessions/<sha256(sessionID)>/state.json`. Overrides both
|
|
228
|
+
* the project-local default and the `OPENCODE_GOAL_STATE_PATH` environment
|
|
229
|
+
* variable.
|
|
228
230
|
* @default "<cwd>/.opencode/goals/state.json"
|
|
229
231
|
*/
|
|
230
232
|
stateFilePath?: string
|
|
231
233
|
|
|
232
234
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
235
|
+
* Legacy aggregate filesystem path for the append-only lifecycle ledger.
|
|
236
|
+
* During migration, events are partitioned into per-session shard ledgers;
|
|
237
|
+
* new writes use `<shard>/state.json.ledger.jsonl`.
|
|
236
238
|
* @default "<stateFilePath>.ledger.jsonl"
|
|
237
239
|
*/
|
|
238
240
|
ledgerFilePath?: string
|
package/package.json
CHANGED
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,
|
|
@@ -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
|
}
|
|
@@ -684,12 +685,6 @@ function listSessionGoals(sessionID) {
|
|
|
684
685
|
return map ? [...map.values()] : []
|
|
685
686
|
}
|
|
686
687
|
|
|
687
|
-
function totalLiveGoals() {
|
|
688
|
-
let total = 0
|
|
689
|
-
for (const goals of sessionGoals.values()) total += goals.size
|
|
690
|
-
return total
|
|
691
|
-
}
|
|
692
|
-
|
|
693
688
|
function rememberMessageID(goal, messageID) {
|
|
694
689
|
goal.messageIDs.add(messageID)
|
|
695
690
|
while (goal.messageIDs.size > MAX_MESSAGE_IDS_PER_GOAL) {
|
|
@@ -789,6 +784,29 @@ function clearRuntimeState() {
|
|
|
789
784
|
runtime.readOnlyCommandGuards.clear()
|
|
790
785
|
}
|
|
791
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
|
+
|
|
792
810
|
function pruneGoalResults(options) {
|
|
793
811
|
const retentionMs = options?.resultRetentionMs ?? DEFAULT_OPTIONS.resultRetentionMs
|
|
794
812
|
const maxStoredResults = options?.maxStoredResults ?? DEFAULT_OPTIONS.maxStoredResults
|
|
@@ -997,6 +1015,26 @@ function ledgerPathFor(stateFilePath) {
|
|
|
997
1015
|
return `${stateFilePath}.ledger.jsonl`
|
|
998
1016
|
}
|
|
999
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
|
+
|
|
1000
1038
|
// XDG-style state path: $XDG_STATE_HOME/opencode-goal-plugin/state.json,
|
|
1001
1039
|
// defaulting to ~/.local/state when XDG_STATE_HOME is unset.
|
|
1002
1040
|
function xdgStateFilePath(env = process.env) {
|
|
@@ -1047,11 +1085,14 @@ function normalizePersistenceOptions(options = {}, { env = process.env, cwd } =
|
|
|
1047
1085
|
: ledgerPathFor(stateFilePath)
|
|
1048
1086
|
const ledgerMaxBytes = toPositiveInteger(options.ledgerMaxBytes, DEFAULT_LEDGER_MAX_BYTES)
|
|
1049
1087
|
const ledgerRetentionFiles = Number.isSafeInteger(options.ledgerRetentionFiles) && options.ledgerRetentionFiles >= 0
|
|
1050
|
-
|
|
1051
|
-
|
|
1088
|
+
? Math.min(options.ledgerRetentionFiles, 10)
|
|
1089
|
+
: DEFAULT_LEDGER_RETENTION_FILES
|
|
1090
|
+
const sessionDirectory = sessionDirectoryFor(stateFilePath)
|
|
1052
1091
|
return {
|
|
1053
1092
|
persistState,
|
|
1054
1093
|
stateFilePath,
|
|
1094
|
+
sessionDirectory,
|
|
1095
|
+
migrationMarkerPath: join(sessionDirectory, ".migration-v1-complete"),
|
|
1055
1096
|
fallbackPaths,
|
|
1056
1097
|
ledgerFilePath,
|
|
1057
1098
|
ledgerMaxBytes,
|
|
@@ -1283,7 +1324,7 @@ function deserializeGoal(goal) {
|
|
|
1283
1324
|
// Parse one state-file body and apply it to runtime state. Returns "loaded" on
|
|
1284
1325
|
// success or "invalid" when the version/shape is unsupported. Throws on
|
|
1285
1326
|
// JSON.parse failure (handled by the caller).
|
|
1286
|
-
async function applyParsedStateFile(raw, client) {
|
|
1327
|
+
async function applyParsedStateFile(raw, client, onlySessionID = null) {
|
|
1287
1328
|
const parsed = JSON.parse(raw)
|
|
1288
1329
|
if (parsed?.version !== STATE_FILE_VERSION) {
|
|
1289
1330
|
await logPluginError(
|
|
@@ -1303,6 +1344,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1303
1344
|
const loadedGoalCounts = new Map()
|
|
1304
1345
|
for (const rawGoal of parsed.goals.slice(0, MAX_PERSISTED_ENTRIES)) {
|
|
1305
1346
|
const normalizedGoal = normalizePersistedGoal(rawGoal)
|
|
1347
|
+
if (onlySessionID && normalizedGoal?.sessionID !== onlySessionID) continue
|
|
1306
1348
|
const sessionCount = normalizedGoal
|
|
1307
1349
|
? loadedGoalCounts.get(normalizedGoal.sessionID) || 0
|
|
1308
1350
|
: 0
|
|
@@ -1318,6 +1360,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1318
1360
|
let skippedResults = 0
|
|
1319
1361
|
for (const rawResult of parsed.results.slice(-MAX_PERSISTED_ENTRIES)) {
|
|
1320
1362
|
const normalizedResult = normalizePersistedResult(rawResult)
|
|
1363
|
+
if (onlySessionID && normalizedResult?.sessionID !== onlySessionID) continue
|
|
1321
1364
|
if (normalizedResult) {
|
|
1322
1365
|
loadedResults.push(normalizedResult)
|
|
1323
1366
|
} else {
|
|
@@ -1332,7 +1375,8 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1332
1375
|
)
|
|
1333
1376
|
}
|
|
1334
1377
|
|
|
1335
|
-
|
|
1378
|
+
if (onlySessionID) clearSessionRuntimeState(onlySessionID)
|
|
1379
|
+
else clearRuntimeState()
|
|
1336
1380
|
|
|
1337
1381
|
const focusBySession = new Map()
|
|
1338
1382
|
for (const { goal, focused } of loadedGoals) {
|
|
@@ -1345,6 +1389,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1345
1389
|
// Restore focus. Older single-goal state files have no `focused` flag, so
|
|
1346
1390
|
// fall back to focusing a session's first (typically only) goal.
|
|
1347
1391
|
for (const [sessionID, goalMap] of sessionGoals.entries()) {
|
|
1392
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1348
1393
|
const focusTarget = focusBySession.get(sessionID) || goalMap.values().next().value
|
|
1349
1394
|
if (focusTarget) focusGoal(sessionID, focusTarget)
|
|
1350
1395
|
}
|
|
@@ -1356,6 +1401,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1356
1401
|
if (Array.isArray(parsed.archives)) {
|
|
1357
1402
|
for (const entry of parsed.archives.slice(-MAX_PERSISTED_ENTRIES)) {
|
|
1358
1403
|
if (!isPlainObject(entry) || typeof entry.sessionID !== "string" || !entry.sessionID) continue
|
|
1404
|
+
if (onlySessionID && entry.sessionID !== onlySessionID) continue
|
|
1359
1405
|
const results = Array.isArray(entry.results)
|
|
1360
1406
|
? entry.results.map(normalizePersistedResult).filter(Boolean)
|
|
1361
1407
|
: []
|
|
@@ -1367,6 +1413,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1367
1413
|
|
|
1368
1414
|
if (Array.isArray(parsed.orderedSessions)) {
|
|
1369
1415
|
for (const sessionID of parsed.orderedSessions) {
|
|
1416
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1370
1417
|
// Only honor the ordered flag for sessions that still have goals loaded.
|
|
1371
1418
|
if (typeof sessionID === "string" && sessionGoals.has(sessionID)) {
|
|
1372
1419
|
sessionOrdered.add(sessionID)
|
|
@@ -1381,7 +1428,7 @@ async function applyParsedStateFile(raw, client) {
|
|
|
1381
1428
|
// terminal events. If a goal has a "completed" or "cleared" entry in the ledger
|
|
1382
1429
|
// but still appears active in the state file (because the state write failed
|
|
1383
1430
|
// after the terminal ledger write), remove it so it is not re-driven.
|
|
1384
|
-
async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
1431
|
+
async function reconcileLoadedStateWithLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1385
1432
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1386
1433
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1387
1434
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
@@ -1395,6 +1442,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
|
1395
1442
|
typeof entry.sessionID === "string" && entry.sessionID &&
|
|
1396
1443
|
typeof entry.goalId === "string" && entry.goalId
|
|
1397
1444
|
) {
|
|
1445
|
+
if (onlySessionID && entry.sessionID !== onlySessionID) continue
|
|
1398
1446
|
terminalGoals.add(`${entry.sessionID}\0${entry.goalId}`)
|
|
1399
1447
|
}
|
|
1400
1448
|
}
|
|
@@ -1402,6 +1450,7 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
|
1402
1450
|
|
|
1403
1451
|
let removed = 0
|
|
1404
1452
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1453
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1405
1454
|
for (const goal of [...goals.values()]) {
|
|
1406
1455
|
if (!terminalGoals.has(`${sessionID}\0${goal.goalId}`)) continue
|
|
1407
1456
|
removeSessionGoal(sessionID, goal.goalId)
|
|
@@ -1420,114 +1469,250 @@ async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
|
1420
1469
|
}
|
|
1421
1470
|
}
|
|
1422
1471
|
|
|
1423
|
-
async function
|
|
1424
|
-
|
|
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
|
+
}
|
|
1425
1481
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
const recoverInvalidPrimary = async () => {
|
|
1431
|
-
const status = await reconstructFromLedger(persistenceOptions, client)
|
|
1432
|
-
if (status !== "reconstructed") return "invalid"
|
|
1433
|
-
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
|
|
1434
1486
|
try {
|
|
1435
|
-
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) {
|
|
1436
1502
|
await logPluginError(
|
|
1437
1503
|
client,
|
|
1438
|
-
`
|
|
1504
|
+
`Skipped persisted goal state: file is not regular or exceeds ${MAX_STATE_FILE_BYTES} bytes.`,
|
|
1439
1505
|
)
|
|
1440
|
-
|
|
1441
|
-
await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
|
|
1442
|
-
return "invalid"
|
|
1506
|
+
return { status: "invalid" }
|
|
1443
1507
|
}
|
|
1444
|
-
|
|
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" }
|
|
1445
1513
|
}
|
|
1446
1514
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
if (!
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
currentRuntime().migrationLease = migrationLease
|
|
1453
|
-
} catch (error) {
|
|
1454
|
-
await logPluginError(client, `Skipped legacy state migration because another process owns ${path}.`, error)
|
|
1455
|
-
continue
|
|
1456
|
-
}
|
|
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" }
|
|
1457
1520
|
}
|
|
1458
|
-
|
|
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
|
|
1459
1618
|
try {
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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)
|
|
1469
1653
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
await
|
|
1474
|
-
|
|
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
|
+
}
|
|
1475
1664
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
await
|
|
1479
|
-
if (primary) return "invalid"
|
|
1480
|
-
await migrationLease?.release()
|
|
1481
|
-
continue
|
|
1665
|
+
return
|
|
1666
|
+
} finally {
|
|
1667
|
+
await migrationLease.release()
|
|
1482
1668
|
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
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
|
+
}
|
|
1483
1675
|
|
|
1484
|
-
|
|
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()}`
|
|
1485
1686
|
try {
|
|
1486
|
-
|
|
1687
|
+
await fs.rename(persistence.stateFilePath, quarantinePath)
|
|
1688
|
+
await logPluginError(
|
|
1689
|
+
client,
|
|
1690
|
+
`Preserved invalid persisted goal state at ${quarantinePath} before ledger recovery.`,
|
|
1691
|
+
)
|
|
1487
1692
|
} catch (error) {
|
|
1488
|
-
await logPluginError(client, "
|
|
1489
|
-
if (primary) return recoverInvalidPrimary()
|
|
1490
|
-
await migrationLease?.release()
|
|
1491
|
-
continue
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
if (status === "loaded") {
|
|
1495
|
-
// Cross-check: the ledger is written before the state file for terminal
|
|
1496
|
-
// events (completed, cleared). If the terminal persist succeeded in the
|
|
1497
|
-
// ledger but the state file write failed (e.g. process killed between the
|
|
1498
|
-
// two writes), the reloaded state may still have the goal as active. Remove
|
|
1499
|
-
// any loaded active goals whose goalId has a terminal ledger entry.
|
|
1500
|
-
await reconcileLoadedStateWithLedger(persistenceOptions, client)
|
|
1501
|
-
if (primary) return "loaded"
|
|
1502
|
-
persistenceOptions.migrationClaim = { path, lease: migrationLease }
|
|
1503
|
-
currentRuntime().migrationLease = migrationLease
|
|
1504
|
-
return "migrated"
|
|
1693
|
+
await logPluginError(client, "Could not quarantine invalid persisted goal state", error)
|
|
1505
1694
|
}
|
|
1506
|
-
// status === "invalid": preserve a present-but-corrupt primary; for a
|
|
1507
|
-
// fallback, keep trying the next candidate.
|
|
1508
|
-
if (primary) return recoverInvalidPrimary()
|
|
1509
|
-
await migrationLease?.release()
|
|
1510
1695
|
}
|
|
1511
|
-
|
|
1512
|
-
// No state file found at any candidate path → try reconstructing from the
|
|
1513
|
-
// append-only ledger before giving up.
|
|
1514
|
-
return reconstructFromLedger(persistenceOptions, client)
|
|
1696
|
+
return recovered
|
|
1515
1697
|
}
|
|
1516
1698
|
|
|
1517
1699
|
// Last-resort recovery: when the main state file is absent, rebuild still-active
|
|
1518
1700
|
// goals from the append-only ledger so a lost/rotated state file does not drop
|
|
1519
1701
|
// in-flight goals. Recovered goals are paused (via deserializeGoal).
|
|
1520
|
-
async function reconstructFromLedger(persistenceOptions, client) {
|
|
1702
|
+
async function reconstructFromLedger(persistenceOptions, client, onlySessionID = null) {
|
|
1521
1703
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1522
1704
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
1523
1705
|
retentionFiles: persistenceOptions.ledgerRetentionFiles,
|
|
1524
1706
|
})
|
|
1525
1707
|
if (!entries.length) return "missing"
|
|
1526
1708
|
|
|
1527
|
-
const reconstructed = reconstructGoalsFromLedger(entries)
|
|
1709
|
+
const reconstructed = reconstructGoalsFromLedger(entries).filter(
|
|
1710
|
+
(goal) => !onlySessionID || goal.sessionID === onlySessionID,
|
|
1711
|
+
)
|
|
1528
1712
|
if (!reconstructed.length) return "missing"
|
|
1529
1713
|
|
|
1530
|
-
|
|
1714
|
+
if (onlySessionID) clearSessionRuntimeState(onlySessionID)
|
|
1715
|
+
else clearRuntimeState()
|
|
1531
1716
|
const focusCandidates = new Map()
|
|
1532
1717
|
for (const stub of reconstructed) {
|
|
1533
1718
|
const normalized = normalizePersistedGoal(stub)
|
|
@@ -1539,6 +1724,7 @@ async function reconstructFromLedger(persistenceOptions, client) {
|
|
|
1539
1724
|
}
|
|
1540
1725
|
}
|
|
1541
1726
|
for (const [sessionID, goals] of sessionGoals.entries()) {
|
|
1727
|
+
if (onlySessionID && sessionID !== onlySessionID) continue
|
|
1542
1728
|
const preferred = focusCandidates.get(sessionID)
|
|
1543
1729
|
const focused = (preferred && goals.get(preferred)) || goals.values().next().value
|
|
1544
1730
|
if (focused) focusGoal(sessionID, focused)
|
|
@@ -1550,55 +1736,46 @@ async function reconstructFromLedger(persistenceOptions, client) {
|
|
|
1550
1736
|
return goalStates.size > 0 ? "reconstructed" : "missing"
|
|
1551
1737
|
}
|
|
1552
1738
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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) => ({
|
|
1574
1761
|
...result,
|
|
1575
1762
|
sessionID,
|
|
1576
1763
|
history: [...(result.history || [])],
|
|
1577
1764
|
checkpoints: [...(result.checkpoints || [])],
|
|
1578
1765
|
lastCheckpoint: result.lastCheckpoint || null,
|
|
1579
1766
|
})),
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
orderedSessions: [...sessionOrdered].slice(-MAX_PERSISTED_ENTRIES),
|
|
1591
|
-
},
|
|
1592
|
-
null,
|
|
1593
|
-
2,
|
|
1594
|
-
),
|
|
1595
|
-
{ encoding: "utf8", mode: 0o600 },
|
|
1596
|
-
)
|
|
1597
|
-
await fs.rename(tmpPath, persistenceOptions.stateFilePath)
|
|
1598
|
-
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))
|
|
1599
1777
|
return true
|
|
1600
1778
|
} catch (error) {
|
|
1601
|
-
await fs.rm(tmpPath, { force: true }).catch(() => {})
|
|
1602
1779
|
await logPluginError(client, "Failed to persist goal state", error)
|
|
1603
1780
|
return false
|
|
1604
1781
|
}
|
|
@@ -2342,10 +2519,6 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2342
2519
|
return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
|
|
2343
2520
|
if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
|
|
2344
2521
|
return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
|
|
2345
|
-
if (!goalStates.has(sessionID) && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
|
|
2346
|
-
return `The plugin already tracks ${MAX_PERSISTED_ENTRIES} live goals; clear or complete one before creating another.`
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
2522
|
const options = normalizeOptions({
|
|
2350
2523
|
...defaultGoalOptions,
|
|
2351
2524
|
...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
|
|
@@ -2371,7 +2544,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2371
2544
|
lastGoalResults.delete(sessionID)
|
|
2372
2545
|
registerSessionGoal(goal)
|
|
2373
2546
|
focusGoal(sessionID, goal)
|
|
2374
|
-
await persist()
|
|
2547
|
+
await persist(sessionID)
|
|
2375
2548
|
// Escape in the tool result only: goal.condition is stored raw so callers
|
|
2376
2549
|
// that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
|
|
2377
2550
|
// themselves. Escaping here prevents XML metacharacters in user-supplied
|
|
@@ -2453,7 +2626,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2453
2626
|
goal.stopReason = "audit rejected"
|
|
2454
2627
|
goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
2455
2628
|
pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
2456
|
-
await persist()
|
|
2629
|
+
await persist(sessionID)
|
|
2457
2630
|
return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /${commandName} resume after addressing the issue.`
|
|
2458
2631
|
}
|
|
2459
2632
|
}
|
|
@@ -2468,7 +2641,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2468
2641
|
cleanupGoal(sessionID)
|
|
2469
2642
|
// Advance an ordered sequence just like the marker path does.
|
|
2470
2643
|
if (ordered) promoteNextOrderedGoal(sessionID)
|
|
2471
|
-
const durable = await persistFinal("completion", ledgerDurable)
|
|
2644
|
+
const durable = await persistFinal(sessionID, "completion", ledgerDurable)
|
|
2472
2645
|
if (durable === false) {
|
|
2473
2646
|
restoreAfterTerminalPersistenceFailure(sessionID, goal, { ordered })
|
|
2474
2647
|
return "Completion verified, but terminal state could not be persisted. Goal remains paused."
|
|
@@ -2512,7 +2685,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2512
2685
|
if (!messages.length) {
|
|
2513
2686
|
return "Nothing to update. Provide `objective` and/or `status`."
|
|
2514
2687
|
}
|
|
2515
|
-
await persist()
|
|
2688
|
+
await persist(sessionID)
|
|
2516
2689
|
return messages.join(" ")
|
|
2517
2690
|
}
|
|
2518
2691
|
|
|
@@ -2528,7 +2701,7 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2528
2701
|
sessionGoals.delete(sessionID)
|
|
2529
2702
|
cleanupGoal(sessionID)
|
|
2530
2703
|
lastGoalResults.delete(sessionID)
|
|
2531
|
-
await persistFinal("clear")
|
|
2704
|
+
await persistFinal(sessionID, "clear")
|
|
2532
2705
|
return "Goal cleared."
|
|
2533
2706
|
}
|
|
2534
2707
|
|
|
@@ -2553,11 +2726,12 @@ async function loadOpencodePluginModule() {
|
|
|
2553
2726
|
return opencodePluginModulePromise
|
|
2554
2727
|
}
|
|
2555
2728
|
|
|
2556
|
-
function buildAgentTools(toolHelper, handlers) {
|
|
2729
|
+
function buildAgentTools(toolHelper, handlers, ensureSessionLoaded = async () => true) {
|
|
2557
2730
|
const schema = toolHelper.schema
|
|
2558
2731
|
const run = (handler) => async (args, ctx) => {
|
|
2559
2732
|
const sessionID = agentToolSessionID(ctx)
|
|
2560
2733
|
if (!sessionID) return "No session id available for the goal tool."
|
|
2734
|
+
await ensureSessionLoaded(sessionID)
|
|
2561
2735
|
return handler(sessionID, args || {})
|
|
2562
2736
|
}
|
|
2563
2737
|
// Canonical tools use a small, versioned machine-readable envelope. Keep the
|
|
@@ -2571,6 +2745,7 @@ function buildAgentTools(toolHelper, handlers) {
|
|
|
2571
2745
|
goalToolFailure("missing_session", "No session id available for the goal tool."),
|
|
2572
2746
|
)
|
|
2573
2747
|
}
|
|
2748
|
+
await ensureSessionLoaded(sessionID)
|
|
2574
2749
|
return serializeGoalToolResult(operation, await handler(sessionID, args || {}))
|
|
2575
2750
|
}
|
|
2576
2751
|
|
|
@@ -2902,30 +3077,67 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2902
3077
|
env: pluginOptions.env,
|
|
2903
3078
|
cwd: pluginOptions.cwd || directory,
|
|
2904
3079
|
})
|
|
2905
|
-
if (persistenceOptions.persistState) {
|
|
2906
|
-
await assertSafeProjectPersistencePath(persistenceOptions)
|
|
2907
|
-
currentRuntime().persistenceLease = await acquirePersistenceLease(persistenceOptions.stateFilePath)
|
|
2908
|
-
}
|
|
2909
3080
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
2910
|
-
|
|
2911
|
-
//
|
|
2912
|
-
//
|
|
2913
|
-
|
|
2914
|
-
const persist = () => {
|
|
2915
|
-
|
|
2916
|
-
|
|
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
|
|
2917
3089
|
.catch(() => false)
|
|
2918
|
-
.then(() => persistState(
|
|
2919
|
-
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
|
+
}
|
|
2920
3133
|
}
|
|
2921
|
-
runtime.drainPersistence = () => persistChain.catch(() => false)
|
|
2922
3134
|
|
|
2923
3135
|
// Fail closed when persisting a terminal state (complete/blocked)
|
|
2924
3136
|
// fails, surface it loudly. The terminal event is already in the append-only
|
|
2925
3137
|
// ledger, so it stays recoverable across a restart even though the main state
|
|
2926
3138
|
// file write did not land.
|
|
2927
|
-
const persistTerminalState = async (label, ledgerDurable = false) => {
|
|
2928
|
-
const stateDurable = await persist()
|
|
3139
|
+
const persistTerminalState = async (sessionID, label, ledgerDurable = false) => {
|
|
3140
|
+
const stateDurable = await persist(sessionID)
|
|
2929
3141
|
if (!stateDurable && persistenceOptions.persistState) {
|
|
2930
3142
|
await logPluginError(
|
|
2931
3143
|
client,
|
|
@@ -2939,10 +3151,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2939
3151
|
|
|
2940
3152
|
// Route lifecycle events to the JSONL ledger only when persistence is on.
|
|
2941
3153
|
if (persistenceOptions.persistState) {
|
|
2942
|
-
setLedgerSink((entry) =>
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
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
|
+
})
|
|
2946
3162
|
} else {
|
|
2947
3163
|
setLedgerSink(null)
|
|
2948
3164
|
}
|
|
@@ -2985,34 +3201,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2985
3201
|
: null
|
|
2986
3202
|
|
|
2987
3203
|
clearRuntimeState()
|
|
2988
|
-
const persistedStateStatus = await loadPersistedState(persistenceOptions, client)
|
|
2989
|
-
pruneGoalResults(defaultGoalOptions)
|
|
2990
|
-
// "migrated" = loaded from a legacy/XDG fallback path; "reconstructed" =
|
|
2991
|
-
// rebuilt from the ledger. Both persist forward to the resolved path.
|
|
2992
|
-
if (
|
|
2993
|
-
persistedStateStatus === "loaded" ||
|
|
2994
|
-
persistedStateStatus === "missing" ||
|
|
2995
|
-
persistedStateStatus === "migrated" ||
|
|
2996
|
-
persistedStateStatus === "reconstructed"
|
|
2997
|
-
) {
|
|
2998
|
-
const initialPersisted = await persist()
|
|
2999
|
-
if (persistedStateStatus === "migrated" && persistenceOptions.migrationClaim) {
|
|
3000
|
-
const { path, lease } = persistenceOptions.migrationClaim
|
|
3001
|
-
if (initialPersisted) {
|
|
3002
|
-
const backupPath = `${path}.migrated.${Date.now()}.${randomUUID()}`
|
|
3003
|
-
try {
|
|
3004
|
-
await fs.rename(path, backupPath)
|
|
3005
|
-
} catch (error) {
|
|
3006
|
-
await logPluginError(client, `Could not retire migrated legacy goal state at ${path}.`, error)
|
|
3007
|
-
}
|
|
3008
|
-
}
|
|
3009
|
-
await lease.release()
|
|
3010
|
-
runtime.migrationLease = null
|
|
3011
|
-
persistenceOptions.migrationClaim = null
|
|
3012
|
-
}
|
|
3013
|
-
}
|
|
3014
3204
|
|
|
3015
|
-
const agentToolHandlers = buildAgentToolHandlers({
|
|
3205
|
+
const agentToolHandlers = buildAgentToolHandlers({
|
|
3206
|
+
defaultGoalOptions,
|
|
3207
|
+
persist,
|
|
3208
|
+
persistTerminalState,
|
|
3209
|
+
completionAuditor,
|
|
3210
|
+
commandName,
|
|
3211
|
+
})
|
|
3016
3212
|
|
|
3017
3213
|
const abortAcceptedContinuation = async (sessionID) => {
|
|
3018
3214
|
const runtimeState = currentRuntime()
|
|
@@ -3043,7 +3239,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3043
3239
|
goal.continuationClaim = null
|
|
3044
3240
|
pushHistory(goal, "paused", history)
|
|
3045
3241
|
activeContinues.delete(sessionID)
|
|
3046
|
-
await persist()
|
|
3242
|
+
await persist(sessionID)
|
|
3047
3243
|
if (abortAccepted) await abortAcceptedContinuation(sessionID)
|
|
3048
3244
|
return true
|
|
3049
3245
|
}
|
|
@@ -3113,7 +3309,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3113
3309
|
}
|
|
3114
3310
|
|
|
3115
3311
|
goal.continuationClaim = { runId: runID, sourceAssistantMessageID }
|
|
3116
|
-
const claimPersisted = await persist()
|
|
3312
|
+
const claimPersisted = await persist(sessionID)
|
|
3117
3313
|
if (!claimPersisted && persistenceOptions.persistState) {
|
|
3118
3314
|
goal.continuationClaim = null
|
|
3119
3315
|
goal.stopped = true
|
|
@@ -3135,6 +3331,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3135
3331
|
},
|
|
3136
3332
|
"chat.params": async (input) => {
|
|
3137
3333
|
if (!input?.sessionID) return
|
|
3334
|
+
await ensureSessionLoaded(input.sessionID)
|
|
3138
3335
|
const context = normalizeExecutionContext({
|
|
3139
3336
|
agent: input.agent,
|
|
3140
3337
|
model: input.model,
|
|
@@ -3145,6 +3342,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3145
3342
|
"chat.message": async (input, output) => {
|
|
3146
3343
|
const sessionID = input?.sessionID
|
|
3147
3344
|
if (!sessionID) return
|
|
3345
|
+
await ensureSessionLoaded(sessionID)
|
|
3148
3346
|
const context = normalizeExecutionContext(input)
|
|
3149
3347
|
if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context)
|
|
3150
3348
|
|
|
@@ -3165,7 +3363,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3165
3363
|
},
|
|
3166
3364
|
"tool.execute.before": async (input) => {
|
|
3167
3365
|
const sessionID = input?.sessionID
|
|
3168
|
-
if (!sessionID
|
|
3366
|
+
if (!sessionID) return
|
|
3367
|
+
await ensureSessionLoaded(sessionID)
|
|
3368
|
+
if (!currentRuntime().readOnlyCommandGuards.has(sessionID)) return
|
|
3169
3369
|
if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
|
|
3170
3370
|
throw new Error(
|
|
3171
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.`,
|
|
@@ -3184,6 +3384,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3184
3384
|
}
|
|
3185
3385
|
const args = input.arguments.trim()
|
|
3186
3386
|
const sessionID = input.sessionID
|
|
3387
|
+
await ensureSessionLoaded(sessionID)
|
|
3187
3388
|
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
3188
3389
|
pruneGoalResults(defaultGoalOptions)
|
|
3189
3390
|
|
|
@@ -3246,7 +3447,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3246
3447
|
sessionGoals.delete(sessionID)
|
|
3247
3448
|
cleanupGoal(sessionID)
|
|
3248
3449
|
lastGoalResults.delete(sessionID)
|
|
3249
|
-
await persist()
|
|
3450
|
+
await persist(sessionID)
|
|
3250
3451
|
output.parts = [makeTextPart("Goal cleared.")]
|
|
3251
3452
|
return
|
|
3252
3453
|
}
|
|
@@ -3265,7 +3466,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3265
3466
|
goal.continuationClaim = null
|
|
3266
3467
|
activeContinues.delete(sessionID)
|
|
3267
3468
|
pushHistory(goal, "paused", "User paused the active goal.")
|
|
3268
|
-
await persist()
|
|
3469
|
+
await persist(sessionID)
|
|
3269
3470
|
await abortAcceptedContinuation(sessionID)
|
|
3270
3471
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
3271
3472
|
return
|
|
@@ -3291,7 +3492,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3291
3492
|
goal.blockedReason = ""
|
|
3292
3493
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
3293
3494
|
pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
|
|
3294
|
-
await persist()
|
|
3495
|
+
await persist(sessionID)
|
|
3295
3496
|
output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
|
|
3296
3497
|
return
|
|
3297
3498
|
}
|
|
@@ -3331,7 +3532,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3331
3532
|
goal.continuationClaim = null
|
|
3332
3533
|
goal.lastStatus = "Goal objective updated."
|
|
3333
3534
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
3334
|
-
await persist()
|
|
3535
|
+
await persist(sessionID)
|
|
3335
3536
|
output.parts = [
|
|
3336
3537
|
makeTextPart(
|
|
3337
3538
|
[
|
|
@@ -3371,11 +3572,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3371
3572
|
output.parts = [makeTextPart(`An ordered sequence may contain at most ${MAX_LIVE_GOALS_PER_SESSION} goals.`)]
|
|
3372
3573
|
return
|
|
3373
3574
|
}
|
|
3374
|
-
const existingCount = listSessionGoals(sessionID).length
|
|
3375
|
-
if (totalLiveGoals() - existingCount + objectives.length > MAX_PERSISTED_ENTRIES) {
|
|
3376
|
-
output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
|
|
3377
|
-
return
|
|
3378
|
-
}
|
|
3379
3575
|
if (objectives.some((objective) => objective.length > MAX_GOAL_OBJECTIVE_LENGTH)) {
|
|
3380
3576
|
output.parts = [makeTextPart(`Each goal objective must be ${MAX_GOAL_OBJECTIVE_LENGTH} characters or fewer.`)]
|
|
3381
3577
|
return
|
|
@@ -3412,7 +3608,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3412
3608
|
})
|
|
3413
3609
|
focusGoal(sessionID, firstGoal)
|
|
3414
3610
|
sessionOrdered.add(sessionID)
|
|
3415
|
-
await persist()
|
|
3611
|
+
await persist(sessionID)
|
|
3416
3612
|
output.parts = [
|
|
3417
3613
|
makeTextPart(
|
|
3418
3614
|
[
|
|
@@ -3471,7 +3667,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3471
3667
|
resumeGoalClock(target)
|
|
3472
3668
|
pushHistory(target, "focused", "Brought into focus as the session's active goal.")
|
|
3473
3669
|
focusGoal(sessionID, target)
|
|
3474
|
-
await persist()
|
|
3670
|
+
await persist(sessionID)
|
|
3475
3671
|
output.parts = [
|
|
3476
3672
|
makeTextPart(
|
|
3477
3673
|
[
|
|
@@ -3511,10 +3707,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3511
3707
|
output.parts = [makeTextPart(`A session may contain at most ${MAX_LIVE_GOALS_PER_SESSION} live goals.`)]
|
|
3512
3708
|
return
|
|
3513
3709
|
}
|
|
3514
|
-
if (totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
|
|
3515
|
-
output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
|
|
3516
|
-
return
|
|
3517
|
-
}
|
|
3518
3710
|
// Keep the current goal (background it) and focus a new one.
|
|
3519
3711
|
const current = goalStates.get(sessionID)
|
|
3520
3712
|
if (current) {
|
|
@@ -3531,7 +3723,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3531
3723
|
)
|
|
3532
3724
|
registerSessionGoal(added)
|
|
3533
3725
|
focusGoal(sessionID, added)
|
|
3534
|
-
await persist()
|
|
3726
|
+
await persist(sessionID)
|
|
3535
3727
|
const total = listSessionGoals(sessionID).length
|
|
3536
3728
|
output.parts = [
|
|
3537
3729
|
makeTextPart(
|
|
@@ -3551,10 +3743,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3551
3743
|
}
|
|
3552
3744
|
|
|
3553
3745
|
const replacedGoal = goalStates.get(sessionID)
|
|
3554
|
-
if (!replacedGoal && totalLiveGoals() >= MAX_PERSISTED_ENTRIES) {
|
|
3555
|
-
output.parts = [makeTextPart(`The plugin may track at most ${MAX_PERSISTED_ENTRIES} live goals across sessions.`)]
|
|
3556
|
-
return
|
|
3557
|
-
}
|
|
3558
3746
|
const goal = buildGoalState(sessionID, parsed.condition, parsed.options, parsed.meta)
|
|
3559
3747
|
|
|
3560
3748
|
pushHistory(
|
|
@@ -3573,7 +3761,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3573
3761
|
lastGoalResults.delete(sessionID)
|
|
3574
3762
|
registerSessionGoal(goal)
|
|
3575
3763
|
focusGoal(sessionID, goal)
|
|
3576
|
-
await persist()
|
|
3764
|
+
await persist(sessionID)
|
|
3577
3765
|
output.parts = [
|
|
3578
3766
|
makeTextPart(
|
|
3579
3767
|
[
|
|
@@ -3605,6 +3793,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3605
3793
|
},
|
|
3606
3794
|
|
|
3607
3795
|
event: async ({ event }) => {
|
|
3796
|
+
const eventSessionID = getSessionID(event) || messageSessionID(messageInfoFromEvent(event))
|
|
3797
|
+
if (eventSessionID) await ensureSessionLoaded(eventSessionID)
|
|
3798
|
+
|
|
3608
3799
|
if (event?.type === "session.status") {
|
|
3609
3800
|
const sessionID = getSessionID(event)
|
|
3610
3801
|
const status = event?.properties?.status?.type || event?.data?.status?.type
|
|
@@ -3641,7 +3832,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3641
3832
|
if (!goal) return
|
|
3642
3833
|
goal.messageIDs = new Set()
|
|
3643
3834
|
goal.totalTokens = 0
|
|
3644
|
-
await persist()
|
|
3835
|
+
await persist(sessionID)
|
|
3645
3836
|
return
|
|
3646
3837
|
}
|
|
3647
3838
|
|
|
@@ -3700,7 +3891,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3700
3891
|
changed = true
|
|
3701
3892
|
}
|
|
3702
3893
|
|
|
3703
|
-
if (changed) await persist()
|
|
3894
|
+
if (changed) await persist(messageSessionID(message))
|
|
3704
3895
|
return
|
|
3705
3896
|
}
|
|
3706
3897
|
|
|
@@ -3835,7 +4026,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3835
4026
|
auditedGoal.stopReason = "audit rejected"
|
|
3836
4027
|
auditedGoal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /${commandName} resume.`
|
|
3837
4028
|
pushHistory(auditedGoal, "audit-rejected", `Completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
3838
|
-
await persist()
|
|
4029
|
+
await persist(sessionID)
|
|
3839
4030
|
await announceAudit(sessionID, `Audit result: completion rejected — ${summarizeText(reason, 160)}.`)
|
|
3840
4031
|
return
|
|
3841
4032
|
}
|
|
@@ -3863,7 +4054,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3863
4054
|
if (ordered) {
|
|
3864
4055
|
promoteNextOrderedGoal(sessionID)
|
|
3865
4056
|
}
|
|
3866
|
-
const durable = await persistTerminalState("completion", ledgerDurable)
|
|
4057
|
+
const durable = await persistTerminalState(sessionID, "completion", ledgerDurable)
|
|
3867
4058
|
if (durable === false) {
|
|
3868
4059
|
restoreAfterTerminalPersistenceFailure(sessionID, activeGoalAfterMessages, { ordered })
|
|
3869
4060
|
await announceAudit(
|
|
@@ -3897,7 +4088,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3897
4088
|
blockedGoal.stopped = true
|
|
3898
4089
|
blockedGoal.stopReason = "blocked"
|
|
3899
4090
|
const ledgerDurable = pushHistory(blockedGoal, "blocked", reason)
|
|
3900
|
-
const durable = await persistTerminalState("blocked", ledgerDurable)
|
|
4091
|
+
const durable = await persistTerminalState(sessionID, "blocked", ledgerDurable)
|
|
3901
4092
|
if (durable === false) {
|
|
3902
4093
|
blockedGoal.stopReason = "terminal persistence failed"
|
|
3903
4094
|
blockedGoal.lastStatus = "Blocked state could not be persisted; goal remains paused."
|
|
@@ -3937,7 +4128,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3937
4128
|
claimedGoal.stopReason = limitReason
|
|
3938
4129
|
claimedGoal.lastStatus = `${limitReason}; requested final handoff.`
|
|
3939
4130
|
pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`)
|
|
3940
|
-
await persist()
|
|
4131
|
+
await persist(sessionID)
|
|
3941
4132
|
currentRuntime().promptInFlightSessions.add(sessionID)
|
|
3942
4133
|
let response
|
|
3943
4134
|
try {
|
|
@@ -3958,7 +4149,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3958
4149
|
activeGoalAfterMessages.lastStatus = limitReason
|
|
3959
4150
|
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
3960
4151
|
}
|
|
3961
|
-
await persist()
|
|
4152
|
+
await persist(sessionID)
|
|
3962
4153
|
return
|
|
3963
4154
|
}
|
|
3964
4155
|
|
|
@@ -4013,7 +4204,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4013
4204
|
"paused",
|
|
4014
4205
|
`Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
|
|
4015
4206
|
)
|
|
4016
|
-
await persist()
|
|
4207
|
+
await persist(sessionID)
|
|
4017
4208
|
return
|
|
4018
4209
|
}
|
|
4019
4210
|
|
|
@@ -4058,7 +4249,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4058
4249
|
"paused",
|
|
4059
4250
|
`Paused after ${activeGoalAfterMessages.noToolCallTurns} continuation turn(s) that produced no tool calls.`,
|
|
4060
4251
|
)
|
|
4061
|
-
await persist()
|
|
4252
|
+
await persist(sessionID)
|
|
4062
4253
|
return
|
|
4063
4254
|
}
|
|
4064
4255
|
|
|
@@ -4107,7 +4298,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4107
4298
|
// promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
|
|
4108
4299
|
// the hard-limit path which also persists before its promptAsync call.
|
|
4109
4300
|
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
4110
|
-
await persist()
|
|
4301
|
+
await persist(sessionID)
|
|
4111
4302
|
}
|
|
4112
4303
|
|
|
4113
4304
|
activeGoalBeforePrompt.turnCount += 1
|
|
@@ -4147,7 +4338,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4147
4338
|
"paused",
|
|
4148
4339
|
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
4149
4340
|
)
|
|
4150
|
-
await persist()
|
|
4341
|
+
await persist(sessionID)
|
|
4151
4342
|
return
|
|
4152
4343
|
}
|
|
4153
4344
|
}
|
|
@@ -4208,7 +4399,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4208
4399
|
)
|
|
4209
4400
|
}
|
|
4210
4401
|
}
|
|
4211
|
-
await persist()
|
|
4402
|
+
await persist(sessionID)
|
|
4212
4403
|
} catch (error) {
|
|
4213
4404
|
const activeGoalAfterError = currentGoal(sessionID, goalID, runID)
|
|
4214
4405
|
if (activeGoalAfterError) {
|
|
@@ -4228,7 +4419,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4228
4419
|
activeGoalAfterError.stopReason = "auto-continue failures"
|
|
4229
4420
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /${commandName} resume to retry.`
|
|
4230
4421
|
}
|
|
4231
|
-
await persist()
|
|
4422
|
+
await persist(sessionID)
|
|
4232
4423
|
}
|
|
4233
4424
|
await logPluginError(client, "Auto-continue failed", error)
|
|
4234
4425
|
} finally {
|
|
@@ -4245,6 +4436,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4245
4436
|
|
|
4246
4437
|
"experimental.chat.system.transform": async (input, output) => {
|
|
4247
4438
|
if (!input.sessionID) return
|
|
4439
|
+
await ensureSessionLoaded(input.sessionID)
|
|
4248
4440
|
|
|
4249
4441
|
const goal = goalStates.get(input.sessionID)
|
|
4250
4442
|
if (!goal) return
|
|
@@ -4294,6 +4486,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4294
4486
|
|
|
4295
4487
|
"experimental.session.compacting": async (input, output) => {
|
|
4296
4488
|
if (!input?.sessionID || !output) return
|
|
4489
|
+
await ensureSessionLoaded(input.sessionID)
|
|
4297
4490
|
const goal = goalStates.get(input.sessionID)
|
|
4298
4491
|
if (!goal) return
|
|
4299
4492
|
const context = buildCompactionContext(goal)
|
|
@@ -4313,6 +4506,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4313
4506
|
// auto-continue to avoid two continuations racing after a compaction.
|
|
4314
4507
|
// Paused/stopped goals leave the native behavior untouched.
|
|
4315
4508
|
if (!input?.sessionID || !output) return
|
|
4509
|
+
await ensureSessionLoaded(input.sessionID)
|
|
4316
4510
|
const goal = goalStates.get(input.sessionID)
|
|
4317
4511
|
if (!goal || goal.stopped) return
|
|
4318
4512
|
output.enabled = false
|
|
@@ -4334,7 +4528,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
4334
4528
|
const toolModule = await loadOpencodePluginModule()
|
|
4335
4529
|
if (toolModule?.tool?.schema) {
|
|
4336
4530
|
try {
|
|
4337
|
-
hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers)
|
|
4531
|
+
hooks.tool = buildAgentTools(toolModule.tool, agentToolHandlers, ensureSessionLoaded)
|
|
4338
4532
|
} catch (error) {
|
|
4339
4533
|
await logPluginError(client, "Failed to register goal agent tools", error)
|
|
4340
4534
|
}
|
|
@@ -4376,13 +4570,17 @@ function bindHooksToRuntime(hooks, runtime) {
|
|
|
4376
4570
|
if (runtime.disposed) return
|
|
4377
4571
|
runtime.disposed = true
|
|
4378
4572
|
for (const controller of runtime.continuationControllers.values()) controller.abort()
|
|
4379
|
-
await runtime.
|
|
4573
|
+
await Promise.allSettled([...runtime.sessionLoadPromises.values()])
|
|
4574
|
+
for (const persistence of runtime.sessionPersistence.values()) {
|
|
4575
|
+
await persistence.persistChain.catch(() => false)
|
|
4576
|
+
}
|
|
4380
4577
|
clearRuntimeState()
|
|
4381
4578
|
setLedgerSink(null)
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
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()
|
|
4386
4584
|
})
|
|
4387
4585
|
return bound
|
|
4388
4586
|
}
|
|
@@ -4396,11 +4594,13 @@ export const GoalPlugin = async (context = {}, pluginOptions = {}) => {
|
|
|
4396
4594
|
return bindHooksToRuntime(hooks, runtime)
|
|
4397
4595
|
} catch (error) {
|
|
4398
4596
|
runtime.disposed = true
|
|
4399
|
-
await runtime.
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
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()
|
|
4404
4604
|
throw error
|
|
4405
4605
|
}
|
|
4406
4606
|
})
|
|
@@ -4457,6 +4657,7 @@ export const testInternals = {
|
|
|
4457
4657
|
normalizeMessageUsage,
|
|
4458
4658
|
normalizeUsage,
|
|
4459
4659
|
normalizePersistenceOptions,
|
|
4660
|
+
sessionPathsFor,
|
|
4460
4661
|
userInterventionDetected,
|
|
4461
4662
|
outputTokensForMessage,
|
|
4462
4663
|
parseGoalArguments,
|
package/src/persistence-lease.js
CHANGED
|
@@ -23,9 +23,9 @@ async function readOwner(lockPath) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Hold an exclusive
|
|
27
|
-
* deliberately rejects a second writer instead of allowing stale
|
|
28
|
-
*
|
|
26
|
+
* Hold an exclusive session lease for the plugin instance lifetime. This
|
|
27
|
+
* deliberately rejects a second writer instead of allowing stale snapshots to
|
|
28
|
+
* overwrite the same session's state.
|
|
29
29
|
*/
|
|
30
30
|
export async function acquirePersistenceLease(
|
|
31
31
|
stateFilePath,
|