dsh-remote 0.8.72 → 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/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
+ }