pantheon-opencode 1.2.0 → 1.2.2
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/AGENTS.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Job Board — state machine for tracking background agent jobs.
|
|
3
|
+
*
|
|
4
|
+
* Tracks jobs through a state machine (running → completed/error/cancelled → reconciled),
|
|
5
|
+
* supports alias generation per session, persistence via adapter, auto-wake signal files,
|
|
6
|
+
* and terminal state listeners (persist-before-notify write-ahead-log pattern).
|
|
7
|
+
*
|
|
8
|
+
* Pure TypeScript — zero external dependencies beyond Node.js builtins (fs/path).
|
|
9
|
+
*
|
|
10
|
+
* @module background-job-board
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { join, dirname } from 'node:path'
|
|
15
|
+
|
|
16
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Valid states for a background job. */
|
|
19
|
+
export type BackgroundJobState =
|
|
20
|
+
| 'running'
|
|
21
|
+
| 'completed'
|
|
22
|
+
| 'error'
|
|
23
|
+
| 'cancelled'
|
|
24
|
+
| 'reconciled'
|
|
25
|
+
|
|
26
|
+
/** Terminal states that can transition to reconciled. */
|
|
27
|
+
const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
|
|
28
|
+
'completed',
|
|
29
|
+
'error',
|
|
30
|
+
'cancelled',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Transitions allowed via `updateStatus()`.
|
|
35
|
+
* Each entry defines which target states are reachable from the current state.
|
|
36
|
+
* Only running → terminal, or same-terminal → same-terminal (idempotent re-notify).
|
|
37
|
+
*/
|
|
38
|
+
const UPDATE_TRANSITIONS: Record<
|
|
39
|
+
BackgroundJobState,
|
|
40
|
+
ReadonlySet<BackgroundJobState>
|
|
41
|
+
> = {
|
|
42
|
+
running: new Set(['completed', 'error', 'cancelled']),
|
|
43
|
+
completed: new Set(['completed']),
|
|
44
|
+
error: new Set(['error']),
|
|
45
|
+
cancelled: new Set(['cancelled']),
|
|
46
|
+
reconciled: new Set([]),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A file context reference read during a job's execution. */
|
|
50
|
+
export interface ContextFile {
|
|
51
|
+
path: string
|
|
52
|
+
lineCount: number
|
|
53
|
+
lastReadAt: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Full record of a background agent job. */
|
|
57
|
+
export interface BackgroundJobRecord {
|
|
58
|
+
taskID: string
|
|
59
|
+
parentSessionID: string
|
|
60
|
+
/** Agent name, e.g. "apollo", "hermes". */
|
|
61
|
+
agent: string
|
|
62
|
+
/** Human-readable description of the job. */
|
|
63
|
+
description: string
|
|
64
|
+
/** Optional objective for the job. */
|
|
65
|
+
objective?: string
|
|
66
|
+
/** Current state in the state machine. */
|
|
67
|
+
state: BackgroundJobState
|
|
68
|
+
/** Whether the job timed out (distinct from error state). */
|
|
69
|
+
timedOut: boolean
|
|
70
|
+
/** Short unique alias within the parent session, e.g. "apo-1". */
|
|
71
|
+
alias: string
|
|
72
|
+
/** Epoch ms when the job was launched. */
|
|
73
|
+
launchedAt: number
|
|
74
|
+
/** Epoch ms when the job reached a terminal state. */
|
|
75
|
+
completedAt?: number
|
|
76
|
+
/** Epoch ms of the last state change. */
|
|
77
|
+
updatedAt: number
|
|
78
|
+
/** Optional summary of the result. */
|
|
79
|
+
resultSummary?: string
|
|
80
|
+
/** Most recent error message. */
|
|
81
|
+
lastStatusError?: string
|
|
82
|
+
/** Cumulative error count across retries. */
|
|
83
|
+
totalErrors: number
|
|
84
|
+
/** Cumulative timeout count. */
|
|
85
|
+
timeoutCount: number
|
|
86
|
+
/** True if the job is in a terminal state that hasn't been reconciled yet. */
|
|
87
|
+
terminalUnreconciled: boolean
|
|
88
|
+
/** Files read during execution. */
|
|
89
|
+
contextFiles: ContextFile[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Input for launching a new job. */
|
|
93
|
+
export interface LaunchInput {
|
|
94
|
+
taskID: string
|
|
95
|
+
parentSessionID: string
|
|
96
|
+
agent: string
|
|
97
|
+
description: string
|
|
98
|
+
objective?: string
|
|
99
|
+
contextFiles?: ContextFile[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Input for updating a job's status to a terminal state. */
|
|
103
|
+
export interface StatusInput {
|
|
104
|
+
taskID: string
|
|
105
|
+
state: 'completed' | 'error' | 'cancelled'
|
|
106
|
+
resultSummary?: string
|
|
107
|
+
error?: string
|
|
108
|
+
timedOut?: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Configuration options for the BackgroundJobBoard. */
|
|
112
|
+
export interface BoardOptions {
|
|
113
|
+
/** Maximum concurrent jobs per agent (default: 3). */
|
|
114
|
+
maxConcurrentPerAgent?: number
|
|
115
|
+
/** Maximum completed jobs kept per agent for reuse (default: 3). */
|
|
116
|
+
maxReusablePerAgent?: number
|
|
117
|
+
/**
|
|
118
|
+
* Directory for auto-wake signal files.
|
|
119
|
+
* When set, a `.signal.json` file is atomically written on every terminal transition.
|
|
120
|
+
* When null or undefined, no signal files are written.
|
|
121
|
+
*/
|
|
122
|
+
signalDir?: string | null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Persistence adapter interface — injected by the plugin layer. */
|
|
126
|
+
export interface PersistenceAdapter {
|
|
127
|
+
saveJob(record: BackgroundJobRecord): Promise<void>
|
|
128
|
+
loadAllJobs(): Promise<BackgroundJobRecord[]>
|
|
129
|
+
deleteJob(taskID: string): Promise<void>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Agent Prefix Mapping ──────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
const AGENT_PREFIXES: Record<string, string> = {
|
|
135
|
+
apollo: 'apo',
|
|
136
|
+
hermes: 'her',
|
|
137
|
+
aphrodite: 'aph',
|
|
138
|
+
demeter: 'dem',
|
|
139
|
+
themis: 'the',
|
|
140
|
+
prometheus: 'pro',
|
|
141
|
+
hephaestus: 'hep',
|
|
142
|
+
nyx: 'nyx',
|
|
143
|
+
athena: 'ath',
|
|
144
|
+
gaia: 'gai',
|
|
145
|
+
iris: 'iri',
|
|
146
|
+
mnemosyne: 'mne',
|
|
147
|
+
talos: 'tal',
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getAgentPrefix(agent: string): string {
|
|
151
|
+
return AGENT_PREFIXES[agent.toLowerCase()] ?? 'job'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── BackgroundJobBoard ────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* In-memory job board that tracks background agent jobs through a state machine.
|
|
158
|
+
*
|
|
159
|
+
* Features:
|
|
160
|
+
* - Strict state machine with validated transitions
|
|
161
|
+
* - Per-session, per-agent alias generation (apo-1, her-2, …)
|
|
162
|
+
* - Optional persistence via injected PersistenceAdapter
|
|
163
|
+
* - Optional auto-wake signal files on terminal transitions
|
|
164
|
+
* - Terminal state listeners with persist-before-notify guarantee
|
|
165
|
+
* - expire/prune helpers for session lifecycle management
|
|
166
|
+
*/
|
|
167
|
+
export class BackgroundJobBoard {
|
|
168
|
+
private readonly jobs: Map<string, BackgroundJobRecord> = new Map()
|
|
169
|
+
/**
|
|
170
|
+
* aliasCounters[parentSessionID][agentPrefix] → next counter value
|
|
171
|
+
*/
|
|
172
|
+
private readonly aliasCounters: Map<string, Map<string, number>> = new Map()
|
|
173
|
+
private readonly terminalListeners: Set<(taskID: string) => void> = new Set()
|
|
174
|
+
private readonly options: Required<BoardOptions>
|
|
175
|
+
private persistence: PersistenceAdapter | null = null
|
|
176
|
+
|
|
177
|
+
constructor(options?: BoardOptions) {
|
|
178
|
+
this.options = {
|
|
179
|
+
maxConcurrentPerAgent: options?.maxConcurrentPerAgent ?? 3,
|
|
180
|
+
maxReusablePerAgent: options?.maxReusablePerAgent ?? 3,
|
|
181
|
+
signalDir: options?.signalDir ?? null,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Persistence ────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/** Inject a persistence adapter. Must be called before `recoverRunningJobs()`. */
|
|
188
|
+
setPersistence(adapter: PersistenceAdapter): void {
|
|
189
|
+
this.persistence = adapter
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Recover previously persisted jobs on startup.
|
|
194
|
+
* Any jobs left in `running` state are marked as `error` (orphaned by crash).
|
|
195
|
+
*/
|
|
196
|
+
async recoverRunningJobs(): Promise<void> {
|
|
197
|
+
if (!this.persistence) return
|
|
198
|
+
const records = await this.persistence.loadAllJobs()
|
|
199
|
+
for (const record of records) {
|
|
200
|
+
if (record.state === 'running') {
|
|
201
|
+
record.state = 'error'
|
|
202
|
+
record.lastStatusError = 'Process restarted — job marked as errored'
|
|
203
|
+
record.updatedAt = Date.now()
|
|
204
|
+
record.totalErrors++
|
|
205
|
+
}
|
|
206
|
+
this.jobs.set(record.taskID, record)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Core Operations ────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Register a new job launch.
|
|
214
|
+
* The job starts in the `running` state with a generated alias.
|
|
215
|
+
*/
|
|
216
|
+
async registerLaunch(input: LaunchInput): Promise<BackgroundJobRecord> {
|
|
217
|
+
const prefix = getAgentPrefix(input.agent)
|
|
218
|
+
let sessionCounters = this.aliasCounters.get(input.parentSessionID)
|
|
219
|
+
if (!sessionCounters) {
|
|
220
|
+
sessionCounters = new Map()
|
|
221
|
+
this.aliasCounters.set(input.parentSessionID, sessionCounters)
|
|
222
|
+
}
|
|
223
|
+
const counter = (sessionCounters.get(prefix) ?? 0) + 1
|
|
224
|
+
sessionCounters.set(prefix, counter)
|
|
225
|
+
|
|
226
|
+
const now = Date.now()
|
|
227
|
+
const record: BackgroundJobRecord = {
|
|
228
|
+
taskID: input.taskID,
|
|
229
|
+
parentSessionID: input.parentSessionID,
|
|
230
|
+
agent: input.agent,
|
|
231
|
+
description: input.description,
|
|
232
|
+
...(input.objective !== undefined ? { objective: input.objective } : {}),
|
|
233
|
+
state: 'running',
|
|
234
|
+
timedOut: false,
|
|
235
|
+
alias: `${prefix}-${counter}`,
|
|
236
|
+
launchedAt: now,
|
|
237
|
+
updatedAt: now,
|
|
238
|
+
totalErrors: 0,
|
|
239
|
+
timeoutCount: 0,
|
|
240
|
+
terminalUnreconciled: false,
|
|
241
|
+
contextFiles: input.contextFiles ?? [],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.jobs.set(input.taskID, record)
|
|
245
|
+
await this.persistRecord(record)
|
|
246
|
+
return record
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Update a job's state to a terminal state.
|
|
251
|
+
*
|
|
252
|
+
* Valid transitions: `running` → `completed`|`error`|`cancelled`
|
|
253
|
+
* Idempotent: terminal → same terminal (re-notifies listeners, re-persists).
|
|
254
|
+
* Rejected: `running` → `running`, terminal → different terminal, `reconciled` → anything.
|
|
255
|
+
*
|
|
256
|
+
* Follows write-ahead-log: persist → signal → notify listeners.
|
|
257
|
+
*/
|
|
258
|
+
async updateStatus(input: StatusInput): Promise<BackgroundJobRecord | undefined> {
|
|
259
|
+
const job = this.jobs.get(input.taskID)
|
|
260
|
+
if (!job) return undefined
|
|
261
|
+
|
|
262
|
+
const allowed = UPDATE_TRANSITIONS[job.state]
|
|
263
|
+
if (!allowed.has(input.state)) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Invalid state transition: ${job.state} → ${input.state} for job ${input.taskID}`,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const wasTerminal = TERMINAL_STATES.has(job.state)
|
|
270
|
+
const willBeTerminal = TERMINAL_STATES.has(input.state)
|
|
271
|
+
|
|
272
|
+
// --- mutate in-memory state ---
|
|
273
|
+
job.state = input.state
|
|
274
|
+
job.updatedAt = Date.now()
|
|
275
|
+
|
|
276
|
+
if (input.resultSummary !== undefined) {
|
|
277
|
+
job.resultSummary = input.resultSummary
|
|
278
|
+
}
|
|
279
|
+
if (input.error !== undefined) {
|
|
280
|
+
job.lastStatusError = input.error
|
|
281
|
+
}
|
|
282
|
+
if (input.timedOut !== undefined) {
|
|
283
|
+
job.timedOut = input.timedOut
|
|
284
|
+
}
|
|
285
|
+
if (input.state === 'error') {
|
|
286
|
+
job.totalErrors++
|
|
287
|
+
}
|
|
288
|
+
if (input.timedOut) {
|
|
289
|
+
job.timeoutCount++
|
|
290
|
+
}
|
|
291
|
+
if (willBeTerminal) {
|
|
292
|
+
if (job.completedAt === undefined) {
|
|
293
|
+
job.completedAt = Date.now()
|
|
294
|
+
}
|
|
295
|
+
job.terminalUnreconciled = true
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- write-ahead log: persist before notify ---
|
|
299
|
+
await this.persistRecord(job)
|
|
300
|
+
|
|
301
|
+
// --- signal on terminal states ---
|
|
302
|
+
if (willBeTerminal) {
|
|
303
|
+
await this.writeSignal(job)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- notify listeners on first terminal transition ---
|
|
307
|
+
if (willBeTerminal && !wasTerminal) {
|
|
308
|
+
this.notifyTerminal(job.taskID)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return job
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Cancel a running job.
|
|
316
|
+
*
|
|
317
|
+
* More permissive than `updateStatus({ state: 'cancelled' })` — it allows
|
|
318
|
+
* cancelling from any non-terminal state. Idempotent on already-cancelled jobs.
|
|
319
|
+
*/
|
|
320
|
+
async markCancelled(taskID: string, reason?: string): Promise<BackgroundJobRecord | undefined> {
|
|
321
|
+
const job = this.jobs.get(taskID)
|
|
322
|
+
if (!job) return undefined
|
|
323
|
+
|
|
324
|
+
if (job.state === 'reconciled') {
|
|
325
|
+
throw new Error(`Cannot cancel reconciled job ${taskID}`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (job.state === 'cancelled') {
|
|
329
|
+
// Idempotent — re-persist and re-signal
|
|
330
|
+
await this.persistRecord(job)
|
|
331
|
+
await this.writeSignal(job)
|
|
332
|
+
return job
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (job.state !== 'running') {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Cannot cancel job ${taskID} in state ${job.state} — only running jobs can be cancelled`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const wasTerminal = TERMINAL_STATES.has(job.state)
|
|
342
|
+
job.state = 'cancelled'
|
|
343
|
+
job.updatedAt = Date.now()
|
|
344
|
+
if (reason !== undefined) {
|
|
345
|
+
job.lastStatusError = reason
|
|
346
|
+
}
|
|
347
|
+
job.completedAt ??= Date.now()
|
|
348
|
+
job.terminalUnreconciled = true
|
|
349
|
+
|
|
350
|
+
await this.persistRecord(job)
|
|
351
|
+
await this.writeSignal(job)
|
|
352
|
+
|
|
353
|
+
if (!wasTerminal) {
|
|
354
|
+
this.notifyTerminal(job.taskID)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return job
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Mark a terminal job as reconciled (acknowledged/consumed by the orchestrator).
|
|
362
|
+
*
|
|
363
|
+
* Valid: only `completed`, `error`, or `cancelled` → `reconciled`.
|
|
364
|
+
* Idempotent: already-reconciled jobs are returned as-is.
|
|
365
|
+
* Rejected: `running` → `reconciled`.
|
|
366
|
+
*/
|
|
367
|
+
async markReconciled(taskID: string): Promise<BackgroundJobRecord | undefined> {
|
|
368
|
+
const job = this.jobs.get(taskID)
|
|
369
|
+
if (!job) return undefined
|
|
370
|
+
|
|
371
|
+
if (job.state === 'reconciled') {
|
|
372
|
+
return job
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!TERMINAL_STATES.has(job.state)) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`Cannot reconcile job ${taskID} in state ${job.state} — only terminal states can be reconciled`,
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const wasTerminalUnreconciled = job.terminalUnreconciled
|
|
382
|
+
job.state = 'reconciled'
|
|
383
|
+
job.updatedAt = Date.now()
|
|
384
|
+
job.terminalUnreconciled = false
|
|
385
|
+
|
|
386
|
+
await this.persistRecord(job)
|
|
387
|
+
await this.writeSignal(job)
|
|
388
|
+
|
|
389
|
+
if (wasTerminalUnreconciled) {
|
|
390
|
+
this.notifyTerminal(job.taskID)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return job
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ─── Queries ─────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
/** Look up a job by its taskID. */
|
|
399
|
+
get(taskID: string): BackgroundJobRecord | undefined {
|
|
400
|
+
return this.jobs.get(taskID)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* List jobs, optionally filtered by parent session ID.
|
|
405
|
+
* Returns a snapshot (shallow copy) of the matching records.
|
|
406
|
+
*/
|
|
407
|
+
list(parentSessionID?: string): BackgroundJobRecord[] {
|
|
408
|
+
const all = Array.from(this.jobs.values())
|
|
409
|
+
if (!parentSessionID) return [...all]
|
|
410
|
+
return all.filter(j => j.parentSessionID === parentSessionID)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Resolve a job by either taskID or alias within a parent session.
|
|
415
|
+
* Checks exact taskID first, then falls back to alias matching.
|
|
416
|
+
*/
|
|
417
|
+
resolve(
|
|
418
|
+
parentSessionID: string,
|
|
419
|
+
taskIDOrAlias: string,
|
|
420
|
+
): BackgroundJobRecord | undefined {
|
|
421
|
+
// Exact taskID match (session-scoped)
|
|
422
|
+
const byID = this.jobs.get(taskIDOrAlias)
|
|
423
|
+
if (byID?.parentSessionID === parentSessionID) return byID
|
|
424
|
+
|
|
425
|
+
// Alias match within session
|
|
426
|
+
for (const job of this.jobs.values()) {
|
|
427
|
+
if (job.parentSessionID === parentSessionID && job.alias === taskIDOrAlias) {
|
|
428
|
+
return job
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return undefined
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Check whether a new job can be dispatched for the given agent (concurrency limit). */
|
|
436
|
+
canDispatch(agent: string): boolean {
|
|
437
|
+
return this.getRunningCount(agent) < this.options.maxConcurrentPerAgent
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Count currently running jobs, optionally filtered by agent. */
|
|
441
|
+
getRunningCount(agent?: string): number {
|
|
442
|
+
let count = 0
|
|
443
|
+
for (const job of this.jobs.values()) {
|
|
444
|
+
if (job.state === 'running' && (agent === undefined || job.agent === agent)) {
|
|
445
|
+
count++
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return count
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─── Terminal State Listeners ────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
/** Register a listener that fires when a job reaches a terminal state. */
|
|
454
|
+
onTerminal(listener: (taskID: string) => void): void {
|
|
455
|
+
this.terminalListeners.add(listener)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Remove a previously registered terminal state listener. */
|
|
459
|
+
removeTerminalListener(listener: (taskID: string) => void): void {
|
|
460
|
+
this.terminalListeners.delete(listener)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ─── Prompt Integration ──────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Generate a formatted prompt fragment for the agent system prompt,
|
|
467
|
+
* showing all background jobs for the given parent session.
|
|
468
|
+
* Returns `undefined` when there are no jobs (caller can skip the section).
|
|
469
|
+
*/
|
|
470
|
+
formatForPrompt(parentSessionID: string): string | undefined {
|
|
471
|
+
const jobs = this.list(parentSessionID)
|
|
472
|
+
if (jobs.length === 0) return undefined
|
|
473
|
+
|
|
474
|
+
const lines = jobs.map(j => {
|
|
475
|
+
const status =
|
|
476
|
+
j.state === 'completed'
|
|
477
|
+
? 'OK'
|
|
478
|
+
: j.state === 'error'
|
|
479
|
+
? 'ERR'
|
|
480
|
+
: j.state === 'cancelled'
|
|
481
|
+
? 'CAN'
|
|
482
|
+
: j.state === 'running'
|
|
483
|
+
? 'RUN'
|
|
484
|
+
: 'REC'
|
|
485
|
+
return ` [${j.alias}] ${j.description} — ${status}`
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
return `Background Jobs:\n${lines.join('\n')}`
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─── Cleanup ─────────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Remove jobs whose `updatedAt` is older than `ttlMs` milliseconds
|
|
495
|
+
* from the current time.
|
|
496
|
+
*/
|
|
497
|
+
pruneExpired(ttlMs: number): void {
|
|
498
|
+
const now = Date.now()
|
|
499
|
+
for (const [taskID, job] of this.jobs) {
|
|
500
|
+
if (now - job.updatedAt > ttlMs) {
|
|
501
|
+
this.jobs.delete(taskID)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Remove all jobs belonging to a parent session. */
|
|
507
|
+
clearParent(parentSessionID: string): void {
|
|
508
|
+
for (const [taskID, job] of this.jobs) {
|
|
509
|
+
if (job.parentSessionID === parentSessionID) {
|
|
510
|
+
this.jobs.delete(taskID)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── Internal Helpers ────────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
private async persistRecord(record: BackgroundJobRecord): Promise<void> {
|
|
518
|
+
if (!this.persistence) return
|
|
519
|
+
try {
|
|
520
|
+
await this.persistence.saveJob(record)
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error(
|
|
523
|
+
`[BackgroundJobBoard] Failed to persist job ${record.taskID}:`,
|
|
524
|
+
err,
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private notifyTerminal(taskID: string): void {
|
|
530
|
+
for (const listener of this.terminalListeners) {
|
|
531
|
+
try {
|
|
532
|
+
listener(taskID)
|
|
533
|
+
} catch (err) {
|
|
534
|
+
console.error(
|
|
535
|
+
`[BackgroundJobBoard] Terminal listener error for ${taskID}:`,
|
|
536
|
+
err,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async writeSignal(record: BackgroundJobRecord): Promise<void> {
|
|
543
|
+
const signalDir = this.options.signalDir
|
|
544
|
+
if (!signalDir) return
|
|
545
|
+
|
|
546
|
+
const signalPath = join(signalDir, `${record.alias}.signal.json`)
|
|
547
|
+
const tmpPath = join(signalDir, `${record.alias}.signal.tmp`)
|
|
548
|
+
const content = JSON.stringify({
|
|
549
|
+
taskID: record.taskID,
|
|
550
|
+
alias: record.alias,
|
|
551
|
+
agent: record.agent,
|
|
552
|
+
state: record.state,
|
|
553
|
+
summary: record.resultSummary ?? null,
|
|
554
|
+
timestamp: Date.now(),
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
await mkdir(dirname(signalPath), { recursive: true })
|
|
559
|
+
await writeFile(tmpPath, content, 'utf-8')
|
|
560
|
+
await rename(tmpPath, signalPath)
|
|
561
|
+
} catch (err) {
|
|
562
|
+
console.error(
|
|
563
|
+
`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`,
|
|
564
|
+
err,
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based persistence adapter for the BackgroundJobBoard.
|
|
3
|
+
*
|
|
4
|
+
* Stores all jobs in a single `state.json` file using atomic writes
|
|
5
|
+
* (write to `state.json.tmp` then rename) to prevent partial writes
|
|
6
|
+
* from crashes.
|
|
7
|
+
*
|
|
8
|
+
* @module file-persistence
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { dirname } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import type { BackgroundJobRecord, PersistenceAdapter } from './background-job-board.ts'
|
|
15
|
+
|
|
16
|
+
/** Default path for the state file. */
|
|
17
|
+
const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* File-based persistence adapter.
|
|
21
|
+
*
|
|
22
|
+
* All jobs are stored in a single JSON array in `statePath`.
|
|
23
|
+
* Writes are atomic: content is written to a `.tmp` file, then renamed.
|
|
24
|
+
*
|
|
25
|
+
* The board's in-memory `this.jobs` Map is the source of truth —
|
|
26
|
+
* this file is a crash-recovery snapshot only. `deleteJob()` is a no-op
|
|
27
|
+
* because stale entries are harmless and overwritten on the next `saveJob()`.
|
|
28
|
+
*/
|
|
29
|
+
export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
30
|
+
private readonly statePath: string
|
|
31
|
+
private readonly dir: string
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param statePath Path to the state JSON file (default: `.pantheon/board/state.json`)
|
|
35
|
+
*/
|
|
36
|
+
constructor(statePath?: string) {
|
|
37
|
+
this.statePath = statePath ?? DEFAULT_STATE_PATH
|
|
38
|
+
this.dir = dirname(this.statePath)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Ensure the parent directory exists. */
|
|
42
|
+
private async ensureDir(): Promise<void> {
|
|
43
|
+
await mkdir(this.dir, { recursive: true })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Path to the atomic-write temp file. */
|
|
47
|
+
private get tmpPath(): string {
|
|
48
|
+
return this.statePath + '.tmp'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read all jobs from the state file.
|
|
53
|
+
* Returns `null` if the file doesn't exist (first run / no jobs yet).
|
|
54
|
+
*/
|
|
55
|
+
private async readState(): Promise<BackgroundJobRecord[] | null> {
|
|
56
|
+
try {
|
|
57
|
+
const content = await readFile(this.statePath, 'utf-8')
|
|
58
|
+
return JSON.parse(content) as BackgroundJobRecord[]
|
|
59
|
+
} catch (err: unknown) {
|
|
60
|
+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write the full state array atomically (tmp + rename). */
|
|
68
|
+
private async writeState(records: BackgroundJobRecord[]): Promise<void> {
|
|
69
|
+
await this.ensureDir()
|
|
70
|
+
const content = JSON.stringify(records, null, 2)
|
|
71
|
+
await writeFile(this.tmpPath, content, 'utf-8')
|
|
72
|
+
await rename(this.tmpPath, this.statePath)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async saveJob(record: BackgroundJobRecord): Promise<void> {
|
|
76
|
+
const records = (await this.readState()) ?? []
|
|
77
|
+
const idx = records.findIndex(r => r.taskID === record.taskID)
|
|
78
|
+
if (idx >= 0) {
|
|
79
|
+
records[idx] = record
|
|
80
|
+
} else {
|
|
81
|
+
records.push(record)
|
|
82
|
+
}
|
|
83
|
+
await this.writeState(records)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async loadAllJobs(): Promise<BackgroundJobRecord[]> {
|
|
87
|
+
return (await this.readState()) ?? []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async deleteJob(_taskID: string): Promise<void> {
|
|
91
|
+
// No-op: the board's in-memory Map is the source of truth.
|
|
92
|
+
// The file is a crash-recovery snapshot; stale entries are harmless
|
|
93
|
+
// and will be overwritten on the next saveJob().
|
|
94
|
+
}
|
|
95
|
+
}
|