@namzu/sdk 52.2.0 → 52.4.0
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 +20 -0
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +3 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +18 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
- package/dist/connector/web/guarded-fetch.js +44 -0
- package/dist/connector/web/guarded-fetch.js.map +1 -1
- package/dist/contracts/session/api.d.ts +1 -1
- package/dist/contracts/session/api.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pals/channels/routing.d.ts +1 -1
- package/dist/pals/channels/schema.d.ts +7 -7
- package/dist/pals/communication/ingress-schema.d.ts +32 -32
- package/dist/process/kill-tree.d.ts +3 -2
- package/dist/process/kill-tree.d.ts.map +1 -1
- package/dist/process/kill-tree.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -2
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +3 -1
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +6 -1
- package/dist/public-tools.js.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.d.ts +59 -2
- package/dist/runtime/jobs/awaited-jobs.d.ts.map +1 -1
- package/dist/runtime/jobs/awaited-jobs.js +108 -5
- package/dist/runtime/jobs/awaited-jobs.js.map +1 -1
- package/dist/runtime/jobs/monitor-stream.d.ts +96 -0
- package/dist/runtime/jobs/monitor-stream.d.ts.map +1 -0
- package/dist/runtime/jobs/monitor-stream.js +221 -0
- package/dist/runtime/jobs/monitor-stream.js.map +1 -0
- package/dist/runtime/jobs/registry.d.ts +175 -1
- package/dist/runtime/jobs/registry.d.ts.map +1 -1
- package/dist/runtime/jobs/registry.js +267 -19
- package/dist/runtime/jobs/registry.js.map +1 -1
- package/dist/runtime/jobs/websocket-source.d.ts +120 -0
- package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
- package/dist/runtime/jobs/websocket-source.js +379 -0
- package/dist/runtime/jobs/websocket-source.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +9 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +6 -0
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +16 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +93 -3
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.d.ts +14 -0
- package/dist/runtime/query/iteration/outstanding-work.d.ts.map +1 -1
- package/dist/runtime/query/iteration/outstanding-work.js +64 -3
- package/dist/runtime/query/iteration/outstanding-work.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +11 -2
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/release-turn.d.ts.map +1 -1
- package/dist/runtime/query/release-turn.js +35 -0
- package/dist/runtime/query/release-turn.js.map +1 -1
- package/dist/runtime/query/steering.d.ts +35 -0
- package/dist/runtime/query/steering.d.ts.map +1 -1
- package/dist/runtime/query/steering.js +134 -0
- package/dist/runtime/query/steering.js.map +1 -1
- package/dist/runtime/query/tooling.d.ts +4 -0
- package/dist/runtime/query/tooling.d.ts.map +1 -1
- package/dist/runtime/query/tooling.js +4 -0
- package/dist/runtime/query/tooling.js.map +1 -1
- package/dist/runtime/system-events.d.ts +9 -0
- package/dist/runtime/system-events.d.ts.map +1 -1
- package/dist/runtime/system-events.js +12 -0
- package/dist/runtime/system-events.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts +3 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +3 -2
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/index.d.ts +1 -0
- package/dist/tools/builtins/index.d.ts.map +1 -1
- package/dist/tools/builtins/index.js +1 -0
- package/dist/tools/builtins/index.js.map +1 -1
- package/dist/tools/builtins/job.d.ts.map +1 -1
- package/dist/tools/builtins/job.js +6 -2
- package/dist/tools/builtins/job.js.map +1 -1
- package/dist/tools/builtins/monitor.d.ts +58 -0
- package/dist/tools/builtins/monitor.d.ts.map +1 -0
- package/dist/tools/builtins/monitor.js +306 -0
- package/dist/tools/builtins/monitor.js.map +1 -0
- package/dist/tools/builtins/wait-for-job.d.ts +1 -1
- package/dist/turn/reporter.d.ts.map +1 -1
- package/dist/turn/reporter.js +8 -0
- package/dist/turn/reporter.js.map +1 -1
- package/dist/types/sandbox/index.d.ts +10 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/session/events.d.ts +30 -4
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js +6 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +2 -2
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +3 -2
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/tool/index.d.ts +60 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +4 -2
- package/src/bridge/a2a/mapper.ts +3 -0
- package/src/bridge/sse/mapper.ts +18 -0
- package/src/connector/web/guarded-fetch.ts +41 -0
- package/src/contracts/session/api.ts +1 -0
- package/src/index.ts +23 -0
- package/src/process/kill-tree.ts +4 -2
- package/src/public-runtime.ts +4 -1
- package/src/public-tools.ts +14 -0
- package/src/runtime/jobs/awaited-jobs.ts +127 -5
- package/src/runtime/jobs/monitor-stream.ts +269 -0
- package/src/runtime/jobs/registry.ts +437 -23
- package/src/runtime/jobs/websocket-source.ts +438 -0
- package/src/runtime/query/executor.ts +15 -0
- package/src/runtime/query/index.ts +116 -3
- package/src/runtime/query/iteration/outstanding-work.ts +64 -3
- package/src/runtime/query/iteration/phases/context.ts +9 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +21 -9
- package/src/runtime/query/release-turn.ts +33 -0
- package/src/runtime/query/steering.ts +170 -0
- package/src/runtime/query/tooling.ts +8 -0
- package/src/runtime/system-events.ts +17 -0
- package/src/tools/builtins/bash.ts +3 -2
- package/src/tools/builtins/index.ts +6 -0
- package/src/tools/builtins/job.ts +11 -2
- package/src/tools/builtins/monitor.ts +376 -0
- package/src/turn/reporter.ts +8 -0
- package/src/types/sandbox/index.ts +10 -0
- package/src/types/session/events.ts +31 -4
- package/src/types/session/records.ts +3 -2
- package/src/types/tool/index.ts +51 -1
|
@@ -9,8 +9,18 @@ import type {
|
|
|
9
9
|
BackgroundJobOutputWaitOptions,
|
|
10
10
|
BackgroundJobOutputWaitResult,
|
|
11
11
|
} from '../../types/job/index.js'
|
|
12
|
+
import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
|
|
12
13
|
import { subscribeToAbort } from '../../utils/abort.js'
|
|
14
|
+
import { oneMetadataLine } from '../system-events.js'
|
|
13
15
|
import { createBackgroundJobId } from './job-id.js'
|
|
16
|
+
import {
|
|
17
|
+
MONITOR_TIMEOUT_CAP_MS,
|
|
18
|
+
type MonitorStreamStats,
|
|
19
|
+
createMonitorStream,
|
|
20
|
+
expiryNotice,
|
|
21
|
+
tooMuchOutputNotice,
|
|
22
|
+
} from './monitor-stream.js'
|
|
23
|
+
import { connectWebSocketSource, webSocketJobCommand } from './websocket-source.js'
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
26
|
* Work that outlives a tool call, owned by whoever started it.
|
|
@@ -57,6 +67,49 @@ export interface BackgroundJob {
|
|
|
57
67
|
readonly recoveryRequired?: boolean
|
|
58
68
|
/** Safe registry diagnostic for an unconfirmed stop; excludes transport secrets. */
|
|
59
69
|
readonly stopError?: string
|
|
70
|
+
/**
|
|
71
|
+
* `monitor` for a job started by the `monitor` tool: its stdout is
|
|
72
|
+
* delivered line by line as events, and it expires. Absent for every
|
|
73
|
+
* other job.
|
|
74
|
+
*/
|
|
75
|
+
readonly kind?: 'monitor'
|
|
76
|
+
/** What the monitor watches, in the model's words; shown with every event. */
|
|
77
|
+
readonly description?: string
|
|
78
|
+
/** When the monitor will be stopped if the command has not ended first (epoch ms). */
|
|
79
|
+
readonly expiresAt?: number
|
|
80
|
+
/**
|
|
81
|
+
* Why a monitor was stopped, when the registry stopped it: `expired` at
|
|
82
|
+
* its deadline, `too_much_output` when the source outran the event
|
|
83
|
+
* ration, `stopped` for a `kill()` asked for from outside. Absent while it
|
|
84
|
+
* runs and when the command ended on its own.
|
|
85
|
+
*/
|
|
86
|
+
readonly stopReason?: 'expired' | 'too_much_output' | 'stopped'
|
|
87
|
+
/**
|
|
88
|
+
* What the monitor's source is, when it is not a command: `websocket` for a
|
|
89
|
+
* monitor of a WebSocket connection, whose `command` is then
|
|
90
|
+
* `websocket <url>` and which has no exit code of a process.
|
|
91
|
+
*/
|
|
92
|
+
readonly transport?: 'websocket'
|
|
93
|
+
/** The close code and reason the server ended a WebSocket connection with, if it did. */
|
|
94
|
+
readonly closeCode?: number
|
|
95
|
+
readonly closeReason?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One thing a monitor has to say: a batch of its stdout lines, or a notice
|
|
100
|
+
* from the monitor itself (events suppressed, stopped, expired).
|
|
101
|
+
*/
|
|
102
|
+
export interface MonitorEvent {
|
|
103
|
+
/** `${jobId}:${seq}`, unique per registry and stable, so a host can acknowledge it. */
|
|
104
|
+
readonly id: string
|
|
105
|
+
readonly jobId: string
|
|
106
|
+
readonly owner: string
|
|
107
|
+
readonly description: string
|
|
108
|
+
readonly kind: 'output' | 'notice'
|
|
109
|
+
readonly text: string
|
|
110
|
+
readonly at: number
|
|
111
|
+
/** Set when the source is a WebSocket connection, whose text the server wrote. */
|
|
112
|
+
readonly transport?: 'websocket'
|
|
60
113
|
}
|
|
61
114
|
|
|
62
115
|
export interface BackgroundJobOutput {
|
|
@@ -78,6 +131,60 @@ export interface BackgroundJobOutput {
|
|
|
78
131
|
readonly error?: string
|
|
79
132
|
}
|
|
80
133
|
|
|
134
|
+
/**
|
|
135
|
+
* What a `spawn` that is not a child process hands the registry: the few
|
|
136
|
+
* members of {@link JobChild} and how to stop it. A {@link JobProcess} is one
|
|
137
|
+
* too, so a `spawn` may return either.
|
|
138
|
+
*/
|
|
139
|
+
export interface JobSource {
|
|
140
|
+
readonly child: JobChild
|
|
141
|
+
/** How to stop it, when the registry's process-group kill would not reach everything. */
|
|
142
|
+
kill?(signal: NodeJS.Signals): void
|
|
143
|
+
/** Confirm the owned tree stopped; reject instead of claiming an unconfirmed exit. */
|
|
144
|
+
terminate?(signal?: NodeJS.Signals): Promise<void>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the registry reads of a job's process, and nothing more.
|
|
149
|
+
*
|
|
150
|
+
* A `ChildProcess` satisfies it, and so does anything else that writes bytes
|
|
151
|
+
* on two pipes and ends: a WebSocket connection held as a monitor's source is
|
|
152
|
+
* such a thing. Named so that the registry's contract is the few members it
|
|
153
|
+
* uses and not a type from `node:child_process` it would have to be cast to.
|
|
154
|
+
*/
|
|
155
|
+
export interface JobChild {
|
|
156
|
+
readonly pid?: number | undefined
|
|
157
|
+
readonly stdout?: JobPipe | null
|
|
158
|
+
readonly stderr?: JobPipe | null
|
|
159
|
+
once(event: 'spawn', listener: () => void): unknown
|
|
160
|
+
on(event: 'error', listener: (error: Error) => void): unknown
|
|
161
|
+
once(
|
|
162
|
+
event: 'close',
|
|
163
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
164
|
+
): unknown
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A readable stream, as far as the registry reads one. */
|
|
168
|
+
export interface JobPipe {
|
|
169
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What a process can say to the registry besides its output, handed to the
|
|
174
|
+
* `spawn` that asked for it.
|
|
175
|
+
*/
|
|
176
|
+
export interface JobHost {
|
|
177
|
+
/**
|
|
178
|
+
* Something the SOURCE has to say, delivered to a monitor's listeners as a
|
|
179
|
+
* `notice` event and never rationed as output is. `final` ends the stream:
|
|
180
|
+
* what is pending goes out first, and nothing the process writes after the
|
|
181
|
+
* notice is delivered. Ignored for a job that is not a monitor.
|
|
182
|
+
*/
|
|
183
|
+
notice(text: string, options?: { readonly final?: boolean }): void
|
|
184
|
+
/** The server closed a WebSocket connection with this code and reason. */
|
|
185
|
+
connectionClosed(code: number, reason: string): void
|
|
186
|
+
}
|
|
187
|
+
|
|
81
188
|
/** A job's process, when something other than the registry starts it. */
|
|
82
189
|
export interface JobProcess {
|
|
83
190
|
readonly child: ReturnType<typeof spawn>
|
|
@@ -98,7 +205,7 @@ export interface StartJobParams {
|
|
|
98
205
|
* host in the `bash` tool's shell (`tools/command-shell.ts`). The process must be the leader of its own group and must not
|
|
99
206
|
* expect stdin.
|
|
100
207
|
*/
|
|
101
|
-
readonly spawn?: () => JobProcess
|
|
208
|
+
readonly spawn?: (host: JobHost) => JobProcess | JobSource
|
|
102
209
|
/**
|
|
103
210
|
* Output the process already wrote before the registry took it over, in
|
|
104
211
|
* the order it was written. Used when a foreground command is moved to the
|
|
@@ -114,6 +221,18 @@ export interface StartJobParams {
|
|
|
114
221
|
* decoded the way a foreground command's are, not as a Linux guest's.
|
|
115
222
|
*/
|
|
116
223
|
readonly hostOutput?: boolean
|
|
224
|
+
/**
|
|
225
|
+
* Run the command as a monitor: stdout is split into lines and delivered
|
|
226
|
+
* to `onMonitorEvent` listeners in batches, and the job is stopped at
|
|
227
|
+
* `timeoutMs`. Both streams stay in the retained buffer, so `read` still
|
|
228
|
+
* shows everything, stderr included.
|
|
229
|
+
*/
|
|
230
|
+
readonly monitor?: {
|
|
231
|
+
readonly description: string
|
|
232
|
+
readonly timeoutMs: number
|
|
233
|
+
/** The source is a WebSocket connection and not a command; see {@link BackgroundJob.transport}. */
|
|
234
|
+
readonly transport?: 'websocket'
|
|
235
|
+
}
|
|
117
236
|
}
|
|
118
237
|
|
|
119
238
|
export interface BackgroundJobRegistryConfig {
|
|
@@ -194,7 +313,7 @@ export class UnknownBackgroundJobError extends Error {
|
|
|
194
313
|
|
|
195
314
|
interface JobEntry {
|
|
196
315
|
record: BackgroundJob
|
|
197
|
-
child:
|
|
316
|
+
child: JobChild
|
|
198
317
|
/** The spawner's own kill, when it gave one. */
|
|
199
318
|
killProcess?: (signal: NodeJS.Signals) => void
|
|
200
319
|
terminateProcess?: (signal?: NodeJS.Signals) => Promise<void>
|
|
@@ -212,6 +331,18 @@ interface JobEntry {
|
|
|
212
331
|
exit: Promise<void>
|
|
213
332
|
/** Settles once the process exists or failed to; `error` says which. */
|
|
214
333
|
started: Promise<{ readonly error?: string }>
|
|
334
|
+
/** Present for a monitor. */
|
|
335
|
+
monitor?: MonitorState
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
interface MonitorState {
|
|
339
|
+
readonly stream: ReturnType<typeof createMonitorStream>
|
|
340
|
+
readonly description: string
|
|
341
|
+
readonly timeoutMs: number
|
|
342
|
+
seq: number
|
|
343
|
+
expiryTimer?: ReturnType<typeof setTimeout>
|
|
344
|
+
/** A terminal notice went out: nothing more is delivered, from the source or from anywhere. */
|
|
345
|
+
sealed?: boolean
|
|
215
346
|
}
|
|
216
347
|
|
|
217
348
|
/** Whether any process of the group led by `pid` is still alive. */
|
|
@@ -230,6 +361,7 @@ const GROUP_POLL_MS = 250
|
|
|
230
361
|
export class BackgroundJobRegistry {
|
|
231
362
|
private readonly jobs = new Map<string, JobEntry>()
|
|
232
363
|
private readonly exitListeners = new Set<(job: BackgroundJob) => void>()
|
|
364
|
+
private readonly monitorListeners = new Set<(event: MonitorEvent) => void>()
|
|
233
365
|
private outputWaiters = 0
|
|
234
366
|
private readonly outputWaitersByOwner = new Map<string, number>()
|
|
235
367
|
|
|
@@ -256,6 +388,53 @@ export class BackgroundJobRegistry {
|
|
|
256
388
|
}
|
|
257
389
|
}
|
|
258
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Be told what a monitor has to say, whoever owns it: a batch of its
|
|
393
|
+
* stdout lines, or a notice from the monitor itself. Returns the
|
|
394
|
+
* unsubscribe.
|
|
395
|
+
*
|
|
396
|
+
* Every listener hears every monitor's events, whatever their `owner`: a
|
|
397
|
+
* consumer serving one turn or session MUST filter on `event.owner`, as the
|
|
398
|
+
* turn does. A listener that throws is its owner's problem, as in
|
|
399
|
+
* {@link onExit}.
|
|
400
|
+
*/
|
|
401
|
+
onMonitorEvent(listener: (event: MonitorEvent) => void): () => void {
|
|
402
|
+
this.monitorListeners.add(listener)
|
|
403
|
+
return () => {
|
|
404
|
+
this.monitorListeners.delete(listener)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private announceMonitorEvent(entry: JobEntry, kind: MonitorEvent['kind'], text: string): void {
|
|
409
|
+
const monitor = entry.monitor
|
|
410
|
+
if (!monitor) return
|
|
411
|
+
monitor.seq += 1
|
|
412
|
+
const event: MonitorEvent = {
|
|
413
|
+
id: `${entry.record.id}:${monitor.seq}`,
|
|
414
|
+
jobId: entry.record.id,
|
|
415
|
+
owner: entry.record.owner,
|
|
416
|
+
description: monitor.description,
|
|
417
|
+
kind,
|
|
418
|
+
text,
|
|
419
|
+
at: Date.now(),
|
|
420
|
+
...(entry.record.transport ? { transport: entry.record.transport } : {}),
|
|
421
|
+
}
|
|
422
|
+
for (const listener of this.monitorListeners) {
|
|
423
|
+
try {
|
|
424
|
+
listener(event)
|
|
425
|
+
} catch {
|
|
426
|
+
// A listener that throws is its owner's problem, not the job's.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** What a monitor's stream has done so far, for the summary of how it ended. */
|
|
432
|
+
monitorStats(id: string): MonitorStreamStats {
|
|
433
|
+
const entry = this.jobs.get(id)
|
|
434
|
+
if (!entry) throw new UnknownBackgroundJobError({ id })
|
|
435
|
+
return entry.monitor?.stream.stats() ?? { eventsDelivered: 0, suppressed: 0, stdoutChars: 0 }
|
|
436
|
+
}
|
|
437
|
+
|
|
259
438
|
/** A failed remote stop never releases ownership or announces a terminal job. */
|
|
260
439
|
private confirmTermination(entry: JobEntry): Promise<void> {
|
|
261
440
|
if (entry.terminationAttempt) return entry.terminationAttempt
|
|
@@ -328,8 +507,14 @@ export class BackgroundJobRegistry {
|
|
|
328
507
|
// The same spawn as a foreground `bash` call, so the permission
|
|
329
508
|
// rules' reading of the line holds for the job too, and so the two
|
|
330
509
|
// cannot disagree about how a shell is started on a given platform.
|
|
331
|
-
const
|
|
332
|
-
|
|
510
|
+
const host: JobHost = {
|
|
511
|
+
notice: (text, options) => this.noticeFromSource(entry, text, options?.final === true),
|
|
512
|
+
connectionClosed: (code, reason) => {
|
|
513
|
+
entry.record = { ...entry.record, closeCode: code, closeReason: reason }
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
const started: JobProcess | JobSource = params.spawn
|
|
517
|
+
? params.spawn(host)
|
|
333
518
|
: {
|
|
334
519
|
child: spawnHostShell(params.command, {
|
|
335
520
|
cwd: params.workingDirectory,
|
|
@@ -364,6 +549,33 @@ export class BackgroundJobRegistry {
|
|
|
364
549
|
// it may be remote, and need not emit `spawn` at all.
|
|
365
550
|
if (params.spawn || child.pid !== undefined) settleStarted()
|
|
366
551
|
|
|
552
|
+
// A monitor's stdout goes through a stream that batches and rations it;
|
|
553
|
+
// everything it says reaches listeners as `MonitorEvent`s. The callbacks
|
|
554
|
+
// close over `entry`, which exists by the time any of them can run.
|
|
555
|
+
const monitor: MonitorState | undefined = params.monitor
|
|
556
|
+
? {
|
|
557
|
+
description: params.monitor.description,
|
|
558
|
+
timeoutMs: params.monitor.timeoutMs,
|
|
559
|
+
seq: 0,
|
|
560
|
+
stream: createMonitorStream({
|
|
561
|
+
onBatch: (text) => this.announceMonitorEvent(entry, 'output', text),
|
|
562
|
+
onNotice: (text) => this.announceMonitorEvent(entry, 'notice', text),
|
|
563
|
+
onOverflow: ({ suppressed, overMs }) => {
|
|
564
|
+
// The reason is recorded before the kill, so the exit that
|
|
565
|
+
// follows is described as what it was and not as a stop
|
|
566
|
+
// somebody asked for.
|
|
567
|
+
entry.record = { ...entry.record, stopReason: 'too_much_output' }
|
|
568
|
+
if (entry.monitor) entry.monitor.sealed = true
|
|
569
|
+
this.announceMonitorEvent(entry, 'notice', tooMuchOutputNotice(suppressed, overMs))
|
|
570
|
+
void this.kill(id).catch(() => {
|
|
571
|
+
// The retained record carries the failure (recoveryRequired).
|
|
572
|
+
})
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
}
|
|
576
|
+
: undefined
|
|
577
|
+
const startedAt = Date.now()
|
|
578
|
+
|
|
367
579
|
let finalizeEntry!: (code: number | null, signal: NodeJS.Signals | null) => void
|
|
368
580
|
const entry: JobEntry = {
|
|
369
581
|
record: {
|
|
@@ -371,8 +583,17 @@ export class BackgroundJobRegistry {
|
|
|
371
583
|
owner: params.owner,
|
|
372
584
|
command: params.command,
|
|
373
585
|
status: 'running',
|
|
374
|
-
startedAt
|
|
586
|
+
startedAt,
|
|
587
|
+
...(monitor
|
|
588
|
+
? {
|
|
589
|
+
kind: 'monitor' as const,
|
|
590
|
+
description: monitor.description,
|
|
591
|
+
expiresAt: startedAt + monitor.timeoutMs,
|
|
592
|
+
...(params.monitor?.transport ? { transport: params.monitor.transport } : {}),
|
|
593
|
+
}
|
|
594
|
+
: {}),
|
|
375
595
|
},
|
|
596
|
+
...(monitor ? { monitor } : {}),
|
|
376
597
|
child,
|
|
377
598
|
...(started.kill ? { killProcess: started.kill.bind(started) } : {}),
|
|
378
599
|
...(started.terminate ? { terminateProcess: started.terminate.bind(started) } : {}),
|
|
@@ -400,6 +621,15 @@ export class BackgroundJobRegistry {
|
|
|
400
621
|
}
|
|
401
622
|
resolve()
|
|
402
623
|
for (const observer of entry.outputObservers) observer()
|
|
624
|
+
if (entry.monitor) {
|
|
625
|
+
// The last batch is a monitor's last words and must precede
|
|
626
|
+
// the exit: a listener that learns the job ended and THEN
|
|
627
|
+
// gets output would be telling the model about a finished
|
|
628
|
+
// watch after it had been told the watch was over.
|
|
629
|
+
clearTimeout(entry.monitor.expiryTimer)
|
|
630
|
+
entry.monitor.stream.drain()
|
|
631
|
+
entry.monitor.stream.close()
|
|
632
|
+
}
|
|
403
633
|
this.announceExit(entry.record)
|
|
404
634
|
}
|
|
405
635
|
finalizeEntry = finalize
|
|
@@ -467,6 +697,10 @@ export class BackgroundJobRegistry {
|
|
|
467
697
|
entry.buffer += text
|
|
468
698
|
entry.buffer = outputTail(entry.buffer, this.maxBytes).text
|
|
469
699
|
entry.outputChunks.push(chunk)
|
|
700
|
+
// Only stdout is an event. stderr stays readable with `read`, which is
|
|
701
|
+
// where a watcher's diagnostics belong: a model told about every
|
|
702
|
+
// warning line would stop reading the events that matter.
|
|
703
|
+
if (stream === 'stdout') entry.monitor?.stream.push(text)
|
|
470
704
|
const retainedStart = entry.produced - Buffer.byteLength(entry.buffer)
|
|
471
705
|
while (
|
|
472
706
|
entry.outputChunks.length > MAX_CHANNEL_CHUNKS ||
|
|
@@ -497,9 +731,56 @@ export class BackgroundJobRegistry {
|
|
|
497
731
|
child.stderr?.on('data', onData('stderr'))
|
|
498
732
|
|
|
499
733
|
this.jobs.set(id, entry)
|
|
734
|
+
if (monitor) {
|
|
735
|
+
// A monitor has a life. Without a deadline a watch the model forgot
|
|
736
|
+
// would run, and bill its events, until the session ended.
|
|
737
|
+
monitor.expiryTimer = setTimeout(() => this.expireMonitor(entry), monitor.timeoutMs)
|
|
738
|
+
monitor.expiryTimer.unref?.()
|
|
739
|
+
}
|
|
500
740
|
return entry.record
|
|
501
741
|
}
|
|
502
742
|
|
|
743
|
+
/** The deadline: the last batch, then the notice, then the same kill every stop uses. */
|
|
744
|
+
private expireMonitor(entry: JobEntry): void {
|
|
745
|
+
const monitor = entry.monitor
|
|
746
|
+
if (!monitor || entry.record.status !== 'running') return
|
|
747
|
+
monitor.stream.flush()
|
|
748
|
+
// The flush can itself have stopped the monitor for too much output.
|
|
749
|
+
if (entry.record.stopReason !== undefined || entry.record.status !== 'running') return
|
|
750
|
+
entry.record = { ...entry.record, stopReason: 'expired' }
|
|
751
|
+
this.announceMonitorEvent(
|
|
752
|
+
entry,
|
|
753
|
+
'notice',
|
|
754
|
+
expiryNotice(monitor.timeoutMs, monitor.stream.stats().eventsDelivered),
|
|
755
|
+
)
|
|
756
|
+
// Nothing follows a terminal notice. A command that ignores SIGTERM keeps
|
|
757
|
+
// printing until the SIGKILL, and those lines would reach the model after
|
|
758
|
+
// it was told the watch was over.
|
|
759
|
+
monitor.sealed = true
|
|
760
|
+
monitor.stream.close()
|
|
761
|
+
void this.kill(entry.record.id).catch(() => {
|
|
762
|
+
// The retained record carries the failure (recoveryRequired).
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* A notice from the source of a monitor (a WebSocket's upgrade was refused,
|
|
768
|
+
* its connection closed). Delivered after whatever was already complete, so
|
|
769
|
+
* the last lines are not overtaken by the news that the stream ended.
|
|
770
|
+
*/
|
|
771
|
+
private noticeFromSource(entry: JobEntry, text: string, final: boolean): void {
|
|
772
|
+
const monitor = entry.monitor
|
|
773
|
+
if (!monitor || monitor.sealed) return
|
|
774
|
+
monitor.stream.flush()
|
|
775
|
+
// The flush can itself have stopped the monitor for too much output.
|
|
776
|
+
if (monitor.sealed) return
|
|
777
|
+
this.announceMonitorEvent(entry, 'notice', text)
|
|
778
|
+
if (final) {
|
|
779
|
+
monitor.sealed = true
|
|
780
|
+
monitor.stream.close()
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
503
784
|
/**
|
|
504
785
|
* Resolves once the job's process exists, or once it could not be
|
|
505
786
|
* created (`error` then says why). Lets a caller report a start that
|
|
@@ -762,9 +1043,34 @@ export class BackgroundJobRegistry {
|
|
|
762
1043
|
const entry = this.jobs.get(id)
|
|
763
1044
|
if (!entry) throw new UnknownBackgroundJobError({ id })
|
|
764
1045
|
if (entry.record.status !== 'running') return entry.record
|
|
1046
|
+
// A stop nobody else explained is the model's (or the operator's). The
|
|
1047
|
+
// expiry and output rules record their own reason first.
|
|
1048
|
+
const stoppedBy = entry.monitor !== undefined && entry.record.stopReason === undefined
|
|
1049
|
+
if (stoppedBy) entry.record = { ...entry.record, stopReason: 'stopped' }
|
|
1050
|
+
// An accepted stop ends the event stream: what was already complete goes
|
|
1051
|
+
// out, and nothing the dying process prints afterwards does. (The expiry
|
|
1052
|
+
// and output rules close it themselves, after their own notice.)
|
|
1053
|
+
const closeStream = (): void => {
|
|
1054
|
+
if (!stoppedBy || !entry.monitor) return
|
|
1055
|
+
entry.monitor.stream.flush()
|
|
1056
|
+
entry.monitor.sealed = true
|
|
1057
|
+
entry.monitor.stream.close()
|
|
1058
|
+
}
|
|
765
1059
|
if (entry.terminateProcess) {
|
|
766
1060
|
entry.killRequested = true
|
|
767
|
-
|
|
1061
|
+
try {
|
|
1062
|
+
await this.confirmTermination(entry)
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
// Still running: do not leave it saying a stop happened.
|
|
1065
|
+
if (stoppedBy) {
|
|
1066
|
+
const { stopReason: _reason, ...rest } = entry.record
|
|
1067
|
+
entry.record = rest
|
|
1068
|
+
}
|
|
1069
|
+
throw error
|
|
1070
|
+
}
|
|
1071
|
+
// Only once the stop is confirmed: a remote stop that failed leaves a
|
|
1072
|
+
// running monitor, and a closed stream could not be reopened.
|
|
1073
|
+
closeStream()
|
|
768
1074
|
await entry.exit
|
|
769
1075
|
return entry.record
|
|
770
1076
|
}
|
|
@@ -773,6 +1079,7 @@ export class BackgroundJobRegistry {
|
|
|
773
1079
|
// tell a kill from an ordinary exit. Set it after and the race decides
|
|
774
1080
|
// which of two different answers a reader gets.
|
|
775
1081
|
entry.record = { ...entry.record, status: 'killed' }
|
|
1082
|
+
closeStream()
|
|
776
1083
|
for (const observer of entry.outputObservers) observer()
|
|
777
1084
|
const signal = (sig: NodeJS.Signals) =>
|
|
778
1085
|
entry.killProcess ? entry.killProcess(sig) : killTree(entry.child, sig)
|
|
@@ -847,6 +1154,19 @@ export function bindOwner(
|
|
|
847
1154
|
* than present and silently doing nothing.
|
|
848
1155
|
*/
|
|
849
1156
|
readonly onAwaited?: (id: string) => void
|
|
1157
|
+
/**
|
|
1158
|
+
* The longest a monitor started through this binding may live. A host
|
|
1159
|
+
* may lower it (a single-shot run has no tomorrow to watch for); the
|
|
1160
|
+
* default is {@link MONITOR_TIMEOUT_CAP_MS}.
|
|
1161
|
+
*/
|
|
1162
|
+
readonly monitorTimeoutCapMs?: number
|
|
1163
|
+
/**
|
|
1164
|
+
* Be told that this binding started a monitor. The turn passes its own
|
|
1165
|
+
* recorder here when the monitor belongs to the turn itself, which is
|
|
1166
|
+
* what makes the turn stay open for it; a session-owned monitor is not
|
|
1167
|
+
* reported, because the host wakes on its events.
|
|
1168
|
+
*/
|
|
1169
|
+
readonly onMonitorStarted?: (id: string) => void
|
|
850
1170
|
} = {},
|
|
851
1171
|
) {
|
|
852
1172
|
const mine = (id: string): BackgroundJob => {
|
|
@@ -854,26 +1174,71 @@ export function bindOwner(
|
|
|
854
1174
|
if (job.owner !== owner) throw new UnknownBackgroundJobError({ id })
|
|
855
1175
|
return job
|
|
856
1176
|
}
|
|
1177
|
+
const launch = (
|
|
1178
|
+
params: { command: string; workingDirectory?: string },
|
|
1179
|
+
monitor?: StartJobParams['monitor'],
|
|
1180
|
+
): BackgroundJob => {
|
|
1181
|
+
const workingDirectory = params.workingDirectory ?? defaults.workingDirectory ?? process.cwd()
|
|
1182
|
+
const spawnHost = defaults.spawn
|
|
1183
|
+
return registry.start({
|
|
1184
|
+
owner,
|
|
1185
|
+
command: params.command,
|
|
1186
|
+
workingDirectory,
|
|
1187
|
+
...(defaults.env ? { env: defaults.env } : {}),
|
|
1188
|
+
...(spawnHost
|
|
1189
|
+
? {
|
|
1190
|
+
spawn: () =>
|
|
1191
|
+
spawnHost({
|
|
1192
|
+
command: params.command,
|
|
1193
|
+
workingDirectory,
|
|
1194
|
+
...(defaults.env ? { env: defaults.env } : {}),
|
|
1195
|
+
}),
|
|
1196
|
+
}
|
|
1197
|
+
: {}),
|
|
1198
|
+
...(monitor ? { monitor } : {}),
|
|
1199
|
+
})
|
|
1200
|
+
}
|
|
1201
|
+
const clampMonitorTimeout = (requested: number): number =>
|
|
1202
|
+
Math.min(requested, defaults.monitorTimeoutCapMs ?? MONITOR_TIMEOUT_CAP_MS)
|
|
857
1203
|
return {
|
|
858
|
-
start: (params: { command: string; workingDirectory?: string }) =>
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1204
|
+
start: (params: { command: string; workingDirectory?: string }) => launch(params),
|
|
1205
|
+
// The same spawn, owner and cap as `start`, plus a deadline. The timeout
|
|
1206
|
+
// is clamped to the host's ceiling and the clamped value is returned, so
|
|
1207
|
+
// the number the model is told is the number that will fire.
|
|
1208
|
+
startMonitor: (params: {
|
|
1209
|
+
command: string
|
|
1210
|
+
description: string
|
|
1211
|
+
timeoutMs: number
|
|
1212
|
+
workingDirectory?: string
|
|
1213
|
+
}) => {
|
|
1214
|
+
const timeoutMs = clampMonitorTimeout(params.timeoutMs)
|
|
1215
|
+
const job = launch(params, { description: params.description, timeoutMs })
|
|
1216
|
+
defaults.onMonitorStarted?.(job.id)
|
|
1217
|
+
return { job, timeoutMs }
|
|
1218
|
+
},
|
|
1219
|
+
// A monitor whose source is a WebSocket connection the HOST process
|
|
1220
|
+
// opens, whatever the sandbox spawn would have done with a command: the
|
|
1221
|
+
// tool has already checked the address and the sandbox's network rule,
|
|
1222
|
+
// and the socket is pinned to the addresses it checked. A separate
|
|
1223
|
+
// method so a host that implements `startMonitor` for commands is not
|
|
1224
|
+
// handed an input it never wrote code for.
|
|
1225
|
+
startWebSocketMonitor: (params: {
|
|
1226
|
+
description: string
|
|
1227
|
+
timeoutMs: number
|
|
1228
|
+
workingDirectory?: string
|
|
1229
|
+
webSocket: MonitorWebSocketTarget
|
|
1230
|
+
}) => {
|
|
1231
|
+
const timeoutMs = clampMonitorTimeout(params.timeoutMs)
|
|
1232
|
+
const { webSocket } = params
|
|
1233
|
+
const job = registry.start({
|
|
862
1234
|
owner,
|
|
863
|
-
command:
|
|
864
|
-
workingDirectory,
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
? {
|
|
868
|
-
spawn: () =>
|
|
869
|
-
spawnHost({
|
|
870
|
-
command: params.command,
|
|
871
|
-
workingDirectory,
|
|
872
|
-
...(defaults.env ? { env: defaults.env } : {}),
|
|
873
|
-
}),
|
|
874
|
-
}
|
|
875
|
-
: {}),
|
|
1235
|
+
command: webSocketJobCommand(webSocket.url),
|
|
1236
|
+
workingDirectory: params.workingDirectory ?? defaults.workingDirectory ?? process.cwd(),
|
|
1237
|
+
spawn: (host) => connectWebSocketSource({ target: webSocket, host }),
|
|
1238
|
+
monitor: { description: params.description, timeoutMs, transport: 'websocket' },
|
|
876
1239
|
})
|
|
1240
|
+
defaults.onMonitorStarted?.(job.id)
|
|
1241
|
+
return { job, timeoutMs }
|
|
877
1242
|
},
|
|
878
1243
|
// A foreground command that ran out of time, kept instead of killed.
|
|
879
1244
|
// The same owner and cap as `start`; the process is the one already
|
|
@@ -924,3 +1289,52 @@ export function bindOwner(
|
|
|
924
1289
|
list: () => registry.list(owner),
|
|
925
1290
|
}
|
|
926
1291
|
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* How a monitor ended, in the words the model, the transcript and a
|
|
1295
|
+
* single-shot run all use, so the three cannot disagree about it.
|
|
1296
|
+
*
|
|
1297
|
+
* `completed` is the command ending by itself: with output it is the stream
|
|
1298
|
+
* ending, and without any it is a watch that never saw anything, which a
|
|
1299
|
+
* reader must not mistake for a quiet success. A non-zero exit is `failed`.
|
|
1300
|
+
* `killed` covers every stop the registry or the model asked for; the expiry
|
|
1301
|
+
* and the too-much-output stop already told the model why in a notice of their
|
|
1302
|
+
* own, so the summary does not repeat it.
|
|
1303
|
+
*/
|
|
1304
|
+
export function describeMonitorEnd(
|
|
1305
|
+
job: Pick<
|
|
1306
|
+
BackgroundJob,
|
|
1307
|
+
| 'description'
|
|
1308
|
+
| 'status'
|
|
1309
|
+
| 'exitCode'
|
|
1310
|
+
| 'signal'
|
|
1311
|
+
| 'error'
|
|
1312
|
+
| 'transport'
|
|
1313
|
+
| 'closeCode'
|
|
1314
|
+
| 'closeReason'
|
|
1315
|
+
>,
|
|
1316
|
+
stats: Pick<MonitorStreamStats, 'stdoutChars'>,
|
|
1317
|
+
): { status: 'completed' | 'failed' | 'killed'; summary: string } {
|
|
1318
|
+
const label = `Monitor "${oneMetadataLine(job.description ?? 'unnamed')}"`
|
|
1319
|
+
if (job.status === 'killed') return { status: 'killed', summary: `${label} stopped` }
|
|
1320
|
+
if (job.transport === 'websocket') {
|
|
1321
|
+
// A connection has no script to fail and no exit status to quote: it was
|
|
1322
|
+
// closed, by the server and with a code, or it never opened.
|
|
1323
|
+
if (job.closeCode === undefined)
|
|
1324
|
+
return { status: 'failed', summary: `${label} connection failed` }
|
|
1325
|
+
// The code only. The server's reason is text it chose, and the summary
|
|
1326
|
+
// is a metadata line outside the untrusted frame; the reason reaches the
|
|
1327
|
+
// model inside the frame, in the `[WebSocket closed: ...]` notice.
|
|
1328
|
+
return {
|
|
1329
|
+
status: job.exitCode === 0 ? 'completed' : 'failed',
|
|
1330
|
+
summary: `${label} connection closed (${job.closeCode})`,
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (job.exitCode === 0 && job.error === undefined) {
|
|
1334
|
+
return stats.stdoutChars > 0
|
|
1335
|
+
? { status: 'completed', summary: `${label} stream ended` }
|
|
1336
|
+
: { status: 'completed', summary: `${label} ended without producing output (exit 0)` }
|
|
1337
|
+
}
|
|
1338
|
+
const how = job.exitCode !== undefined ? `exit ${job.exitCode}` : (job.signal ?? 'exit unknown')
|
|
1339
|
+
return { status: 'failed', summary: `${label} script failed (${how})` }
|
|
1340
|
+
}
|