dsh-remote 0.8.51 → 0.8.53
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/README.en.md +11 -0
- package/README.md +43 -0
- package/lib/client.js +15 -3
- package/lib/exec-job.js +221 -0
- package/lib/index.js +349 -18
- package/lib/instructions-dynamic.js +336 -0
- package/lib/instructions.js +288 -0
- package/lib/openssh.js +10 -1
- package/lib/pool.js +37 -1
- package/lib/remote-fs.js +26 -1
- package/lib/sftp-surface.js +46 -3
- package/lib/sshconfig.js +13 -3
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -285,6 +285,17 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
285
285
|
**`@` lists remote files but the built-in read tool cannot open them** — the harness's own file tools see the **local mirror**, which stays empty until `rw_sync` downloads it. Read remote files with `rw_read_file` or the sidebar remote tab.
|
|
286
286
|
|
|
287
287
|
**A file link in a remote Session does not open** (issue #55) — fixed. When a link is clicked the harness turns it into `dsh-resource://file/session/<session>/<path>` and hands it to the built-in text preview, which reads the **local** filesystem; a remote Session's cwd is a **local mirror directory** (empty until `rw_sync`), so the open simply failed. The plugin now registers a higher-priority handler for that address and opens it over SFTP through the same editor as the sidebar "Remote Files" tab. The safety rule is deliberately conservative: it takes over only when the Session's cwd is **provably inside a mirror** and the mirror registry has loaded, so local Sessions — and the instant right after a page load — keep the built-in preview and no local file is ever mistaken for a remote one. The two cases that still cannot be mapped say so instead of showing a blank page: an address with no mirror mapping, and a Session whose workspace is not a remote mirror directory.
|
|
288
|
+
Two exceptions (0.8.52+): `.md`/`.markdown` links are **not** taken over and stay with the built-in Markdown renderer (issue #64 — the remote editor would only show source, a net loss for Markdown; it reads the local mirror copy, so `rw_sync` first for the latest content). And images never had a renderer on either path anyway: the sidebar remote tree now renders png/jpg/gif/webp/svg/bmp inline (issue #66; files over 2MB fall back to the download button).
|
|
289
|
+
|
|
290
|
+
**One SFTP timeout wedged every SFTP tool on that machine** (issue #65) — fixed (0.8.52+). A single operation hitting the 20s timeout used to poison the whole process for that machine (every later SFTP call paid its own 20s, while `rw_exec` stayed fine) until a disconnect/reconnect. Now a timeout kills the channel so later requests fail fast; the native backend tears the connection down and re-handshakes after two consecutive timeouts, and the openssh backend rebuilds its sftp child — a transient network blip no longer ends with "disconnect and reconnect". As a bonus, `rw_edit` now writes atomically (temp file + rename), so an interrupted write can no longer truncate the remote file to zero bytes, and the original permission bits are preserved.
|
|
291
|
+
|
|
292
|
+
**The machine's IP / username changed — must every workspace be recreated?** (issue #67) — no (0.8.52+). Edit the machine's new address on the settings page and save, or call `rw_connect(machineId=…, host=…, username=…, port=…)` with the new address: every workspace mirror bound to that machine is **rebound in place** (its `.dsh-remote-meta.json` is rewritten) — local content is kept, no re-sync needed, and the save response says how many mirrors were rebound. The mirror directory name does not follow (live Sessions hold cwd paths inside it); the name is a display label only and does not affect connectivity.
|
|
293
|
+
|
|
294
|
+
**Remote Sessions could not see the workspace AGENTS.md** (issue #68) — fixed (0.8.52+). The core instruction loader only knows the local filesystem, and a remote Session's cwd is the local mirror, so the remote repo's AGENTS.md never reached the model's context. Now, when `rw_pick_workspace` succeeds, the remote workspace gets a shallow scan (root + at most two levels down, matching `AGENTS.md`/`CLAUDE.md` only); the found files are synced into the local mirror under a three-way guard (root-level copies thus enter the core's own injection path, exactly like a local Session), and an index of paths + headings is rendered into the system prompt, telling the model to read nested instruction files with `rw_read_file` before editing. Full-tree recursion and plugin-side full-text injection are deliberately **not** done (the former would scan through multi-project roots; the latter would run a second injection pipeline in parallel with the core's).
|
|
295
|
+
|
|
296
|
+
**Does reading a file in a remote subdirectory pull that subdirectory's AGENTS.md into context?** (issue #71) — yes (0.8.53+). #68 fixed only the static half (workspace shallow scan + index line); the dynamic half was still broken, because the core's incremental injection recognizes only `read`/`write`/`edit` with a `file_path` argument — `rw_read_file` matches on neither name nor argument — and it expands scopes using LOCAL paths, so a remote absolute path maps onto no mirror cwd. Any one of those three gaps was enough to stop the injection from ever happening. The plugin now hooks `rw_*` file touches itself on `tools/result`, takes the WHOLE ancestor chain from the touched directory up to the workspace root (not just the deepest level, matching local Sessions: reading `a/b/c.ts` brings in both `a/AGENTS.md` and `a/b/AGENTS.md`), stats each level, and injects the current contents as ONE user message. Boundaries that matter: the cwd's own level is not re-scanned (the core baseline covers it, so reading a file directly in the workspace root triggers no extra scan); delivery is deduplicated per session by content digest, so the same content is injected once; a remote edit is re-injected on the next touch; a previously injected file that disappears produces a "removed" notice; one batch has a 16 KB budget; and an offline machine degrades silently — it hangs neither the tool call nor the prompt assembly. The message carries the plugin's own `source.kind` (`dsh-remote-instructions`), while the core's state machine claims only its own `agent-instructions`, so the two injection paths cannot fight.
|
|
297
|
+
|
|
298
|
+
**Can `rw_exec` take a per-call timeout, be cancelled, and move to the background like the built-in shell?** (issue #70) — yes (0.8.53+). The transport (`pool.exec`) already supported `timeoutMs` and `AbortSignal`; what was missing was the wiring, and all three pieces are now in place: (1) a `timeoutMs` argument (clamped to 1000-600000, defaulting to `commandTimeoutMs`) whose timeout message prints the value ACTUALLY APPLIED rather than the config value; (2) `exec.signal` is forwarded, so pressing "stop" really SIGTERMs the remote process and returns at once instead of waiting out the timeout; (3) `run_in_background: true` returns a job id immediately (read with the existing `job_output`, stop with `job_kill`), and a command that outlives its timeout is PROMOTED to a background job instead of being killed (`promoteOnTimeout`, on by default). Background execution and promotion need the core `jobs` service; a composition without it (minimal embedding, test double) degrades to foreground-only execution — the 0.8.52 behaviour — rather than failing to load.
|
|
288
299
|
|
|
289
300
|
**The terminal in a remote Session is still the local one** (issue #55) — that is **by design, and a plugin cannot change it**, but there is an equivalent: DSH's sidebar terminal is core, its PTY comes from the **context-global** `subprocess` service, and its cwd is the Session's own workspace directory (the local mirror for a remote Session). A plugin can neither replace that service nor retarget the core terminal onto SSH. For a **genuine remote terminal** use the "Remote DSH Web UI" (above): it opens the **remote machine's own** DSH in this browser, and that interface's terminal runs on the remote host, with no extra configuration. To run a single remote command instead, use `rw_exec` (add `pty: true` for an interactive PTY).
|
|
290
301
|
|
package/README.md
CHANGED
|
@@ -274,6 +274,49 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
274
274
|
本地会话与页面刚加载的那一瞬间仍然走内置预览 —— 不会把本地文件误当成远程文件。
|
|
275
275
|
两个仍然开不了的情况会给出明确提示而不是空白页:链接指向的是**绝对**远端路径之外无法映射的地址,
|
|
276
276
|
或该会话的工作区不是一个远程镜像目录。
|
|
277
|
+
两个例外(0.8.52+):`.md`/`.markdown` 链接**不被接管**、交还内置 Markdown 渲染(issue #64——远程编辑器只能看
|
|
278
|
+
源码,对 Markdown 是净损失;它读本地镜像副本,要最新内容先 `rw_sync`);图片文件则在内置链路与远程链路
|
|
279
|
+
都谈不上「渲染」,侧栏远程文件树现在直接内联显示 png/jpg/gif/webp/svg/bmp(issue #66,>2MB 退回下载按钮)。
|
|
280
|
+
|
|
281
|
+
**SFTP 操作一旦超时就永远卡死**(issue #65)—— 已修(0.8.52+)。旧版里一次撞满 20s 超时的 SFTP 操作会把
|
|
282
|
+
该机器在本进程内的全部 SFTP 工具拖到永久超时(`rw_exec` 却正常),只能断连重连。现在超时即置死该通道、
|
|
283
|
+
后续请求立即失败;native 后端连续两次超时会废弃整条连接重新握手,openssh 后端会重建 sftp 子进程——
|
|
284
|
+
一次网络抖动不再需要用「断开重连」来收场。顺带,`rw_edit` 改成了「临时文件 + rename」原子写,
|
|
285
|
+
写一半超时不会再把远端文件截成 0 字节,原文件的权限位也会保留。
|
|
286
|
+
|
|
287
|
+
**机器换了 IP / 用户名,工作区要全部重建吗?**(issue #67)—— 不用(0.8.52+)。在设置页编辑该机器的新
|
|
288
|
+
地址并保存,或 `rw_connect(machineId=…, host=…, username=…, port=…)` 同传新地址:该机器名下所有工作区
|
|
289
|
+
镜像会**原地重绑**(改写镜像的 `.dsh-remote-meta.json`),本地内容保留、无需重新同步,保存后会提示重绑了
|
|
290
|
+
几个镜像。镜像目录名不会跟着改(活 session 的 cwd 在其中),目录名只是显示标签、不影响连接。
|
|
291
|
+
|
|
292
|
+
**远程会话看不到工作区的 AGENTS.md**(issue #68)—— 已修(0.8.52+)。核心的指令加载只认本地文件系统,
|
|
293
|
+
而远程会话的 cwd 是本地镜像,远端仓库的 AGENTS.md 以前永远进不了模型上下文。现在 `rw_pick_workspace`
|
|
294
|
+
成功时会对远程工作区做浅扫(根 + 向下 ≤2 层,只匹配 `AGENTS.md`/`CLAUDE.md`),把命中的文件按三向安全
|
|
295
|
+
规则同步进本地镜像(根级文件由此进入核心自己的注入链路,与本地会话一致),并把路径+标题索引渲染进
|
|
296
|
+
system prompt,提示模型编辑前用 `rw_read_file` 阅读嵌套层的指令。全树递归扫描与插件侧全文注入都**刻意
|
|
297
|
+
不做**(前者会扫穿多项目根目录,后者会与核心的注入管线并行打架)。
|
|
298
|
+
|
|
299
|
+
**读了远程子目录的文件,子目录里的 AGENTS.md 会自动进上下文吗?**(issue #71)—— 会(0.8.53+)。
|
|
300
|
+
#68 只修了静态部分(工作区浅扫 + 索引行);动态部分当时还断着,原因是核心的增量注入只认
|
|
301
|
+
`read`/`write`/`edit` 且只认 `file_path` 参数,而 `rw_read_file` 名字和参数都对不上,且它做作用域
|
|
302
|
+
扩展时用的是**本地**路径——远程绝对路径映射不到镜像 cwd,三个断点任一都足以让注入永不发生。
|
|
303
|
+
现在插件自己在 `tools/result` 里接 `rw_*` 文件触碰,取「被访问目录 → 工作区根」的**整条目录链**
|
|
304
|
+
(不是最近一层,与本地会话一致:读 `a/b/c.ts` 会同时收 `a/AGENTS.md` 和 `a/b/AGENTS.md`),逐层
|
|
305
|
+
`stat` 后把当前内容合成**一条** user message 注入。几个关键边界:cwd 自己那层不重扫(核心 baseline
|
|
306
|
+
已覆盖,所以读工作区根下的文件不触发任何额外扫描);按内容摘要 per-session 去重,同内容只注入一次;
|
|
307
|
+
远端修改后下次触碰即重新注入;注入过的文件被删除会发一条「已移除」提示;单次注入有 16 KB 预算;
|
|
308
|
+
机器离线时整条链路静默降级,既不挂工具调用也不挂 prompt 组装。注入消息用插件自己的
|
|
309
|
+
`source.kind`(`dsh-remote-instructions`),核心的状态机只认领它自己的 `agent-instructions`,两条
|
|
310
|
+
注入路径不会互相打架。
|
|
311
|
+
|
|
312
|
+
**`rw_exec` 能像内置 shell 那样自定义超时/取消/转后台吗?**(issue #70)—— 能(0.8.53+)。
|
|
313
|
+
底层 `pool.exec` 本来就支持 `timeoutMs` 与 `AbortSignal`,缺的是接线,现在补齐三件事:
|
|
314
|
+
① `timeoutMs` 参数(clamp 到 1000–600000,缺省仍用 `commandTimeoutMs`,超时文案打印**实际生效**的值,
|
|
315
|
+
不会印错成配置值);② `exec.signal` 透传给 `pool.exec`——点「停止」会真的 SIGTERM 掉远端进程并立即
|
|
316
|
+
返回,不用再等满超时;③ `run_in_background: true` 立即返回 job id(用现成的 `job_output` 读、
|
|
317
|
+
`job_kill` 停),以及超时**转后台**而非直接杀(`promoteOnTimeout`,默认开)。后台/转后台依赖核心的
|
|
318
|
+
`jobs` 服务;没有该服务的组合(最小嵌入、测试替身)自动退化为只有前台执行——即 0.8.52 的行为,
|
|
319
|
+
不会因此加载失败。
|
|
277
320
|
|
|
278
321
|
**远程会话里的终端还是本机的**(issue #55)—— 这是**设计如此,插件改不了**,但有等效方案。
|
|
279
322
|
DSH 的侧栏终端是核心功能:它的 PTY 来自**全局唯一**的 `subprocess` 服务,cwd 取会话自己的
|
package/lib/client.js
CHANGED
|
@@ -179,6 +179,7 @@ window.__ModuleLoader__.load({
|
|
|
179
179
|
'settings.feedback': '💬 反馈建议 / 提 issue',
|
|
180
180
|
'settings.deleted': '已删除',
|
|
181
181
|
'settings.saved': '已保存更新',
|
|
182
|
+
'settings.rebound': '已将 {n} 个本地工作区镜像重新绑定到新地址(内容保留,无需重新同步)',
|
|
182
183
|
'settings.added': '已添加 — 可设为当前',
|
|
183
184
|
'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已回退为明文保存在 machines.json,连接仍可用。',
|
|
184
185
|
'settings.switched': '已切换为当前远程机',
|
|
@@ -467,6 +468,7 @@ window.__ModuleLoader__.load({
|
|
|
467
468
|
'settings.feedback': '💬 Feedback / report issue',
|
|
468
469
|
'settings.deleted': 'Deleted',
|
|
469
470
|
'settings.saved': 'Saved',
|
|
471
|
+
'settings.rebound': 'Rebound {n} local workspace mirror(s) to the new address (content kept — no re-sync needed)',
|
|
470
472
|
'settings.added': 'Added — you can set it as current',
|
|
471
473
|
'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); it was saved as plaintext in machines.json and the machine still connects.',
|
|
472
474
|
'settings.switched': 'Switched to current remote machine',
|
|
@@ -1190,6 +1192,9 @@ window.__ModuleLoader__.load({
|
|
|
1190
1192
|
// Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
|
|
1191
1193
|
// 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
|
|
1192
1194
|
if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
|
|
1195
|
+
// Issue #67: an edited address rebinds the machine's existing mirrors —
|
|
1196
|
+
// say so, or the user cannot tell their workspaces came along.
|
|
1197
|
+
else if (r && r.reboundMirrors) setMsg(tr('settings.saved') + ' — ' + tr('settings.rebound', { n: r.reboundMirrors }))
|
|
1193
1198
|
else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
|
|
1194
1199
|
}).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
1195
1200
|
}
|
|
@@ -3237,7 +3242,7 @@ window.__ModuleLoader__.load({
|
|
|
3237
3242
|
React.useEffect(load, [path])
|
|
3238
3243
|
|
|
3239
3244
|
const startEdit = () => {
|
|
3240
|
-
if (!data || data.binary) return
|
|
3245
|
+
if (!data || data.binary || data.image) return
|
|
3241
3246
|
setDraft((data.content != null ? data.content : '').replace(/\n…\[truncated:.*$/, ''))
|
|
3242
3247
|
setEdit(true); setErr('')
|
|
3243
3248
|
}
|
|
@@ -3276,7 +3281,7 @@ window.__ModuleLoader__.load({
|
|
|
3276
3281
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, padding: '2px 10px', fontSize: 12 }, onClick: saveRemote, disabled: saving || !dirty, title: tr('file.saveHint') }, saving ? tr('file.saving') : tr('file.saveToRemote')),
|
|
3277
3282
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: () => { setEdit(false); setErr('') } }, tr('file.cancel')))
|
|
3278
3283
|
: React.createElement(React.Fragment, null,
|
|
3279
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary }, tr('file.edit')),
|
|
3284
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: startEdit, disabled: !data || data.binary || data.image }, tr('file.edit')),
|
|
3280
3285
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px', fontSize: 12 }, onClick: downloadMirror, disabled: saving }, tr('file.download'))),
|
|
3281
3286
|
),
|
|
3282
3287
|
React.createElement('div', { style: { flex: 1, overflow: 'auto', minHeight: 0, display: 'flex', flexDirection: 'column' } },
|
|
@@ -3285,7 +3290,10 @@ window.__ModuleLoader__.load({
|
|
|
3285
3290
|
: (data && data.binary
|
|
3286
3291
|
? React.createElement('div', { style: { padding: 12, fontSize: 13, color: T.warn } },
|
|
3287
3292
|
tr('file.binary', { size: (data.size != null ? data.size + ' bytes' : tr('file.unknownSize')) }))
|
|
3288
|
-
:
|
|
3293
|
+
: data && data.image
|
|
3294
|
+
? React.createElement('div', { style: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12, minHeight: 0 } },
|
|
3295
|
+
React.createElement('img', { src: `data:${data.mime};base64,${data.content}`, alt: baseName, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }))
|
|
3296
|
+
: (edit
|
|
3289
3297
|
? React.createElement('textarea', {
|
|
3290
3298
|
value: draft,
|
|
3291
3299
|
onChange: (e) => setDraft(e.target.value),
|
|
@@ -3636,6 +3644,10 @@ window.__ModuleLoader__.load({
|
|
|
3636
3644
|
canOpen: (address) => {
|
|
3637
3645
|
const parsed = parseCoreFileAddress(address)
|
|
3638
3646
|
if (!parsed) return false
|
|
3647
|
+
// Let the built-in Markdown preview keep .md/.markdown files: it can
|
|
3648
|
+
// render them, whereas the remote-link editor would only show source.
|
|
3649
|
+
const name = String(parsed.path || '').split(/[\\/]/).pop()
|
|
3650
|
+
if (/\.(md|markdown)$/i.test(name)) return false
|
|
3639
3651
|
const cwd = sessionCwdFor(parsed.sessionId)
|
|
3640
3652
|
// `MIRRORS.loaded` is checked so a cold start NEVER steals a local
|
|
3641
3653
|
// link: an empty registry cannot prove a mirror, and guessing would
|
package/lib/exec-job.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
try {
|
|
115
|
+
res = await pool.exec(command, {
|
|
116
|
+
// 0/undefined ⇒ no transport deadline: the job outlives the call.
|
|
117
|
+
...(jobDeadlineMs ? { timeoutMs: jobDeadlineMs } : {}),
|
|
118
|
+
pty: !!pty,
|
|
119
|
+
env,
|
|
120
|
+
signal: ac.signal,
|
|
121
|
+
// Stream into the ring: a background job's value is its output, and
|
|
122
|
+
// the model reads it later through job_output, not from a return
|
|
123
|
+
// value it already lost.
|
|
124
|
+
onData: (channel, chunk) => {
|
|
125
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
126
|
+
if (!text) return
|
|
127
|
+
try { job.append(text, { channel }) } catch { /* settled: ring closed */ }
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { status: 'failed', detail: String((err && err.message) || err) }
|
|
132
|
+
}
|
|
133
|
+
if (res.signal === 'ABORTED' || cancelled) return { status: 'killed', detail: cancelled || 'cancelled' }
|
|
134
|
+
if (res.signal === 'TIMEOUT') return { status: 'killed', detail: `timed out after ${jobDeadlineMs}ms` }
|
|
135
|
+
const code = res.code
|
|
136
|
+
return {
|
|
137
|
+
status: code === 0 ? 'completed' : 'failed',
|
|
138
|
+
detail: code === 0 ? 'exit code: 0' : `exit code: ${code}`,
|
|
139
|
+
}
|
|
140
|
+
})()
|
|
141
|
+
return {
|
|
142
|
+
done,
|
|
143
|
+
// Synchronous, idempotent, and it reaches the remote process: the
|
|
144
|
+
// transport turns this abort into `stream.signal('SIGTERM')`.
|
|
145
|
+
cancel: (reason) => {
|
|
146
|
+
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
147
|
+
try { ac.abort() } catch { /* already aborted */ }
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
cancel: (reason) => {
|
|
155
|
+
if (cancelled === null) cancelled = String(reason || 'cancelled')
|
|
156
|
+
try { abort && abort.abort() } catch { /* not started yet */ }
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run one remote command in the FOREGROUND, optionally promoting it to the
|
|
163
|
+
* background when it outlives `timeoutMs`.
|
|
164
|
+
*
|
|
165
|
+
* Without a job registry this is a plain bounded exec. With one, the command is
|
|
166
|
+
* registered first so the caller can be handed a live id the moment it stops
|
|
167
|
+
* waiting — the built-in shell's "promote on timeout" contract.
|
|
168
|
+
*
|
|
169
|
+
* @returns {Promise<{promoted: boolean, jobId?: string, text: string}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function execWithOptionalPromotion({
|
|
172
|
+
registry, pool, command, label, owner, timeoutMs, pty, env, signal,
|
|
173
|
+
formatResult,
|
|
174
|
+
}) {
|
|
175
|
+
if (!registry) {
|
|
176
|
+
// No registry: no promotion is possible, so the timeout is the transport's
|
|
177
|
+
// job (bounded foreground execution, the pre-#70 behaviour).
|
|
178
|
+
const res = await pool.exec(command, { timeoutMs, pty: !!pty, env, signal })
|
|
179
|
+
return { promoted: false, text: formatResult(res, { timeoutMs }) }
|
|
180
|
+
}
|
|
181
|
+
// The transport gets NO deadline here on purpose: this command is registered
|
|
182
|
+
// as a job from the start, and the ONLY thing `timeoutMs` bounds is how long
|
|
183
|
+
// THIS CALL waits before handing it to the background. Giving the exec the
|
|
184
|
+
// same deadline would SIGTERM the command at the moment of promotion — the
|
|
185
|
+
// exact bug the issue #70 live E2E caught.
|
|
186
|
+
const job = startExecJob(registry, { pool, command, label, owner, pty, env })
|
|
187
|
+
let view
|
|
188
|
+
try {
|
|
189
|
+
view = await registry.wait(job.id, timeoutMs, owner, signal)
|
|
190
|
+
} catch {
|
|
191
|
+
// The WAIT was cancelled (the caller pressed stop), not necessarily the
|
|
192
|
+
// job: stop the job too, or the remote command keeps running behind a dead
|
|
193
|
+
// tool call.
|
|
194
|
+
try { registry.kill(job.id, owner, 'tool call aborted') } catch { /* already settled */ }
|
|
195
|
+
job.cancel('tool call aborted')
|
|
196
|
+
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
197
|
+
const aborted = { code: -1, signal: 'ABORTED', stdout: '', stderr: '' }
|
|
198
|
+
return { promoted: false, text: formatResult(aborted, { timeoutMs, cancelled: true }) }
|
|
199
|
+
}
|
|
200
|
+
if (view && (view.status === 'running' || view.status === 'stopping')) {
|
|
201
|
+
// Promotion: the command keeps running under the registry, the caller gets
|
|
202
|
+
// an id it can drain with job_output and stop with job_kill.
|
|
203
|
+
return {
|
|
204
|
+
promoted: true,
|
|
205
|
+
jobId: job.id,
|
|
206
|
+
text: `[still running after ${timeoutMs}ms; moved to background job ${job.id}]\n`
|
|
207
|
+
+ `Read its output with job_output (job_id="${job.id}") and stop it with job_kill.`,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Settled inside the deadline: collect the ring (the registry's own cursor)
|
|
211
|
+
// and drop the record — the model never saw the id, so it must not linger.
|
|
212
|
+
let text = ''
|
|
213
|
+
try {
|
|
214
|
+
const read = registry.read(job.id, owner)
|
|
215
|
+
text = (read.chunks || []).map((c) => c.text).join('')
|
|
216
|
+
if (read.result) text = text ? text + '\n' + read.result : read.result
|
|
217
|
+
} catch { /* fall through with an empty body */ }
|
|
218
|
+
try { registry.remove(job.id, owner) } catch { /* settlement pending */ }
|
|
219
|
+
const detail = (view && view.detail) || ''
|
|
220
|
+
return { promoted: false, text: formatResult({ code: 0, signal: null, stdout: text, stderr: '' }, { timeoutMs }) + (detail ? `\n[${detail}]` : '') }
|
|
221
|
+
}
|