pantheon-opencode 1.5.0-beta.6 → 1.5.0-beta.8
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 +77 -0
- package/README.md +31 -10
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/pyproject.toml +10 -2
- package/scripts/mcp_persistence_server.py +24 -5
- package/scripts/versioning.mjs +108 -7
- package/src/agents/mnemosyne.md +1 -1
- package/src/mcp/mcp_persistence_server.py +24 -5
- package/src/pantheon/background-job-board.ts +29 -1
- package/src/pantheon/cost-command.ts +249 -3
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +235 -18
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/v2-events.ts +14 -6
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort salvage of complete top-level JSON objects from a corrupt
|
|
3
|
+
* `state.json`. The file is normally a JSON array; a torn write can leave a
|
|
4
|
+
* truncated tail (or interleaved NUL bytes). We scan for balanced `{…}`
|
|
5
|
+
* objects, parsing each independently, so every intact record survives.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from file-persistence.ts to keep that module within the 300-line
|
|
8
|
+
* ceiling. Exported for tests.
|
|
9
|
+
*/
|
|
10
|
+
import type { BackgroundJobRecord } from './background-job-board.ts'
|
|
11
|
+
|
|
12
|
+
export function salvageRecords(content: string): BackgroundJobRecord[] {
|
|
13
|
+
const records: BackgroundJobRecord[] = []
|
|
14
|
+
let depth = 0
|
|
15
|
+
let start = -1
|
|
16
|
+
let inString = false
|
|
17
|
+
let escaped = false
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < content.length; i++) {
|
|
20
|
+
const ch = content[i]
|
|
21
|
+
|
|
22
|
+
if (inString) {
|
|
23
|
+
if (escaped) {
|
|
24
|
+
escaped = false
|
|
25
|
+
} else if (ch === '\\') {
|
|
26
|
+
escaped = true
|
|
27
|
+
} else if (ch === '"') {
|
|
28
|
+
inString = false
|
|
29
|
+
}
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (ch === '"') {
|
|
34
|
+
inString = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (ch === '{') {
|
|
38
|
+
if (depth === 0) start = i
|
|
39
|
+
depth++
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (ch === '}') {
|
|
43
|
+
if (depth > 0) depth--
|
|
44
|
+
if (depth === 0 && start >= 0) {
|
|
45
|
+
const candidate = content.slice(start, i + 1)
|
|
46
|
+
try {
|
|
47
|
+
const parsed: unknown = JSON.parse(candidate)
|
|
48
|
+
if (
|
|
49
|
+
parsed !== null &&
|
|
50
|
+
typeof parsed === 'object' &&
|
|
51
|
+
typeof (parsed as BackgroundJobRecord).taskID === 'string'
|
|
52
|
+
) {
|
|
53
|
+
records.push(parsed as BackgroundJobRecord)
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Incomplete/partial object — skip it, keep scanning.
|
|
57
|
+
}
|
|
58
|
+
start = -1
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return records
|
|
64
|
+
}
|
|
@@ -1,91 +1,238 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File-based persistence adapter for the BackgroundJobBoard.
|
|
3
3
|
*
|
|
4
|
-
* Stores all jobs in a single `state.json` file
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Stores all jobs in a single `state.json` file. Writes are crash-safe:
|
|
5
|
+
* the payload is written to a UNIQUE temp file, fsynced, then renamed over
|
|
6
|
+
* the destination. Writes to the same path are serialized through an
|
|
7
|
+
* in-process queue so two logical writers (plugin × native) cannot
|
|
8
|
+
* interleave and corrupt the JSON or race the rename.
|
|
9
|
+
*
|
|
10
|
+
* Reads are corruption-tolerant: a malformed `state.json` is moved aside to
|
|
11
|
+
* `<state>.corrupt-<ts>`, a clear log is emitted, and whatever complete
|
|
12
|
+
* records can be salvaged from the array are returned — never a throw, so
|
|
13
|
+
* callers (delete/prune/recover) cannot get stuck in a retry loop.
|
|
7
14
|
*
|
|
8
15
|
* @module file-persistence
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
|
-
import { mkdir, readFile, rename,
|
|
12
|
-
import { dirname } from 'node:path'
|
|
18
|
+
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
|
19
|
+
import { dirname, resolve } from 'node:path'
|
|
13
20
|
|
|
14
21
|
import type { BackgroundJobRecord, PersistenceAdapter } from './background-job-board.ts'
|
|
22
|
+
import { salvageRecords } from './file-persistence-salvage.ts'
|
|
23
|
+
import { createPantheonLogger } from './logger.ts'
|
|
24
|
+
|
|
25
|
+
export { salvageRecords } from './file-persistence-salvage.ts'
|
|
26
|
+
|
|
27
|
+
const log = createPantheonLogger({ module: 'FilePersistence' })
|
|
15
28
|
|
|
16
29
|
/** Default path for the state file. */
|
|
17
30
|
const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
|
|
18
31
|
|
|
32
|
+
/** Max rename attempts after an ENOENT before giving up (dir/tmp recreated each try). */
|
|
33
|
+
const RENAME_MAX_ATTEMPTS = 3
|
|
34
|
+
|
|
35
|
+
/** Minimal file handle surface used to fsync the temp file before rename. */
|
|
36
|
+
export interface PersistenceFileHandle {
|
|
37
|
+
writeFile(data: string, encoding: 'utf-8'): Promise<void>
|
|
38
|
+
sync(): Promise<void>
|
|
39
|
+
close(): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Filesystem operations used by the adapter. Injectable so tests can fault
|
|
44
|
+
* inject a failing `rename` (ENOENT) deterministically.
|
|
45
|
+
*/
|
|
46
|
+
export interface PersistenceFsOps {
|
|
47
|
+
mkdir(dir: string): Promise<void>
|
|
48
|
+
readFile(path: string, encoding: 'utf-8'): Promise<string>
|
|
49
|
+
rename(from: string, to: string): Promise<void>
|
|
50
|
+
openForWrite(path: string): Promise<PersistenceFileHandle>
|
|
51
|
+
unlink(path: string): Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Real Node.js filesystem implementation (the default). */
|
|
55
|
+
const defaultFsOps: PersistenceFsOps = {
|
|
56
|
+
mkdir: async (dir) => {
|
|
57
|
+
await mkdir(dir, { recursive: true })
|
|
58
|
+
},
|
|
59
|
+
readFile: (path, encoding) => readFile(path, encoding),
|
|
60
|
+
rename: (from, to) => rename(from, to),
|
|
61
|
+
openForWrite: (path) => open(path, 'w'),
|
|
62
|
+
unlink: (path) => unlink(path),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isEnoent(err: unknown): boolean {
|
|
66
|
+
return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* In-process write queues keyed by absolute state path. Shared across
|
|
71
|
+
* adapter INSTANCES so a plugin adapter and a native adapter pointing at the
|
|
72
|
+
* same file serialize their read-modify-write cycles.
|
|
73
|
+
*/
|
|
74
|
+
const writeQueues = new Map<string, Promise<unknown>>()
|
|
75
|
+
|
|
76
|
+
function enqueueWrite<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
77
|
+
const previous = writeQueues.get(key) ?? Promise.resolve()
|
|
78
|
+
const run = previous.then(task, task)
|
|
79
|
+
writeQueues.set(
|
|
80
|
+
key,
|
|
81
|
+
run.then(
|
|
82
|
+
() => undefined,
|
|
83
|
+
() => undefined,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
return run
|
|
87
|
+
}
|
|
88
|
+
|
|
19
89
|
/**
|
|
20
90
|
* File-based persistence adapter.
|
|
21
91
|
*
|
|
22
|
-
* All jobs are stored in a single JSON array in `statePath`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* The board's in-memory `this.jobs` Map is the source of truth —
|
|
26
|
-
* this file is a crash-recovery snapshot. `deleteJob()` removes the
|
|
27
|
-
* record from the snapshot so pruned/reconciled jobs don't resurface
|
|
28
|
-
* after a restart.
|
|
92
|
+
* All jobs are stored in a single JSON array in `statePath`. Writes are
|
|
93
|
+
* atomic and serialized (unique tmp + fsync + rename, with ENOENT retry).
|
|
94
|
+
* Corrupt state is backed up and salvaged instead of throwing.
|
|
29
95
|
*/
|
|
30
96
|
export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
31
97
|
private readonly statePath: string
|
|
32
98
|
private readonly dir: string
|
|
99
|
+
private readonly ops: PersistenceFsOps
|
|
100
|
+
private readonly lockKey: string
|
|
101
|
+
private tmpCounter = 0
|
|
33
102
|
|
|
34
103
|
/**
|
|
35
104
|
* @param statePath Path to the state JSON file (default: `.pantheon/board/state.json`)
|
|
105
|
+
* @param ops Filesystem operations (defaults to real Node.js fs; injectable for tests)
|
|
36
106
|
*/
|
|
37
|
-
constructor(statePath?: string) {
|
|
107
|
+
constructor(statePath?: string, ops: PersistenceFsOps = defaultFsOps) {
|
|
38
108
|
this.statePath = statePath ?? DEFAULT_STATE_PATH
|
|
39
109
|
this.dir = dirname(this.statePath)
|
|
110
|
+
this.ops = ops
|
|
111
|
+
this.lockKey = resolve(this.statePath)
|
|
40
112
|
}
|
|
41
113
|
|
|
42
114
|
/** Ensure the parent directory exists. */
|
|
43
115
|
private async ensureDir(): Promise<void> {
|
|
44
|
-
await mkdir(this.dir
|
|
116
|
+
await this.ops.mkdir(this.dir)
|
|
45
117
|
}
|
|
46
118
|
|
|
47
|
-
/**
|
|
48
|
-
private
|
|
49
|
-
|
|
119
|
+
/** Unique temp path per write — prevents two writers racing the same tmp. */
|
|
120
|
+
private makeTmpPath(): string {
|
|
121
|
+
this.tmpCounter += 1
|
|
122
|
+
const unique = `${process.pid}.${this.tmpCounter}.${Math.random().toString(36).slice(2, 8)}`
|
|
123
|
+
return `${this.statePath}.${unique}.tmp`
|
|
50
124
|
}
|
|
51
125
|
|
|
52
126
|
/**
|
|
53
127
|
* Read all jobs from the state file.
|
|
54
|
-
*
|
|
128
|
+
*
|
|
129
|
+
* Return contract distinguishes three failure modes:
|
|
130
|
+
* - absent file (ENOENT) → `[]` (first run / no jobs yet);
|
|
131
|
+
* - corrupt content → quarantine + `salvageRecords()` (best-effort recovery);
|
|
132
|
+
* - non-ENOENT IO error (e.g. EIO) → `null`, signalling the read failed.
|
|
133
|
+
*
|
|
134
|
+
* Callers doing read-modify-write MUST abort on `null` so a failed read
|
|
135
|
+
* cannot overwrite the on-disk state with a partial snapshot.
|
|
55
136
|
*/
|
|
56
137
|
private async readState(): Promise<BackgroundJobRecord[] | null> {
|
|
138
|
+
let content: string
|
|
57
139
|
try {
|
|
58
|
-
|
|
59
|
-
return JSON.parse(content) as BackgroundJobRecord[]
|
|
140
|
+
content = await this.ops.readFile(this.statePath, 'utf-8')
|
|
60
141
|
} catch (err: unknown) {
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
142
|
+
if (isEnoent(err)) return []
|
|
143
|
+
log.error(`[FilePersistence] Failed to read ${this.statePath}:`, err)
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const parsed: unknown = JSON.parse(content)
|
|
149
|
+
if (!Array.isArray(parsed)) {
|
|
150
|
+
throw new Error('state.json is not a JSON array')
|
|
67
151
|
}
|
|
68
|
-
|
|
152
|
+
return parsed as BackgroundJobRecord[]
|
|
153
|
+
} catch (err: unknown) {
|
|
154
|
+
await this.quarantineCorrupt(err)
|
|
155
|
+
const salvaged = salvageRecords(content)
|
|
156
|
+
log.warn(
|
|
157
|
+
`[FilePersistence] Corrupt ${this.statePath} — recovered ${salvaged.length} record(s)`,
|
|
158
|
+
)
|
|
159
|
+
return salvaged
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Move a corrupt state.json aside so the next write starts from a clean file. */
|
|
164
|
+
private async quarantineCorrupt(cause: unknown): Promise<void> {
|
|
165
|
+
const backupPath = `${this.statePath}.corrupt-${Date.now()}`
|
|
166
|
+
try {
|
|
167
|
+
await this.ops.rename(this.statePath, backupPath)
|
|
168
|
+
log.error(`[FilePersistence] Corrupt ${this.statePath} backed up to ${backupPath}:`, cause)
|
|
169
|
+
} catch (err: unknown) {
|
|
170
|
+
log.error(
|
|
171
|
+
`[FilePersistence] Could not back up corrupt ${this.statePath} — continuing with salvaged state:`,
|
|
172
|
+
err,
|
|
173
|
+
)
|
|
69
174
|
}
|
|
70
175
|
}
|
|
71
176
|
|
|
72
|
-
/** Write the full state array
|
|
177
|
+
/** Write the full state array: unique tmp + fsync + rename (with ENOENT retry). */
|
|
73
178
|
private async writeState(records: BackgroundJobRecord[]): Promise<void> {
|
|
74
|
-
await this.ensureDir()
|
|
75
179
|
const content = JSON.stringify(records, null, 2)
|
|
76
|
-
|
|
77
|
-
|
|
180
|
+
const tmpPath = this.makeTmpPath()
|
|
181
|
+
let lastError: unknown
|
|
182
|
+
|
|
183
|
+
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
await this.ensureDir()
|
|
186
|
+
const handle = await this.ops.openForWrite(tmpPath)
|
|
187
|
+
try {
|
|
188
|
+
await handle.writeFile(content, 'utf-8')
|
|
189
|
+
await handle.sync()
|
|
190
|
+
} finally {
|
|
191
|
+
await handle.close()
|
|
192
|
+
}
|
|
193
|
+
await this.ops.rename(tmpPath, this.statePath)
|
|
194
|
+
return
|
|
195
|
+
} catch (err: unknown) {
|
|
196
|
+
lastError = err
|
|
197
|
+
if (!isEnoent(err)) break
|
|
198
|
+
log.warn(
|
|
199
|
+
`[FilePersistence] rename ENOENT for ${this.statePath} ` +
|
|
200
|
+
`(attempt ${attempt}/${RENAME_MAX_ATTEMPTS}) — recreating dir and retrying`,
|
|
201
|
+
)
|
|
202
|
+
await this.safeUnlink(tmpPath)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await this.safeUnlink(tmpPath)
|
|
207
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
78
208
|
}
|
|
79
209
|
|
|
80
|
-
async
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} else {
|
|
86
|
-
records.push(record)
|
|
210
|
+
private async safeUnlink(path: string): Promise<void> {
|
|
211
|
+
try {
|
|
212
|
+
await this.ops.unlink(path)
|
|
213
|
+
} catch {
|
|
214
|
+
// Best-effort temp cleanup — absent file is fine.
|
|
87
215
|
}
|
|
88
|
-
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async saveJob(record: BackgroundJobRecord): Promise<void> {
|
|
219
|
+
await enqueueWrite(this.lockKey, async () => {
|
|
220
|
+
const records = await this.readState()
|
|
221
|
+
if (records === null) {
|
|
222
|
+
log.error(
|
|
223
|
+
`[FilePersistence] Aborting saveJob(${record.taskID}): could not read ` +
|
|
224
|
+
`${this.statePath} — refusing to overwrite state with a partial snapshot`,
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const idx = records.findIndex((r) => r.taskID === record.taskID)
|
|
229
|
+
if (idx >= 0) {
|
|
230
|
+
records[idx] = record
|
|
231
|
+
} else {
|
|
232
|
+
records.push(record)
|
|
233
|
+
}
|
|
234
|
+
await this.writeState(records)
|
|
235
|
+
})
|
|
89
236
|
}
|
|
90
237
|
|
|
91
238
|
async loadAllJobs(): Promise<BackgroundJobRecord[]> {
|
|
@@ -93,11 +240,13 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
|
93
240
|
}
|
|
94
241
|
|
|
95
242
|
async deleteJob(taskID: string): Promise<void> {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
243
|
+
await enqueueWrite(this.lockKey, async () => {
|
|
244
|
+
const records = await this.readState()
|
|
245
|
+
if (!records) return
|
|
246
|
+
const idx = records.findIndex((r) => r.taskID === taskID)
|
|
247
|
+
if (idx < 0) return
|
|
248
|
+
records.splice(idx, 1)
|
|
249
|
+
await this.writeState(records)
|
|
250
|
+
})
|
|
102
251
|
}
|
|
103
252
|
}
|
|
@@ -104,12 +104,20 @@ export function createV2EventDispatcher(deps: V2EventDeps): V2EventDispatcher {
|
|
|
104
104
|
} else {
|
|
105
105
|
// Native task(background=true) child: register on the board
|
|
106
106
|
// atomically (skip if V1 idle hook or re-dispatch already claimed it).
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
try {
|
|
108
|
+
await deps.board.registerLaunchIfAbsent({
|
|
109
|
+
taskID: info.id,
|
|
110
|
+
parentSessionID: info.parentID,
|
|
111
|
+
agent: 'native',
|
|
112
|
+
description: `native child ${info.id}`,
|
|
113
|
+
})
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// The per-agent concurrency limit can refuse the launch. Do not
|
|
116
|
+
// abort the rest of the handler: this child is simply not
|
|
117
|
+
// mirrored on the board, while finalize/idle dispatch for every
|
|
118
|
+
// other event must still run.
|
|
119
|
+
log.warn(`[Pantheon V2] native child ${info.id} not mirrored on board:`, err)
|
|
120
|
+
}
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.8",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pantheon-tui",
|
|
9
|
-
"version": "1.5.0-beta.
|
|
9
|
+
"version": "1.5.0-beta.8",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@opencode-ai/plugin": ">=1.14.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.5.0-beta.
|
|
4
|
+
"version": "1.5.0-beta.8",
|
|
5
5
|
"description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|