dsh-remote 0.8.71 → 0.8.73
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/LICENSE +21 -21
- package/README.en.md +389 -389
- package/README.md +462 -462
- package/cordis.patch.yml +9 -9
- package/lib/audit.js +45 -0
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -3990
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -118
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4550 -4548
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -886
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/package.json +100 -99
package/lib/exec-job.js
CHANGED
|
@@ -1,235 +1,235 @@
|
|
|
1
|
-
// dsh-remote — remote command execution: per-call timeout, real cancellation,
|
|
2
|
-
// and background jobs (issue #70).
|
|
3
|
-
//
|
|
4
|
-
// The transport (`SshPool.exec`) already supported `opts.timeoutMs` and
|
|
5
|
-
// `opts.signal` — #70 was pure wiring on top of it:
|
|
6
|
-
//
|
|
7
|
-
// 1. `rw_exec` had no per-call `timeoutMs`, so a 10-minute build meant editing
|
|
8
|
-
// the machine-wide `commandTimeoutMs` config and editing it back.
|
|
9
|
-
// 2. `exec.signal` was never forwarded, so pressing "stop" left the tool
|
|
10
|
-
// running until the full timeout elapsed (rw_search already forwarded it).
|
|
11
|
-
// 3. A timeout always SIGTERM'd the command. There was no way to hand a long
|
|
12
|
-
// build to the background and collect it later, unlike the built-in shell.
|
|
13
|
-
//
|
|
14
|
-
// This module owns the pieces that are easy to get subtly wrong:
|
|
15
|
-
// • the timeout clamp and the "effective value" reported in the message (the
|
|
16
|
-
// old code printed the CONFIG value, which becomes a lie the moment a
|
|
17
|
-
// per-call override exists);
|
|
18
|
-
// • streaming taps, so a background job's output reaches its ring instead of
|
|
19
|
-
// only the return value;
|
|
20
|
-
// • the job lifecycle: register → wait → settle/remove, with cancellation
|
|
21
|
-
// flowing through the SAME AbortSignal the transport honors, so a kill
|
|
22
|
-
// actually stops the remote process rather than just abandoning the call.
|
|
23
|
-
//
|
|
24
|
-
// The core `jobs` service is optional. Without it (a minimal embedding, a test
|
|
25
|
-
// double) everything degrades to foreground-only execution — the tool still
|
|
26
|
-
// works, it just cannot be promoted.
|
|
27
|
-
|
|
28
|
-
/** Per-call timeout bounds, matching the built-in shell tool's shape. */
|
|
29
|
-
export const EXEC_TIMEOUT_MIN_MS = 1000
|
|
30
|
-
export const EXEC_TIMEOUT_MAX_MS = 600000
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Resolve the effective timeout for one `rw_exec` call.
|
|
34
|
-
*
|
|
35
|
-
* A supplied value is clamped into [MIN, MAX]; an absent/invalid one falls back
|
|
36
|
-
* to the machine-wide `commandTimeoutMs` (itself clamped, so a bad config can
|
|
37
|
-
* never produce a nonsensical deadline).
|
|
38
|
-
*
|
|
39
|
-
* @param {unknown} value - the call's `timeoutMs` argument.
|
|
40
|
-
* @param {number} fallback - `config.commandTimeoutMs`.
|
|
41
|
-
* @returns {number} milliseconds to actually apply.
|
|
42
|
-
*/
|
|
43
|
-
export function resolveExecTimeout(value, fallback) {
|
|
44
|
-
const clamp = (n) => Math.min(Math.max(Math.round(n), EXEC_TIMEOUT_MIN_MS), EXEC_TIMEOUT_MAX_MS)
|
|
45
|
-
const n = Number(value)
|
|
46
|
-
if (Number.isFinite(n) && n > 0) return clamp(n)
|
|
47
|
-
const f = Number(fallback)
|
|
48
|
-
if (Number.isFinite(f) && f > 0) return clamp(f)
|
|
49
|
-
return clamp(20000)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Render a finished command into the tool's text form. Kept next to the timeout
|
|
54
|
-
* resolution so the "timed out after Nms" line can NEVER disagree with the
|
|
55
|
-
* deadline that was actually applied (issue #70 acceptance criterion 1).
|
|
56
|
-
*
|
|
57
|
-
* @param {{code: number|null, signal: string|null, stdout: string, stderr: string}} res
|
|
58
|
-
* @param {{timeoutMs: number, cancelled?: boolean}} opts
|
|
59
|
-
* @returns {string}
|
|
60
|
-
*/
|
|
61
|
-
export function formatExecResult(res, { timeoutMs, cancelled = false } = {}) {
|
|
62
|
-
const parts = []
|
|
63
|
-
if (res.stdout) parts.push(String(res.stdout).replace(/\s+$/, ''))
|
|
64
|
-
if (res.stderr) parts.push('-- stderr --\n' + String(res.stderr).replace(/\s+$/, ''))
|
|
65
|
-
if (!parts.length) parts.push('(no output)')
|
|
66
|
-
let text = parts.join('\n')
|
|
67
|
-
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${timeoutMs}ms]`
|
|
68
|
-
else if (cancelled || res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
69
|
-
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
70
|
-
return text
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Register one remote command as a core background job.
|
|
75
|
-
*
|
|
76
|
-
* The producer owns execution: the command starts inside `run()` (so a
|
|
77
|
-
* preflight rejection leaves no SSH channel behind), its output is pushed into
|
|
78
|
-
* the job ring as it arrives, and `cancel` aborts the SAME AbortSignal the
|
|
79
|
-
* transport turns into a SIGTERM on the remote process. That last point is what
|
|
80
|
-
* makes `job_kill` real rather than cosmetic.
|
|
81
|
-
*
|
|
82
|
-
* Timeout ownership is subtle and got this wrong once: a JOB's deadline must
|
|
83
|
-
* not be enforced by the transport, because the whole point of a background
|
|
84
|
-
* job is to OUTLIVE the tool call that started it. Passing `timeoutMs` down
|
|
85
|
-
* would make the transport SIGTERM the command at the very moment the caller
|
|
86
|
-
* promotes it. So the transport gets a deadline only when this job is meant to
|
|
87
|
-
* be bounded on its own (`jobDeadlineMs`); the foreground/promotion decision is
|
|
88
|
-
* made by the CALLER's wait, not by the exec.
|
|
89
|
-
*
|
|
90
|
-
* @param {object} registry - the core `jobs` service (`ctx.jobs`).
|
|
91
|
-
* @param {object} spec
|
|
92
|
-
* @param {object} spec.pool - the machine's SshPool.
|
|
93
|
-
* @param {string} spec.command - the full command line to run.
|
|
94
|
-
* @param {string} [spec.label] - one-line model-facing label (defaults to command).
|
|
95
|
-
* @param {any} [spec.owner] - owning session id, when the caller has one.
|
|
96
|
-
* @param {number} [spec.jobDeadlineMs] - optional deadline enforced ON THE
|
|
97
|
-
* COMMAND itself (omitted ⇒ the job runs until it finishes or is killed).
|
|
98
|
-
* @param {boolean} [spec.pty]
|
|
99
|
-
* @param {object} [spec.env]
|
|
100
|
-
* @returns {{id: string, cancel: Function}}
|
|
101
|
-
*/
|
|
102
|
-
export function startExecJob(registry, { pool, command, label, owner, jobDeadlineMs, pty, env }) {
|
|
103
|
-
let abort = null
|
|
104
|
-
let cancelled = null
|
|
105
|
-
const id = registry.start({
|
|
106
|
-
kind: 'rw-exec',
|
|
107
|
-
label: String(label || command || 'rw_exec'),
|
|
108
|
-
...(owner ? { owner } : {}),
|
|
109
|
-
run: (job) => {
|
|
110
|
-
const ac = new AbortController()
|
|
111
|
-
abort = ac
|
|
112
|
-
const done = (async () => {
|
|
113
|
-
let res
|
|
114
|
-
// Whether the backend actually streamed anything through the tap. A
|
|
115
|
-
// backend without an onData implementation (openssh before issue #80)
|
|
116
|
-
// leaves the ring EMPTY even though the command produced output — the
|
|
117
|
-
// only copy then lives in the return value below, which the callers of
|
|
118
|
-
// a job never see. Flush that copy into the ring at settle time, but
|
|
119
|
-
// ONLY when nothing streamed, or every line would arrive twice.
|
|
120
|
-
let sawStream = false
|
|
121
|
-
try {
|
|
122
|
-
res = await pool.exec(command, {
|
|
123
|
-
// 0/undefined ⇒ no transport deadline: the job outlives the call.
|
|
124
|
-
...(jobDeadlineMs ? { timeoutMs: jobDeadlineMs } : {}),
|
|
125
|
-
pty: !!pty,
|
|
126
|
-
env,
|
|
127
|
-
signal: ac.signal,
|
|
128
|
-
// Stream into the ring: a background job's value is its output, and
|
|
129
|
-
// the model reads it later through job_output, not from a return
|
|
130
|
-
// value it already lost.
|
|
131
|
-
onData: (channel, chunk) => {
|
|
132
|
-
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
133
|
-
if (!text) return
|
|
134
|
-
sawStream = true
|
|
135
|
-
try { job.append(text, { channel }) } catch { /* settled: ring closed */ }
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
} catch (err) {
|
|
139
|
-
return { status: 'failed', detail: String((err && err.message) || err) }
|
|
140
|
-
}
|
|
141
|
-
if (!sawStream) {
|
|
142
|
-
try {
|
|
143
|
-
if (res.stdout) job.append(String(res.stdout), { channel: 'stdout' })
|
|
144
|
-
if (res.stderr) job.append(String(res.stderr), { channel: 'stderr' })
|
|
145
|
-
} catch { /* settled: ring closed */ }
|
|
146
|
-
}
|
|
147
|
-
if (res.signal === 'ABORTED' || cancelled) return { status: 'killed', detail: cancelled || 'cancelled' }
|
|
148
|
-
if (res.signal === 'TIMEOUT') return { status: 'killed', detail: `timed out after ${jobDeadlineMs}ms` }
|
|
149
|
-
const code = res.code
|
|
150
|
-
return {
|
|
151
|
-
status: code === 0 ? 'completed' : 'failed',
|
|
152
|
-
detail: code === 0 ? 'exit code: 0' : `exit code: ${code}`,
|
|
153
|
-
}
|
|
154
|
-
})()
|
|
155
|
-
return {
|
|
156
|
-
done,
|
|
157
|
-
// Synchronous, idempotent, and it reaches the remote process: the
|
|
158
|
-
// transport turns this abort into `stream.signal('SIGTERM')`.
|
|
159
|
-
cancel: (reason) => {
|
|
160
|
-
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
161
|
-
try { ac.abort() } catch { /* already aborted */ }
|
|
162
|
-
},
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
})
|
|
166
|
-
return {
|
|
167
|
-
id,
|
|
168
|
-
cancel: (reason) => {
|
|
169
|
-
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
170
|
-
try { abort && abort.abort() } catch { /* not started yet */ }
|
|
171
|
-
},
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Run one remote command in the FOREGROUND, optionally promoting it to the
|
|
177
|
-
* background when it outlives `timeoutMs`.
|
|
178
|
-
*
|
|
179
|
-
* Without a job registry this is a plain bounded exec. With one, the command is
|
|
180
|
-
* registered first so the caller can be handed a live id the moment it stops
|
|
181
|
-
* waiting — the built-in shell's "promote on timeout" contract.
|
|
182
|
-
*
|
|
183
|
-
* @returns {Promise<{promoted: boolean, jobId?: string, text: string}>}
|
|
184
|
-
*/
|
|
185
|
-
export async function execWithOptionalPromotion({
|
|
186
|
-
registry, pool, command, label, owner, timeoutMs, pty, env, signal,
|
|
187
|
-
formatResult,
|
|
188
|
-
}) {
|
|
189
|
-
if (!registry) {
|
|
190
|
-
// No registry: no promotion is possible, so the timeout is the transport's
|
|
191
|
-
// job (bounded foreground execution, the pre-#70 behaviour).
|
|
192
|
-
const res = await pool.exec(command, { timeoutMs, pty: !!pty, env, signal })
|
|
193
|
-
return { promoted: false, text: formatResult(res, { timeoutMs }) }
|
|
194
|
-
}
|
|
195
|
-
// The transport gets NO deadline here on purpose: this command is registered
|
|
196
|
-
// as a job from the start, and the ONLY thing `timeoutMs` bounds is how long
|
|
197
|
-
// THIS CALL waits before handing it to the background. Giving the exec the
|
|
198
|
-
// same deadline would SIGTERM the command at the moment of promotion — the
|
|
199
|
-
// exact bug the issue #70 live E2E caught.
|
|
200
|
-
const job = startExecJob(registry, { pool, command, label, owner, pty, env })
|
|
201
|
-
let view
|
|
202
|
-
try {
|
|
203
|
-
view = await registry.wait(job.id, timeoutMs, owner, signal)
|
|
204
|
-
} catch {
|
|
205
|
-
// The WAIT was cancelled (the caller pressed stop), not necessarily the
|
|
206
|
-
// job: stop the job too, or the remote command keeps running behind a dead
|
|
207
|
-
// tool call.
|
|
208
|
-
try { registry.kill(job.id, owner, 'tool call aborted') } catch { /* already settled */ }
|
|
209
|
-
job.cancel('tool call aborted')
|
|
210
|
-
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
211
|
-
const aborted = { code: -1, signal: 'ABORTED', stdout: '', stderr: '' }
|
|
212
|
-
return { promoted: false, text: formatResult(aborted, { timeoutMs, cancelled: true }) }
|
|
213
|
-
}
|
|
214
|
-
if (view && (view.status === 'running' || view.status === 'stopping')) {
|
|
215
|
-
// Promotion: the command keeps running under the registry, the caller gets
|
|
216
|
-
// an id it can drain with job_output and stop with job_kill.
|
|
217
|
-
return {
|
|
218
|
-
promoted: true,
|
|
219
|
-
jobId: job.id,
|
|
220
|
-
text: `[still running after ${timeoutMs}ms; moved to background job ${job.id}]\n`
|
|
221
|
-
+ `Read its output with job_output (job_id="${job.id}") and stop it with job_kill.`,
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// Settled inside the deadline: collect the ring (the registry's own cursor)
|
|
225
|
-
// and drop the record — the model never saw the id, so it must not linger.
|
|
226
|
-
let text = ''
|
|
227
|
-
try {
|
|
228
|
-
const read = registry.read(job.id, owner)
|
|
229
|
-
text = (read.chunks || []).map((c) => c.text).join('')
|
|
230
|
-
if (read.result) text = text ? text + '\n' + read.result : read.result
|
|
231
|
-
} catch { /* fall through with an empty body */ }
|
|
232
|
-
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
233
|
-
const detail = (view && view.detail) || ''
|
|
234
|
-
return { promoted: false, text: formatResult({ code: 0, signal: null, stdout: text, stderr: '' }, { timeoutMs }) + (detail ? `\n[${detail}]` : '') }
|
|
235
|
-
}
|
|
1
|
+
// dsh-remote — remote command execution: per-call timeout, real cancellation,
|
|
2
|
+
// and background jobs (issue #70).
|
|
3
|
+
//
|
|
4
|
+
// The transport (`SshPool.exec`) already supported `opts.timeoutMs` and
|
|
5
|
+
// `opts.signal` — #70 was pure wiring on top of it:
|
|
6
|
+
//
|
|
7
|
+
// 1. `rw_exec` had no per-call `timeoutMs`, so a 10-minute build meant editing
|
|
8
|
+
// the machine-wide `commandTimeoutMs` config and editing it back.
|
|
9
|
+
// 2. `exec.signal` was never forwarded, so pressing "stop" left the tool
|
|
10
|
+
// running until the full timeout elapsed (rw_search already forwarded it).
|
|
11
|
+
// 3. A timeout always SIGTERM'd the command. There was no way to hand a long
|
|
12
|
+
// build to the background and collect it later, unlike the built-in shell.
|
|
13
|
+
//
|
|
14
|
+
// This module owns the pieces that are easy to get subtly wrong:
|
|
15
|
+
// • the timeout clamp and the "effective value" reported in the message (the
|
|
16
|
+
// old code printed the CONFIG value, which becomes a lie the moment a
|
|
17
|
+
// per-call override exists);
|
|
18
|
+
// • streaming taps, so a background job's output reaches its ring instead of
|
|
19
|
+
// only the return value;
|
|
20
|
+
// • the job lifecycle: register → wait → settle/remove, with cancellation
|
|
21
|
+
// flowing through the SAME AbortSignal the transport honors, so a kill
|
|
22
|
+
// actually stops the remote process rather than just abandoning the call.
|
|
23
|
+
//
|
|
24
|
+
// The core `jobs` service is optional. Without it (a minimal embedding, a test
|
|
25
|
+
// double) everything degrades to foreground-only execution — the tool still
|
|
26
|
+
// works, it just cannot be promoted.
|
|
27
|
+
|
|
28
|
+
/** Per-call timeout bounds, matching the built-in shell tool's shape. */
|
|
29
|
+
export const EXEC_TIMEOUT_MIN_MS = 1000
|
|
30
|
+
export const EXEC_TIMEOUT_MAX_MS = 600000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the effective timeout for one `rw_exec` call.
|
|
34
|
+
*
|
|
35
|
+
* A supplied value is clamped into [MIN, MAX]; an absent/invalid one falls back
|
|
36
|
+
* to the machine-wide `commandTimeoutMs` (itself clamped, so a bad config can
|
|
37
|
+
* never produce a nonsensical deadline).
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} value - the call's `timeoutMs` argument.
|
|
40
|
+
* @param {number} fallback - `config.commandTimeoutMs`.
|
|
41
|
+
* @returns {number} milliseconds to actually apply.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveExecTimeout(value, fallback) {
|
|
44
|
+
const clamp = (n) => Math.min(Math.max(Math.round(n), EXEC_TIMEOUT_MIN_MS), EXEC_TIMEOUT_MAX_MS)
|
|
45
|
+
const n = Number(value)
|
|
46
|
+
if (Number.isFinite(n) && n > 0) return clamp(n)
|
|
47
|
+
const f = Number(fallback)
|
|
48
|
+
if (Number.isFinite(f) && f > 0) return clamp(f)
|
|
49
|
+
return clamp(20000)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a finished command into the tool's text form. Kept next to the timeout
|
|
54
|
+
* resolution so the "timed out after Nms" line can NEVER disagree with the
|
|
55
|
+
* deadline that was actually applied (issue #70 acceptance criterion 1).
|
|
56
|
+
*
|
|
57
|
+
* @param {{code: number|null, signal: string|null, stdout: string, stderr: string}} res
|
|
58
|
+
* @param {{timeoutMs: number, cancelled?: boolean}} opts
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function formatExecResult(res, { timeoutMs, cancelled = false } = {}) {
|
|
62
|
+
const parts = []
|
|
63
|
+
if (res.stdout) parts.push(String(res.stdout).replace(/\s+$/, ''))
|
|
64
|
+
if (res.stderr) parts.push('-- stderr --\n' + String(res.stderr).replace(/\s+$/, ''))
|
|
65
|
+
if (!parts.length) parts.push('(no output)')
|
|
66
|
+
let text = parts.join('\n')
|
|
67
|
+
if (res.signal === 'TIMEOUT') text += `\n[command timed out after ${timeoutMs}ms]`
|
|
68
|
+
else if (cancelled || res.signal === 'ABORTED') text += '\n[cancelled — the remote command was stopped]'
|
|
69
|
+
else if (res.code !== 0) text += `\n[exit code: ${res.code}]`
|
|
70
|
+
return text
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register one remote command as a core background job.
|
|
75
|
+
*
|
|
76
|
+
* The producer owns execution: the command starts inside `run()` (so a
|
|
77
|
+
* preflight rejection leaves no SSH channel behind), its output is pushed into
|
|
78
|
+
* the job ring as it arrives, and `cancel` aborts the SAME AbortSignal the
|
|
79
|
+
* transport turns into a SIGTERM on the remote process. That last point is what
|
|
80
|
+
* makes `job_kill` real rather than cosmetic.
|
|
81
|
+
*
|
|
82
|
+
* Timeout ownership is subtle and got this wrong once: a JOB's deadline must
|
|
83
|
+
* not be enforced by the transport, because the whole point of a background
|
|
84
|
+
* job is to OUTLIVE the tool call that started it. Passing `timeoutMs` down
|
|
85
|
+
* would make the transport SIGTERM the command at the very moment the caller
|
|
86
|
+
* promotes it. So the transport gets a deadline only when this job is meant to
|
|
87
|
+
* be bounded on its own (`jobDeadlineMs`); the foreground/promotion decision is
|
|
88
|
+
* made by the CALLER's wait, not by the exec.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} registry - the core `jobs` service (`ctx.jobs`).
|
|
91
|
+
* @param {object} spec
|
|
92
|
+
* @param {object} spec.pool - the machine's SshPool.
|
|
93
|
+
* @param {string} spec.command - the full command line to run.
|
|
94
|
+
* @param {string} [spec.label] - one-line model-facing label (defaults to command).
|
|
95
|
+
* @param {any} [spec.owner] - owning session id, when the caller has one.
|
|
96
|
+
* @param {number} [spec.jobDeadlineMs] - optional deadline enforced ON THE
|
|
97
|
+
* COMMAND itself (omitted ⇒ the job runs until it finishes or is killed).
|
|
98
|
+
* @param {boolean} [spec.pty]
|
|
99
|
+
* @param {object} [spec.env]
|
|
100
|
+
* @returns {{id: string, cancel: Function}}
|
|
101
|
+
*/
|
|
102
|
+
export function startExecJob(registry, { pool, command, label, owner, jobDeadlineMs, pty, env }) {
|
|
103
|
+
let abort = null
|
|
104
|
+
let cancelled = null
|
|
105
|
+
const id = registry.start({
|
|
106
|
+
kind: 'rw-exec',
|
|
107
|
+
label: String(label || command || 'rw_exec'),
|
|
108
|
+
...(owner ? { owner } : {}),
|
|
109
|
+
run: (job) => {
|
|
110
|
+
const ac = new AbortController()
|
|
111
|
+
abort = ac
|
|
112
|
+
const done = (async () => {
|
|
113
|
+
let res
|
|
114
|
+
// Whether the backend actually streamed anything through the tap. A
|
|
115
|
+
// backend without an onData implementation (openssh before issue #80)
|
|
116
|
+
// leaves the ring EMPTY even though the command produced output — the
|
|
117
|
+
// only copy then lives in the return value below, which the callers of
|
|
118
|
+
// a job never see. Flush that copy into the ring at settle time, but
|
|
119
|
+
// ONLY when nothing streamed, or every line would arrive twice.
|
|
120
|
+
let sawStream = false
|
|
121
|
+
try {
|
|
122
|
+
res = await pool.exec(command, {
|
|
123
|
+
// 0/undefined ⇒ no transport deadline: the job outlives the call.
|
|
124
|
+
...(jobDeadlineMs ? { timeoutMs: jobDeadlineMs } : {}),
|
|
125
|
+
pty: !!pty,
|
|
126
|
+
env,
|
|
127
|
+
signal: ac.signal,
|
|
128
|
+
// Stream into the ring: a background job's value is its output, and
|
|
129
|
+
// the model reads it later through job_output, not from a return
|
|
130
|
+
// value it already lost.
|
|
131
|
+
onData: (channel, chunk) => {
|
|
132
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
133
|
+
if (!text) return
|
|
134
|
+
sawStream = true
|
|
135
|
+
try { job.append(text, { channel }) } catch { /* settled: ring closed */ }
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { status: 'failed', detail: String((err && err.message) || err) }
|
|
140
|
+
}
|
|
141
|
+
if (!sawStream) {
|
|
142
|
+
try {
|
|
143
|
+
if (res.stdout) job.append(String(res.stdout), { channel: 'stdout' })
|
|
144
|
+
if (res.stderr) job.append(String(res.stderr), { channel: 'stderr' })
|
|
145
|
+
} catch { /* settled: ring closed */ }
|
|
146
|
+
}
|
|
147
|
+
if (res.signal === 'ABORTED' || cancelled) return { status: 'killed', detail: cancelled || 'cancelled' }
|
|
148
|
+
if (res.signal === 'TIMEOUT') return { status: 'killed', detail: `timed out after ${jobDeadlineMs}ms` }
|
|
149
|
+
const code = res.code
|
|
150
|
+
return {
|
|
151
|
+
status: code === 0 ? 'completed' : 'failed',
|
|
152
|
+
detail: code === 0 ? 'exit code: 0' : `exit code: ${code}`,
|
|
153
|
+
}
|
|
154
|
+
})()
|
|
155
|
+
return {
|
|
156
|
+
done,
|
|
157
|
+
// Synchronous, idempotent, and it reaches the remote process: the
|
|
158
|
+
// transport turns this abort into `stream.signal('SIGTERM')`.
|
|
159
|
+
cancel: (reason) => {
|
|
160
|
+
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
161
|
+
try { ac.abort() } catch { /* already aborted */ }
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
return {
|
|
167
|
+
id,
|
|
168
|
+
cancel: (reason) => {
|
|
169
|
+
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
170
|
+
try { abort && abort.abort() } catch { /* not started yet */ }
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run one remote command in the FOREGROUND, optionally promoting it to the
|
|
177
|
+
* background when it outlives `timeoutMs`.
|
|
178
|
+
*
|
|
179
|
+
* Without a job registry this is a plain bounded exec. With one, the command is
|
|
180
|
+
* registered first so the caller can be handed a live id the moment it stops
|
|
181
|
+
* waiting — the built-in shell's "promote on timeout" contract.
|
|
182
|
+
*
|
|
183
|
+
* @returns {Promise<{promoted: boolean, jobId?: string, text: string}>}
|
|
184
|
+
*/
|
|
185
|
+
export async function execWithOptionalPromotion({
|
|
186
|
+
registry, pool, command, label, owner, timeoutMs, pty, env, signal,
|
|
187
|
+
formatResult,
|
|
188
|
+
}) {
|
|
189
|
+
if (!registry) {
|
|
190
|
+
// No registry: no promotion is possible, so the timeout is the transport's
|
|
191
|
+
// job (bounded foreground execution, the pre-#70 behaviour).
|
|
192
|
+
const res = await pool.exec(command, { timeoutMs, pty: !!pty, env, signal })
|
|
193
|
+
return { promoted: false, text: formatResult(res, { timeoutMs }) }
|
|
194
|
+
}
|
|
195
|
+
// The transport gets NO deadline here on purpose: this command is registered
|
|
196
|
+
// as a job from the start, and the ONLY thing `timeoutMs` bounds is how long
|
|
197
|
+
// THIS CALL waits before handing it to the background. Giving the exec the
|
|
198
|
+
// same deadline would SIGTERM the command at the moment of promotion — the
|
|
199
|
+
// exact bug the issue #70 live E2E caught.
|
|
200
|
+
const job = startExecJob(registry, { pool, command, label, owner, pty, env })
|
|
201
|
+
let view
|
|
202
|
+
try {
|
|
203
|
+
view = await registry.wait(job.id, timeoutMs, owner, signal)
|
|
204
|
+
} catch {
|
|
205
|
+
// The WAIT was cancelled (the caller pressed stop), not necessarily the
|
|
206
|
+
// job: stop the job too, or the remote command keeps running behind a dead
|
|
207
|
+
// tool call.
|
|
208
|
+
try { registry.kill(job.id, owner, 'tool call aborted') } catch { /* already settled */ }
|
|
209
|
+
job.cancel('tool call aborted')
|
|
210
|
+
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
211
|
+
const aborted = { code: -1, signal: 'ABORTED', stdout: '', stderr: '' }
|
|
212
|
+
return { promoted: false, text: formatResult(aborted, { timeoutMs, cancelled: true }) }
|
|
213
|
+
}
|
|
214
|
+
if (view && (view.status === 'running' || view.status === 'stopping')) {
|
|
215
|
+
// Promotion: the command keeps running under the registry, the caller gets
|
|
216
|
+
// an id it can drain with job_output and stop with job_kill.
|
|
217
|
+
return {
|
|
218
|
+
promoted: true,
|
|
219
|
+
jobId: job.id,
|
|
220
|
+
text: `[still running after ${timeoutMs}ms; moved to background job ${job.id}]\n`
|
|
221
|
+
+ `Read its output with job_output (job_id="${job.id}") and stop it with job_kill.`,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Settled inside the deadline: collect the ring (the registry's own cursor)
|
|
225
|
+
// and drop the record — the model never saw the id, so it must not linger.
|
|
226
|
+
let text = ''
|
|
227
|
+
try {
|
|
228
|
+
const read = registry.read(job.id, owner)
|
|
229
|
+
text = (read.chunks || []).map((c) => c.text).join('')
|
|
230
|
+
if (read.result) text = text ? text + '\n' + read.result : read.result
|
|
231
|
+
} catch { /* fall through with an empty body */ }
|
|
232
|
+
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
233
|
+
const detail = (view && view.detail) || ''
|
|
234
|
+
return { promoted: false, text: formatResult({ code: 0, signal: null, stdout: text, stderr: '' }, { timeoutMs }) + (detail ? `\n[${detail}]` : '') }
|
|
235
|
+
}
|