pantheon-opencode 1.5.0-beta.7 β 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 +32 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/src/pantheon/background-job-board.ts +6 -1
- package/src/pantheon/delegation.ts +141 -5
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- 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
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
18
18
|
|
|
19
19
|
## β
Closed Issues
|
|
20
20
|
|
|
21
|
+
## [v1.5.0-beta.8] - 2026-09-13
|
|
22
|
+
|
|
23
|
+
<!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
|
|
24
|
+
move this section to a versioned entry and reset the template below. -->
|
|
25
|
+
|
|
26
|
+
## π Fixed
|
|
27
|
+
|
|
28
|
+
- **Board persistence is corruption- and concurrency-safe:** state writes now go
|
|
29
|
+
through a UNIQUE temp file (per-process, per-write counter + random suffix), are
|
|
30
|
+
`fsync`ed, then renamed over the destination with bounded ENOENT retries, and
|
|
31
|
+
read-modify-write cycles on the same state path are serialized. This closes the
|
|
32
|
+
window where two writers could interleave and corrupt `state.json` or race the
|
|
33
|
+
rename.
|
|
34
|
+
- **Corrupt board state is quarantined and salvaged:** a malformed `state.json` is
|
|
35
|
+
moved aside to `<state>.corrupt-<ts>`, a clear error is logged, and every complete
|
|
36
|
+
record that can be parsed is salvaged and returned instead of throwing β so
|
|
37
|
+
delete/prune/recover callers cannot get stuck in a retry loop.
|
|
38
|
+
- **P1-1 β no orphan `.signal.json` on reconcile:** reconcile now deletes any
|
|
39
|
+
still-pending wake signal instead of re-writing it. Reconcile is an
|
|
40
|
+
acknowledgment, not a new terminal transition, so the finalizeβreconcile cycle
|
|
41
|
+
no longer leaves a stale `<alias>.signal.json` with `state:"reconciled"` behind.
|
|
42
|
+
- **Unresolved `{sessionID}` template guard:** the TUI/runtime can hand the plugin
|
|
43
|
+
the unsubstituted literal `{sessionID}` (or `%7BsessionID%7D`) when no session is
|
|
44
|
+
focused; the plugin now refuses placeholder session ids at the delegation,
|
|
45
|
+
bootstrap-probe, and activity-sample boundaries instead of forwarding them to the
|
|
46
|
+
session API (which rejected every call).
|
|
47
|
+
- **Delegation lookup scoped to the calling session:** `pantheon_delegation_read`
|
|
48
|
+
resolves exact task IDs first (must belong to the caller's session), then alias
|
|
49
|
+
matches within that session (exactly one required). Cross-session and ambiguous
|
|
50
|
+
aliases now return explicit errors instead of the generic "Unknown delegation".
|
|
51
|
+
|
|
52
|
+
|
|
21
53
|
## [v1.5.0-beta.7] - 2026-09-13
|
|
22
54
|
|
|
23
55
|
<!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon",
|
|
3
3
|
"publisher": "ils15",
|
|
4
|
-
"version": "1.5.0-beta.
|
|
4
|
+
"version": "1.5.0-beta.8",
|
|
5
5
|
"displayName": "Pantheon",
|
|
6
6
|
"description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
|
|
7
7
|
"agents": "./src/agents",
|
package/pyproject.toml
CHANGED
|
@@ -457,7 +457,12 @@ export class BackgroundJobBoard {
|
|
|
457
457
|
job.terminalUnreconciled = false
|
|
458
458
|
|
|
459
459
|
await this.persistRecord(job)
|
|
460
|
-
|
|
460
|
+
// P1-1: reconcile is an acknowledgment, NOT a new terminal transition
|
|
461
|
+
// (onTerminal does not re-fire). Re-writing a `.signal.json` here leaked
|
|
462
|
+
// orphan signals with `state:"reconciled"` after the finalizeβreconcile
|
|
463
|
+
// cycle deleted them. Instead, remove any still-pending wake signal so
|
|
464
|
+
// reconcile leaves no `<alias>.signal.json` behind.
|
|
465
|
+
await this.deleteSignal(job.alias)
|
|
461
466
|
|
|
462
467
|
return job
|
|
463
468
|
}
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
|
|
62
62
|
import { createPantheonLogger } from './logger.ts'
|
|
63
63
|
import { buildAgentListDescription } from './permission-globs.ts'
|
|
64
|
+
import { safeSessionPath } from './session-guard.ts'
|
|
64
65
|
import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
|
|
65
66
|
|
|
66
67
|
export type {
|
|
@@ -96,6 +97,40 @@ function readIntegerEnv(
|
|
|
96
97
|
return Number.isSafeInteger(value) && value >= minimum ? value : fallback
|
|
97
98
|
}
|
|
98
99
|
|
|
100
|
+
// βββ B1: unresolved session template guard βββββββββββββββββββββββββββββ
|
|
101
|
+
//
|
|
102
|
+
// The TUI/runtime can hand a plugin surface the UNSUBSTITUTED template literal
|
|
103
|
+
// `{sessionID}` (URL-encoded `%7BsessionID%7D`) when no session is focused.
|
|
104
|
+
// Forwarding it to a session API makes the opencode server reject every call
|
|
105
|
+
// (`Expected a string starting with "ses", got "%7BsessionID%7D"`), which the
|
|
106
|
+
// beta.7 E2E recorded at ~1k errors/s. The delegation layer is a defensive
|
|
107
|
+
// choke point too: it is downstream of the plugin adapter in production, but
|
|
108
|
+
// tests, older hosts, and direct embedders can inject a raw client.
|
|
109
|
+
//
|
|
110
|
+
// This guard targets the placeholder ONLY. It is deliberately NOT the full
|
|
111
|
+
// `isValidSessionId` contract: structural test hosts use non-"ses" ids on
|
|
112
|
+
// purpose (delegation-bootstrap.test.ts), and rejecting those would be a
|
|
113
|
+
// behavior change unrelated to this bug. `session-guard.ts` remains the
|
|
114
|
+
// strict choke point for the plugin adapters.
|
|
115
|
+
const UNRESOLVED_SESSION_TEMPLATE = /(?:\{sessionID\}|%7BsessionID%7D)/i
|
|
116
|
+
|
|
117
|
+
/** True when a session id is an unresolved `{sessionID}` template placeholder. */
|
|
118
|
+
export function isUnresolvedSessionTemplate(id: unknown): boolean {
|
|
119
|
+
return typeof id === 'string' && UNRESOLVED_SESSION_TEMPLATE.test(id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Refuse a placeholder session id before it reaches a session API. Returns
|
|
124
|
+
* true (and logs a clear warning) when the caller must skip the operation.
|
|
125
|
+
*/
|
|
126
|
+
function refuseUnresolvedSessionTemplate(id: unknown, operation: string): boolean {
|
|
127
|
+
if (!isUnresolvedSessionTemplate(id)) return false
|
|
128
|
+
log.warn(
|
|
129
|
+
`[pantheon-delegate] ${operation} skipped: unresolved session template "${String(id)}" β the host did not substitute the session id`,
|
|
130
|
+
)
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
type BootstrapOutcome =
|
|
100
135
|
| { status: 'started'; elapsedMs: number; reason: string }
|
|
101
136
|
| { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
|
|
@@ -115,6 +150,9 @@ async function promptWithTimeout(
|
|
|
115
150
|
input: Parameters<DelegationClient['session']['promptAsync']>[0],
|
|
116
151
|
timeoutMs: number,
|
|
117
152
|
): Promise<PromptOutcome> {
|
|
153
|
+
if (refuseUnresolvedSessionTemplate(input.path.id, 'promptAsync')) {
|
|
154
|
+
return { status: 'rejected', reason: `invalid sessionID: ${input.path.id}` }
|
|
155
|
+
}
|
|
118
156
|
const controller = new AbortController()
|
|
119
157
|
let timer: NodeJS.Timeout | undefined
|
|
120
158
|
const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
|
|
@@ -190,6 +228,14 @@ async function bootstrapChild(
|
|
|
190
228
|
const hasStatusAPI = typeof client.session.get === 'function'
|
|
191
229
|
const acceptedRunning = isRunningSession(accepted)
|
|
192
230
|
|
|
231
|
+
if (refuseUnresolvedSessionTemplate(childSessionID, 'bootstrap probe')) {
|
|
232
|
+
return {
|
|
233
|
+
status: 'bootstrap_unknown',
|
|
234
|
+
elapsedMs: 0,
|
|
235
|
+
reason: `invalid child sessionID "${childSessionID}"`,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
193
239
|
if (!hasMessagesAPI && !hasStatusAPI) {
|
|
194
240
|
return acceptedRunning
|
|
195
241
|
? {
|
|
@@ -403,6 +449,72 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
403
449
|
}
|
|
404
450
|
}
|
|
405
451
|
|
|
452
|
+
// βββ B2: session-scoped delegation target resolution βββββββββββββββββββ
|
|
453
|
+
//
|
|
454
|
+
// Board aliases restart per parent session (apo-1 in every root), so an alias
|
|
455
|
+
// lookup MUST be scoped to the calling session. `pantheon_delegation_read`
|
|
456
|
+
// previously delegated to the board's first-match resolver and, when the id
|
|
457
|
+
// was not found in the current session, the generic "Unknown delegation"
|
|
458
|
+
// message hid the real cause (the alias belonged to another session). This
|
|
459
|
+
// helper makes the scoping explicit and returns a CLEAR error for each
|
|
460
|
+
// cross-session / ambiguous case.
|
|
461
|
+
|
|
462
|
+
/** Resolution outcome for a delegation read by task ID or alias. */
|
|
463
|
+
export type DelegationTargetResolution =
|
|
464
|
+
| { kind: 'found'; job: BackgroundJobRecord }
|
|
465
|
+
| { kind: 'error'; message: string }
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Resolve a `pantheon_delegation_read` target with strict session scoping:
|
|
469
|
+
* - exact task-ID match first (must belong to `sessionID`);
|
|
470
|
+
* - then alias matches WITHIN `sessionID` (exactly one required);
|
|
471
|
+
* - an alias that exists only in another session β clear cross-session
|
|
472
|
+
* error (never another session's job);
|
|
473
|
+
* - two matches in this session β clear ambiguity error.
|
|
474
|
+
*/
|
|
475
|
+
export function resolveDelegationTarget(
|
|
476
|
+
board: Pick<BackgroundJobBoard, 'list'>,
|
|
477
|
+
sessionID: string,
|
|
478
|
+
idOrAlias: string,
|
|
479
|
+
): DelegationTargetResolution {
|
|
480
|
+
const jobs = board.list()
|
|
481
|
+
|
|
482
|
+
const byTaskID = jobs.find((job) => job.taskID === idOrAlias)
|
|
483
|
+
if (byTaskID !== undefined) {
|
|
484
|
+
if (byTaskID.parentSessionID === sessionID) return { kind: 'found', job: byTaskID }
|
|
485
|
+
return {
|
|
486
|
+
kind: 'error',
|
|
487
|
+
message:
|
|
488
|
+
`Delegation "${idOrAlias}" belongs to a different session ` +
|
|
489
|
+
`(${byTaskID.parentSessionID}) and cannot be read from ${sessionID}.`,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const aliasMatches = jobs.filter((job) => job.alias === idOrAlias)
|
|
494
|
+
const inSession = aliasMatches.filter((job) => job.parentSessionID === sessionID)
|
|
495
|
+
if (inSession.length === 1) return { kind: 'found', job: inSession[0]! }
|
|
496
|
+
if (inSession.length > 1) {
|
|
497
|
+
return {
|
|
498
|
+
kind: 'error',
|
|
499
|
+
message:
|
|
500
|
+
`Delegation alias "${idOrAlias}" is ambiguous in session ${sessionID} ` +
|
|
501
|
+
`(${inSession.length} matches); read it by task ID instead.`,
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (aliasMatches.length > 0) {
|
|
505
|
+
return {
|
|
506
|
+
kind: 'error',
|
|
507
|
+
message:
|
|
508
|
+
`Delegation alias "${idOrAlias}" belongs to a different session β ` +
|
|
509
|
+
'aliases are session-scoped. Use pantheon_delegation_list in the session that created it.',
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
kind: 'error',
|
|
514
|
+
message: `Unknown delegation "${idOrAlias}" for this session. Use pantheon_delegation_list to see active delegations.`,
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
406
518
|
/** Normalize the SDK-provided caller agent without inventing an identity. */
|
|
407
519
|
const normalizeToolAgent = normalizeDelegationAgent
|
|
408
520
|
|
|
@@ -509,6 +621,7 @@ async function sampleChildActivity(
|
|
|
509
621
|
collector: ActivityCollector,
|
|
510
622
|
): Promise<void> {
|
|
511
623
|
if (typeof client.session.messages !== 'function') return
|
|
624
|
+
if (refuseUnresolvedSessionTemplate(childSessionID, 'activity sample')) return
|
|
512
625
|
try {
|
|
513
626
|
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
514
627
|
if (!Array.isArray(bundles)) return
|
|
@@ -534,6 +647,7 @@ async function lastChildActivity(
|
|
|
534
647
|
childSessionID: string,
|
|
535
648
|
): Promise<string | undefined> {
|
|
536
649
|
if (typeof client.session.messages !== 'function') return undefined
|
|
650
|
+
if (refuseUnresolvedSessionTemplate(childSessionID, 'last activity fetch')) return undefined
|
|
537
651
|
try {
|
|
538
652
|
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
539
653
|
if (!Array.isArray(bundles)) return undefined
|
|
@@ -775,8 +889,13 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
775
889
|
delete?: (input: { path: { id: string } }) => Promise<unknown>
|
|
776
890
|
}
|
|
777
891
|
if (typeof session.delete !== 'function') return
|
|
892
|
+
const sessionPath = safeSessionPath(childSessionID)
|
|
893
|
+
if (sessionPath === null) {
|
|
894
|
+
log.warn(`[pantheon-delegate] session.delete skipped: invalid sessionID "${childSessionID}"`)
|
|
895
|
+
return
|
|
896
|
+
}
|
|
778
897
|
try {
|
|
779
|
-
await session.delete(
|
|
898
|
+
await session.delete(sessionPath)
|
|
780
899
|
} catch (err: unknown) {
|
|
781
900
|
const reason = err instanceof Error ? err.message : String(err)
|
|
782
901
|
log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
|
|
@@ -899,6 +1018,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
899
1018
|
return `pantheon_delegate failed to create child session: ${reason}`
|
|
900
1019
|
}
|
|
901
1020
|
const childSessionID = created.id
|
|
1021
|
+
// B1: a host that passes through the unresolved template must never
|
|
1022
|
+
// produce a board job / bootstrap poll β reject it here with a clear
|
|
1023
|
+
// message and refund the reserved budget (no child was started).
|
|
1024
|
+
if (isUnresolvedSessionTemplate(childSessionID)) {
|
|
1025
|
+
if (budgetReserved && budgetKey !== undefined) {
|
|
1026
|
+
budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
|
|
1027
|
+
}
|
|
1028
|
+
log.warn(
|
|
1029
|
+
`[pantheon-delegate] session.create returned unresolved session template "${childSessionID}" β child ignored`,
|
|
1030
|
+
)
|
|
1031
|
+
return `pantheon_delegate failed: host returned an unresolved child session template ("${childSessionID}"); expected an id starting with "ses".`
|
|
1032
|
+
}
|
|
902
1033
|
knownChildren.add(childSessionID)
|
|
903
1034
|
options.registerChildSession?.(childSessionID, ctx.sessionID)
|
|
904
1035
|
|
|
@@ -1024,6 +1155,12 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1024
1155
|
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
|
|
1025
1156
|
}
|
|
1026
1157
|
const retryID = retry.id
|
|
1158
|
+
if (isUnresolvedSessionTemplate(retryID)) {
|
|
1159
|
+
log.warn(
|
|
1160
|
+
`[pantheon-delegate] retry session.create returned unresolved session template "${retryID}" β retry ignored`,
|
|
1161
|
+
)
|
|
1162
|
+
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; host returned an unresolved retry session template ("${retryID}")`
|
|
1163
|
+
}
|
|
1027
1164
|
knownChildren.add(retryID)
|
|
1028
1165
|
options.registerChildSession?.(retryID, ctx.sessionID)
|
|
1029
1166
|
|
|
@@ -1115,10 +1252,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1115
1252
|
args: readArgs,
|
|
1116
1253
|
execute: async (args, ctx) => {
|
|
1117
1254
|
guardEnabled()
|
|
1118
|
-
const
|
|
1119
|
-
if (
|
|
1120
|
-
|
|
1121
|
-
}
|
|
1255
|
+
const resolution = resolveDelegationTarget(board, ctx.sessionID, args.id)
|
|
1256
|
+
if (resolution.kind === 'error') return resolution.message
|
|
1257
|
+
const job = resolution.job
|
|
1122
1258
|
|
|
1123
1259
|
// Sample the child's messages while waiting so the caller sees what the
|
|
1124
1260
|
// agent is doing β fail-open: no messages support β report as before.
|
|
@@ -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
|
}
|
|
@@ -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": {
|