pantheon-opencode 1.5.0-beta.1 → 1.5.0-beta.11
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/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +1054 -0
- package/README.md +171 -16
- package/README.pt-BR.md +99 -13
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +23 -15
- package/plugin.json +1 -1
- package/pyproject.toml +65 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/cost.mjs +30 -14
- package/scripts/dev-tui.sh +48 -0
- package/scripts/doctor.mjs +207 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +537 -128
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +121 -53
- package/scripts/test-opencode-v1-v2-sandbox.sh +367 -96
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +537 -128
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +43 -1
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +345 -62
- package/src/pantheon/delegate-manager.ts +390 -8
- 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/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +146 -12
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +18 -1
- package/src/plugin-v2.ts +2 -14
- package/src/plugin.ts +28 -17
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +137 -27
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +459 -163
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +506 -103
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +506 -103
- package/.pantheon/code-mode/.gitkeep +0 -0
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +0 -8
- package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +0 -11
- package/src/pantheon/delegate-task-adapter.ts +0 -444
|
@@ -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
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Probe — lazy capability detection for native task() delegation.
|
|
3
|
+
*
|
|
4
|
+
* The probe is invoked on the FIRST native delegation of a session (not at
|
|
5
|
+
* startup). It creates a child session with a minimal "ping" prompt, waits
|
|
6
|
+
* for idle, and classifies the output.
|
|
7
|
+
*
|
|
8
|
+
* Results are cached per probe instance (singleton guard):
|
|
9
|
+
* - UNAVAILABLE is TERMINAL — no retry.
|
|
10
|
+
* - AVAILABLE is also cached — no re-probe.
|
|
11
|
+
*
|
|
12
|
+
* Uses the OpenCode V1 session API (session.create + promptAsync) injected via
|
|
13
|
+
* the parent context.
|
|
14
|
+
*
|
|
15
|
+
* @module native-probe
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createPantheonLogger } from './logger.ts'
|
|
19
|
+
|
|
20
|
+
const log = createPantheonLogger({ module: 'native-probe' })
|
|
21
|
+
|
|
22
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Possible outcomes of a native capability probe. */
|
|
25
|
+
export type NativeProbeResult = {
|
|
26
|
+
status: 'AVAILABLE' | 'UNAVAILABLE' | 'UNSUPPORTED'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal V1 client shape needed by the probe (session.create + promptAsync). */
|
|
30
|
+
export interface ProbeClient {
|
|
31
|
+
session: {
|
|
32
|
+
create: (input: { body: { parentID: string } }) => Promise<{ id: string }>
|
|
33
|
+
promptAsync: (input: {
|
|
34
|
+
path: { id: string }
|
|
35
|
+
body: { agent: string; parts: Array<{ type: 'text'; text: string }> }
|
|
36
|
+
}) => Promise<unknown>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Context injected into the probe function. */
|
|
41
|
+
export interface NativeProbeContext {
|
|
42
|
+
client: ProbeClient
|
|
43
|
+
parentSessionID: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Options for a single probe invocation. */
|
|
47
|
+
export interface NativeProbeOptions {
|
|
48
|
+
/** Timeout in milliseconds. Default: 30000. */
|
|
49
|
+
timeoutMs?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The probe function signature. */
|
|
53
|
+
export type NativeProbeFn = (
|
|
54
|
+
ctx: NativeProbeContext,
|
|
55
|
+
options?: NativeProbeOptions,
|
|
56
|
+
) => Promise<NativeProbeResult>
|
|
57
|
+
|
|
58
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Default timeout for the probe. */
|
|
61
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
62
|
+
|
|
63
|
+
/** Sentinel value indicating a timeout occurred. */
|
|
64
|
+
const TIMEOUT_SENTINEL = Symbol('timeout')
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap a promise with a timeout.
|
|
68
|
+
* Returns the value on success, throws the original error on rejection,
|
|
69
|
+
* or returns TIMEOUT_SENTINEL if the timeout fires first.
|
|
70
|
+
*/
|
|
71
|
+
async function withTimeout<T>(
|
|
72
|
+
promise: Promise<T>,
|
|
73
|
+
ms: number,
|
|
74
|
+
): Promise<T | typeof TIMEOUT_SENTINEL> {
|
|
75
|
+
return new Promise<T | typeof TIMEOUT_SENTINEL>((resolve, reject) => {
|
|
76
|
+
const timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), ms)
|
|
77
|
+
void promise.then(
|
|
78
|
+
(value) => {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
resolve(value)
|
|
81
|
+
},
|
|
82
|
+
(err: unknown) => {
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
reject(err)
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract text content from a promptAsync response.
|
|
92
|
+
* Returns empty string if no recognizable content found.
|
|
93
|
+
*/
|
|
94
|
+
function extractOutput(result: unknown): string {
|
|
95
|
+
if (result == null) return ''
|
|
96
|
+
if (typeof result === 'string') return result
|
|
97
|
+
if (typeof result === 'object') {
|
|
98
|
+
const obj = result as Record<string, unknown>
|
|
99
|
+
// Check common shapes: { output }, { content }, { text }
|
|
100
|
+
if (typeof obj.output === 'string') return obj.output
|
|
101
|
+
if (typeof obj.content === 'string') return obj.content
|
|
102
|
+
if (typeof obj.text === 'string') return obj.text
|
|
103
|
+
}
|
|
104
|
+
// Non-empty object → treat as content (accepted message, etc.)
|
|
105
|
+
if (typeof result === 'object' && result !== null) {
|
|
106
|
+
return 'acknowledged'
|
|
107
|
+
}
|
|
108
|
+
return ''
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isUnsupportedError(message: string): boolean {
|
|
112
|
+
const lower = message.toLowerCase()
|
|
113
|
+
return (
|
|
114
|
+
lower.includes('not available') ||
|
|
115
|
+
lower.includes('not supported') ||
|
|
116
|
+
lower.includes('unsupported') ||
|
|
117
|
+
lower.includes('method not found')
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Implementation ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a native probe function with per-instance caching.
|
|
125
|
+
*
|
|
126
|
+
* The probe is LAZY: it does not run at startup. Call it on the first
|
|
127
|
+
* native delegation of the session. The result is cached — subsequent
|
|
128
|
+
* calls return the cached value without re-probing.
|
|
129
|
+
*
|
|
130
|
+
* @returns A probe function that caches results per instance.
|
|
131
|
+
*/
|
|
132
|
+
export function createNativeProbe(): NativeProbeFn {
|
|
133
|
+
let cachedResult: NativeProbeResult | null = null
|
|
134
|
+
let inFlight: Promise<NativeProbeResult> | null = null
|
|
135
|
+
|
|
136
|
+
return async (
|
|
137
|
+
ctx: NativeProbeContext,
|
|
138
|
+
options?: NativeProbeOptions,
|
|
139
|
+
): Promise<NativeProbeResult> => {
|
|
140
|
+
// Return cached result if available (singleton guard)
|
|
141
|
+
if (cachedResult !== null) {
|
|
142
|
+
return cachedResult
|
|
143
|
+
}
|
|
144
|
+
// Concurrent first dispatches share one probe instead of creating multiple
|
|
145
|
+
// child sessions. The promise is cleared only after a terminal result.
|
|
146
|
+
if (inFlight !== null) return inFlight
|
|
147
|
+
|
|
148
|
+
inFlight = probe(ctx, options)
|
|
149
|
+
try {
|
|
150
|
+
return await inFlight
|
|
151
|
+
} finally {
|
|
152
|
+
inFlight = null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function probe(
|
|
157
|
+
ctx: NativeProbeContext,
|
|
158
|
+
options?: NativeProbeOptions,
|
|
159
|
+
): Promise<NativeProbeResult> {
|
|
160
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
161
|
+
|
|
162
|
+
// ── Phase 1: create child session ──────────────────────────────
|
|
163
|
+
let childID: string
|
|
164
|
+
try {
|
|
165
|
+
const createResult = await withTimeout(
|
|
166
|
+
ctx.client.session.create({ body: { parentID: ctx.parentSessionID } }),
|
|
167
|
+
timeoutMs,
|
|
168
|
+
)
|
|
169
|
+
if (createResult === TIMEOUT_SENTINEL) {
|
|
170
|
+
// Timeout during session.create → UNAVAILABLE
|
|
171
|
+
log.warn('[Native Probe] session.create timed out after', timeoutMs, 'ms')
|
|
172
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
173
|
+
cachedResult = result
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
childID = createResult.id
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
179
|
+
log.error('[Native Probe] session.create failed:', msg)
|
|
180
|
+
// Check for unsupported API patterns
|
|
181
|
+
if (isUnsupportedError(msg)) {
|
|
182
|
+
const result: NativeProbeResult = { status: 'UNSUPPORTED' }
|
|
183
|
+
cachedResult = result
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
// Other creation failures → UNAVAILABLE
|
|
187
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
188
|
+
cachedResult = result
|
|
189
|
+
return result
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Phase 2: send probe prompt ─────────────────────────────────
|
|
193
|
+
let promptResult: unknown
|
|
194
|
+
try {
|
|
195
|
+
const raw = await withTimeout(
|
|
196
|
+
ctx.client.session.promptAsync({
|
|
197
|
+
path: { id: childID },
|
|
198
|
+
body: { agent: 'probe', parts: [{ type: 'text', text: 'Reply with PONG.' }] },
|
|
199
|
+
}),
|
|
200
|
+
timeoutMs,
|
|
201
|
+
)
|
|
202
|
+
if (raw === TIMEOUT_SENTINEL) {
|
|
203
|
+
// Timeout during promptAsync → UNAVAILABLE
|
|
204
|
+
log.warn('[Native Probe] promptAsync timed out after', timeoutMs, 'ms')
|
|
205
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
206
|
+
cachedResult = result
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
209
|
+
promptResult = raw
|
|
210
|
+
} catch (err: unknown) {
|
|
211
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
212
|
+
log.error('[Native Probe] promptAsync failed:', msg)
|
|
213
|
+
if (isUnsupportedError(msg)) {
|
|
214
|
+
const result: NativeProbeResult = { status: 'UNSUPPORTED' }
|
|
215
|
+
cachedResult = result
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
// promptAsync error → UNAVAILABLE
|
|
219
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
220
|
+
cachedResult = result
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Phase 3: classify output ───────────────────────────────────
|
|
225
|
+
const output = extractOutput(promptResult)
|
|
226
|
+
const hasContent = output.trim() !== ''
|
|
227
|
+
|
|
228
|
+
const result: NativeProbeResult = {
|
|
229
|
+
status: hasContent ? 'AVAILABLE' : 'UNAVAILABLE',
|
|
230
|
+
}
|
|
231
|
+
cachedResult = result
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Task Status — classifies BackgroundJobRecord results from native
|
|
3
|
+
* task() delegation into actionable status codes.
|
|
4
|
+
*
|
|
5
|
+
* Pure module — zero external dependencies. Consumed by native-probe.ts
|
|
6
|
+
* and the delegation manager to decide retry/skip/escalate strategy.
|
|
7
|
+
*
|
|
8
|
+
* @module native-task-status
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BackgroundJobRecord } from './background-job-board.ts'
|
|
12
|
+
|
|
13
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Actionable status for a native task() delegation result. */
|
|
16
|
+
export type NativeTaskStatus =
|
|
17
|
+
| 'OK'
|
|
18
|
+
| 'UNSUPPORTED'
|
|
19
|
+
| 'UNAVAILABLE'
|
|
20
|
+
| 'INVALID_INPUT'
|
|
21
|
+
| 'INVALID_STATE'
|
|
22
|
+
| 'CONFLICT'
|
|
23
|
+
| 'CORRUPT_DATA'
|
|
24
|
+
| 'TIMEOUT'
|
|
25
|
+
| 'ESCALATE'
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Check if a string contains any of the given substrings (case-insensitive). */
|
|
30
|
+
function containsAny(text: string | undefined, patterns: readonly string[]): boolean {
|
|
31
|
+
if (text == null) return false
|
|
32
|
+
const lower = text.toLowerCase()
|
|
33
|
+
return patterns.some((p) => lower.includes(p.toLowerCase()))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Check if a job's output is empty or whitespace-only. */
|
|
37
|
+
function isOutputEmpty(job: BackgroundJobRecord): boolean {
|
|
38
|
+
const summary = job.resultSummary
|
|
39
|
+
if (summary == null) return true
|
|
40
|
+
if (typeof summary !== 'string') return true
|
|
41
|
+
return summary.trim() === ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Implementation ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify a BackgroundJobRecord result into a NativeTaskStatus.
|
|
48
|
+
*
|
|
49
|
+
* Priority order (first match wins):
|
|
50
|
+
* 1. verifyError and lastStatusError patterns → UNSUPPORTED, CONFLICT,
|
|
51
|
+
* INVALID_INPUT, INVALID_STATE, CORRUPT_DATA, ESCALATE
|
|
52
|
+
* 2. state === startup_failed or timedOut → TIMEOUT
|
|
53
|
+
* 3. state === completed + non-empty output → OK
|
|
54
|
+
* 4. state === completed + empty output → UNAVAILABLE (silent failure / admission rejection)
|
|
55
|
+
* 5. state === error → UNAVAILABLE (safe default for provider/infra errors)
|
|
56
|
+
* 6. Fallback → UNAVAILABLE
|
|
57
|
+
*/
|
|
58
|
+
export function classifyNativeResult(
|
|
59
|
+
job: BackgroundJobRecord,
|
|
60
|
+
verifyError?: string,
|
|
61
|
+
): NativeTaskStatus {
|
|
62
|
+
const statusError = [verifyError, job.lastStatusError]
|
|
63
|
+
.filter((value): value is string => value != null && value.length > 0)
|
|
64
|
+
.join(' ')
|
|
65
|
+
|
|
66
|
+
// A missing native task API is terminal and must not be retried or sent
|
|
67
|
+
// through a fallback model. Provider/transport failures remain unavailable.
|
|
68
|
+
if (
|
|
69
|
+
containsAny(statusError, [
|
|
70
|
+
'api unavailable',
|
|
71
|
+
'api not available',
|
|
72
|
+
'api missing',
|
|
73
|
+
'method not found',
|
|
74
|
+
'unknown method',
|
|
75
|
+
'not supported',
|
|
76
|
+
'unsupported',
|
|
77
|
+
'does not support',
|
|
78
|
+
'task() is not available',
|
|
79
|
+
])
|
|
80
|
+
) {
|
|
81
|
+
return 'UNSUPPORTED'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Phase 1: verifyError explicit signals (highest priority) ───────
|
|
85
|
+
|
|
86
|
+
if (verifyError != null && verifyError.length > 0) {
|
|
87
|
+
// CONFLICT: canDispatch=false or concurrency exceeded
|
|
88
|
+
if (containsAny(verifyError, ['canDispatch', 'concurrency', 'conflict'])) {
|
|
89
|
+
return 'CONFLICT'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// INVALID_INPUT: agent not recognized / invalid
|
|
93
|
+
if (containsAny(verifyError, ['invalid agent', 'agent not found', 'unknown agent'])) {
|
|
94
|
+
return 'INVALID_INPUT'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// INVALID_STATE: child already in terminal state
|
|
98
|
+
if (containsAny(verifyError, ['already terminal', 'already in terminal', 'invalid state'])) {
|
|
99
|
+
return 'INVALID_STATE'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// CORRUPT_DATA: output can't be parsed / corrupted
|
|
103
|
+
if (containsAny(verifyError, ['corrupt', 'parse failed', 'malformed', 'invalid format'])) {
|
|
104
|
+
return 'CORRUPT_DATA'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ESCALATE: unrecoverable failure
|
|
108
|
+
if (containsAny(verifyError, ['unrecoverable', 'fatal', 'max retries exceeded'])) {
|
|
109
|
+
return 'ESCALATE'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Phase 2: state-based classification ────────────────────────────
|
|
114
|
+
|
|
115
|
+
// TIMEOUT: startup failure or timed out
|
|
116
|
+
if (job.state === 'startup_failed' || job.state === 'startup_unknown' || job.timedOut) {
|
|
117
|
+
return 'TIMEOUT'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Phase 3: output-based classification ───────────────────────────
|
|
121
|
+
|
|
122
|
+
if (job.state === 'completed') {
|
|
123
|
+
if (!isOutputEmpty(job)) {
|
|
124
|
+
return 'OK'
|
|
125
|
+
}
|
|
126
|
+
// completed but empty → silent failure (BackendAdmissionRejected pattern)
|
|
127
|
+
return 'UNAVAILABLE'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Phase 4: error state fallback ──────────────────────────────────
|
|
131
|
+
|
|
132
|
+
if (job.state === 'error') {
|
|
133
|
+
return 'UNAVAILABLE'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Fallback ───────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
return 'UNAVAILABLE'
|
|
139
|
+
}
|