dsh-plugin-wsl-env 0.12.0 → 0.13.1
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 +195 -0
- package/CONTRIBUTING.md +28 -6
- package/README.md +13 -6
- package/SECURITY.md +11 -1
- package/SUPPORT.md +29 -2
- package/cordis.patch.yml +9 -2
- package/docs/ARCHITECTURE.md +7 -5
- package/docs/CONFIGURATION.md +75 -16
- package/docs/LIMITATIONS.md +47 -7
- package/docs/MIGRATION.md +98 -0
- package/docs/PARITY.md +1 -1
- package/docs/PERFORMANCE.md +60 -0
- package/docs/QUICKSTART.md +125 -0
- package/docs/README.zh.md +6 -4
- package/docs/ROADMAP-1.0.md +780 -0
- package/docs/ROADMAP.md +4 -0
- package/docs/UPSTREAM-FILE-REFERENCE-SEAM.md +80 -0
- package/docs/UPSTREAM-FSIO-EXPORT.md +3 -2
- package/docs/UPSTREAM-LSP-SEAM.md +90 -0
- package/docs/UPSTREAM-SESSION-NOTICE.md +95 -0
- package/lib/agent-errors.d.ts +19 -0
- package/lib/agent-errors.js +22 -0
- package/lib/agent-exec.d.ts +9 -2
- package/lib/agent-exec.js +4 -4
- package/lib/agent.d.ts +68 -5
- package/lib/agent.js +129 -22
- package/lib/distro-route.d.ts +75 -0
- package/lib/distro-route.js +86 -0
- package/lib/fs-routing.d.ts +4 -1
- package/lib/index.d.ts +166 -37
- package/lib/index.js +358 -74
- package/lib/preflight.d.ts +46 -3
- package/lib/preflight.js +90 -15
- package/lib/shell-env.d.ts +9 -3
- package/lib/shell-env.js +128 -43
- package/lib/subprocess.d.ts +12 -3
- package/lib/subprocess.js +21 -3
- package/package.json +23 -6
- package/scripts/diagnose.sh +9 -88
- package/scripts/doctor.mjs +548 -0
- package/scripts/profile-preset.mjs +254 -0
package/docs/ROADMAP.md
CHANGED
|
@@ -6,6 +6,10 @@ measurement; the numbers are in the ADR's
|
|
|
6
6
|
[FS queue measurement](adr/0002-native-agent.md#fs-queue-measurement-a6-2026-10-18)
|
|
7
7
|
section.
|
|
8
8
|
|
|
9
|
+
> The product and ecosystem plan toward 1.0 is
|
|
10
|
+
> [ROADMAP-1.0.md](ROADMAP-1.0.md). This file remains the current technical
|
|
11
|
+
> plan for the native agent.
|
|
12
|
+
|
|
9
13
|
## Done
|
|
10
14
|
|
|
11
15
|
| Step | Outcome |
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Upstream seam: `@` references over a WSL workspace
|
|
2
|
+
|
|
3
|
+
Status: measured and deferred (2026-10-19). The `@` completion in
|
|
4
|
+
`@deepseek-ai/dsh-file-reference-local` walks the workspace with
|
|
5
|
+
`node:fs/promises` (`readdir` + `lstat`) from the **Windows** harness process.
|
|
6
|
+
For a WSL workspace that walk crosses the 9p share on every directory; the
|
|
7
|
+
plugin already has a distro-side traversal, but it cannot be wired until
|
|
8
|
+
upstream exposes the funnel. This note records the cost, the seam, and the
|
|
9
|
+
decision.
|
|
10
|
+
|
|
11
|
+
## The measured cost
|
|
12
|
+
|
|
13
|
+
Probe: [`test/probe/file-reference-9p.sh`](../test/probe/file-reference-9p.sh)
|
|
14
|
+
(three runs, `node:fs` readdir walk, no ranking engine). Tree: 20 directories,
|
|
15
|
+
520 files, one level of subdirectories; the same tree walked from Windows over
|
|
16
|
+
`\\wsl.localhost\<distro>` and from inside the distro on ext4.
|
|
17
|
+
|
|
18
|
+
| Walk | Median | Samples |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Windows over 9p (`\\wsl.localhost\...`) | **84.7 ms** | 192.5, 84.7, 63.0 |
|
|
21
|
+
| Distro on ext4 | **1.5 ms** | 2.4, 1.5, 1.3 |
|
|
22
|
+
|
|
23
|
+
The ratio is ~56× on this tree. Variance on the 9p side is large (63–192 ms
|
|
24
|
+
across three runs), which is itself the point: the cost is per-directory 9p
|
|
25
|
+
traffic, so it grows with the tree and is sensitive to the share's state. A
|
|
26
|
+
synthetic 520-entry tree is a floor for a real repository, not a ceiling.
|
|
27
|
+
|
|
28
|
+
These numbers are the *traversal* only. The fuzzy ranking, generation and
|
|
29
|
+
invalidation logic is not part of the measurement — it is the upstream code the
|
|
30
|
+
seam must keep.
|
|
31
|
+
|
|
32
|
+
## The seam
|
|
33
|
+
|
|
34
|
+
RFC addendum ① (in [rfc-wsl-workspaces.md](upstream/rfc-wsl-workspaces.md))
|
|
35
|
+
proposes exposing the three module-internal funnels as an injectable strategy:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface FileReferenceTraversal {
|
|
39
|
+
readWorkspaceRoot(absolute: string, signal: AbortSignal): Promise<TraversalDirent[]>
|
|
40
|
+
readDirectory(absolute: string, signal: AbortSignal): Promise<TraversalDirent[]>
|
|
41
|
+
resolveDisplayDirectory(root: string, displayDirectory: string, signal: AbortSignal): Promise<string | undefined>
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
with three semantics that matter for a remote world:
|
|
46
|
+
|
|
47
|
+
- **Dirent shape**: entries stay `{ name, isDirectory(), isFile() }`; a symlink
|
|
48
|
+
is neither, so the distro listing uses `find -printf '%y\t%f'` (type char `l`)
|
|
49
|
+
rather than resolving links.
|
|
50
|
+
- **Errors**: an unreadable directory yields `[]`; an aborted caller propagates
|
|
51
|
+
the abort.
|
|
52
|
+
- **Coordinates**: the returned absolutes stay in the root's own coordinate
|
|
53
|
+
system (UNC for a distro workspace), because upstream joins children with the
|
|
54
|
+
host `node:path` — a POSIX path from the distro layer would be mangled.
|
|
55
|
+
|
|
56
|
+
## The plugin side is ready
|
|
57
|
+
|
|
58
|
+
[`src/file-reference-wsl.ts`](../src/file-reference-wsl.ts) implements that
|
|
59
|
+
interface over the resident agent: one `find -printf` per directory, a
|
|
60
|
+
single-exec segment walk for `resolveDisplayDirectory` (instead of N sequential
|
|
61
|
+
`lstat`s), and the UNC/POSIX conversions. It is unit-tested against a fake
|
|
62
|
+
agent and deliberately **not** wired into `cordis.patch.yml`: the funnel
|
|
63
|
+
functions are module-internal, so wiring today would mean subclassing past them
|
|
64
|
+
or re-implementing the ranking engine — the regression path the RFC rules out.
|
|
65
|
+
|
|
66
|
+
## Decision and trigger
|
|
67
|
+
|
|
68
|
+
**Fallback: keep the 9p walk.** `@` completion works today; it pays the
|
|
69
|
+
per-directory share cost above. There is no plugin-side optimization that is
|
|
70
|
+
safe without the seam, and the plugin does not replace the upstream module.
|
|
71
|
+
|
|
72
|
+
Wire `file-reference-wsl` when either happens:
|
|
73
|
+
|
|
74
|
+
- upstream exposes the traversal strategy (or an equivalent injectable funnel);
|
|
75
|
+
- the plugin's own composition gains a way to supply the strategy without
|
|
76
|
+
patching module internals.
|
|
77
|
+
|
|
78
|
+
At that point: wire the consumer, add an `@`-completion latency probe (the
|
|
79
|
+
traversal probe above is the baseline), and only then claim a speed-up. Until
|
|
80
|
+
then neither the README nor the roadmap promises one.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Upstream proposal: export the fsio text helpers from `dsh-fs-local`
|
|
2
2
|
|
|
3
|
-
Status:
|
|
4
|
-
|
|
3
|
+
Status: filed upstream as
|
|
4
|
+
[deepseek-ai/deepseek-harness#10262](https://github.com/deepseek-ai/deepseek-harness/discussions/10262)
|
|
5
|
+
(Ideas); the filing convention is [UPSTREAM-TERMINAL-TITLE.md](UPSTREAM-TERMINAL-TITLE.md).
|
|
5
6
|
Targeting `deepseek-harness` @ `packages/fs/fs-local`, the `0.2.1-rc.x` line
|
|
6
7
|
this plugin pins exactly (`@deepseek-ai/dsh-fs-local@0.2.1-rc.2`, in both the
|
|
7
8
|
dev and peer dependency blocks of `package.json`). This is the Shape-A
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Upstream seam: LSP navigation for a WSL workspace
|
|
2
|
+
|
|
3
|
+
Status: researched, **prototype deferred** (2026-10-19). No user-facing claim is
|
|
4
|
+
made: the desktop composition does not mount `ctx.lsp` in `0.2.1-rc.2`, so
|
|
5
|
+
`lsp` is not a tool the model can call in this plugin's profiles today. This
|
|
6
|
+
note records what the seam is, what a WSL-backed provider would require, and
|
|
7
|
+
the trigger that turns the prototype back on.
|
|
8
|
+
|
|
9
|
+
## What exists upstream
|
|
10
|
+
|
|
11
|
+
The LSP capability is split across three packages in the harness tree
|
|
12
|
+
(`packages/lsp/`):
|
|
13
|
+
|
|
14
|
+
| Package | Role |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `@deepseek-ai/dsh-lsp` | the `ctx.lsp` service and the provider registry; four read-only operations: `goToDefinition`, `findReferences`, `goToImplementation`, `hover` |
|
|
17
|
+
| `@deepseek-ai/dsh-lsp-stdio` | the generic stdio provider: a configured language-server command per workspace, transient document open, serialized queries |
|
|
18
|
+
| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool, which owns the one-based cursor convention |
|
|
19
|
+
|
|
20
|
+
It is an optional capability, not part of the agent-loop spine: a composition
|
|
21
|
+
must mount the seam, a provider, and the tool; none are in the base profile.
|
|
22
|
+
|
|
23
|
+
**Checked against `dsh-v0.2.1-rc.2`:** the desktop's
|
|
24
|
+
`@deepseek-ai/dsh-base/cordis.patch.yml` (the app's bundled base layer) has no
|
|
25
|
+
`lsp` row, and `packages/bundle/*` reference neither `dsh-lsp` nor
|
|
26
|
+
`dsh-tool-lsp`. The packages exist and are test-covered upstream, but a
|
|
27
|
+
stock desktop session has no LSP surface.
|
|
28
|
+
|
|
29
|
+
## The pairing the provider assumes
|
|
30
|
+
|
|
31
|
+
`dsh-lsp-stdio` declares `inject = ['fs', 'lsp', 'subprocess']`: source files
|
|
32
|
+
are read through `ctx.fs`, and the language server is spawned through
|
|
33
|
+
`ctx.subprocess`. Both must describe **the same execution world**:
|
|
34
|
+
|
|
35
|
+
- the server's executable is resolved with `ctx.subprocess.resolveExecutable`
|
|
36
|
+
and spawned with `ctx.subprocess.spawn`;
|
|
37
|
+
- the queried source is read through `ctx.fs`, which also produces the
|
|
38
|
+
`fileUrl` sent as the transient document's URI;
|
|
39
|
+
- `workspaceRoot` is caller-supplied and canonicalized by the provider.
|
|
40
|
+
|
|
41
|
+
In a WSL session, `ctx.fs` (the preset's isolated `wsl-fs`) and `ctx.subprocess`
|
|
42
|
+
currently describe **different worlds**: the filesystem is the distro's, the
|
|
43
|
+
subprocess seam is the host's. The plugin's preset isolates `shell` and `fs`
|
|
44
|
+
only; the app-level `subprocess-wsl` row exists for the GUI terminal and is not
|
|
45
|
+
preset-scoped. Mounting `dsh-lsp-stdio` in the WSL preset today would therefore
|
|
46
|
+
launch a Windows language server against distro source and send it URIs it
|
|
47
|
+
cannot open.
|
|
48
|
+
|
|
49
|
+
## What a WSL-backed provider needs
|
|
50
|
+
|
|
51
|
+
1. **A preset-scoped `subprocess` implementation.** `resolveExecutable` and
|
|
52
|
+
`spawn` must run inside the distro (argv, cwd, env in Linux coordinates),
|
|
53
|
+
with the same lifetime rules as the shell executor's commands. This is the
|
|
54
|
+
largest piece and has no upstream seam today; the terminal's
|
|
55
|
+
`subprocess-wsl` is a different, app-level consumer.
|
|
56
|
+
2. **URI and path translation.** The server's world is `file:///home/...`;
|
|
57
|
+
the harness workspace identity is the UNC target
|
|
58
|
+
(`\\wsl.localhost\<distro>\...`). The provider/tool boundary must translate
|
|
59
|
+
both directions (request source, `workspaceFolders`, returned locations),
|
|
60
|
+
or the seam must gain a path/URI adapter instead of providers doing it
|
|
61
|
+
ad hoc.
|
|
62
|
+
3. **Confinement and lifecycle.** A language server is a long-lived child of
|
|
63
|
+
the session: it should run under the same sandbox policy as commands
|
|
64
|
+
(`bwrap`), die with the session, and be subject to the same idle reclaim as
|
|
65
|
+
the resident agent. "Same execution world" also implies the server sees the
|
|
66
|
+
workspace at the same `/home/...` path the commands see.
|
|
67
|
+
4. **Configuration per distro.** The `servers` record (id → command, extension →
|
|
68
|
+
language id) and executable resolution belong with the session's distro, not
|
|
69
|
+
the host profile: the same `typescript-language-server` may exist in one
|
|
70
|
+
distro and not another.
|
|
71
|
+
|
|
72
|
+
## Prototype decision
|
|
73
|
+
|
|
74
|
+
**Deferred, with a trigger.** The prototype cost is dominated by item 1 (a
|
|
75
|
+
preset-scoped subprocess provider) and the tool surface does not exist in the
|
|
76
|
+
shipped composition, so building it now would produce an unreachable code path.
|
|
77
|
+
Revisit when any of these happens:
|
|
78
|
+
|
|
79
|
+
- the desktop/base composition mounts `dsh-lsp` + `dsh-lsp-stdio` +
|
|
80
|
+
`dsh-tool-lsp` (the tool becomes reachable);
|
|
81
|
+
- upstream proposes a preset-scoped subprocess provider or an fs/URI adapter
|
|
82
|
+
for providers;
|
|
83
|
+
- a user asks for semantic navigation in a WSL workspace and accepts the
|
|
84
|
+
dependency on one of the above.
|
|
85
|
+
|
|
86
|
+
Then, in order: a fake stdio language server running **inside the distro**
|
|
87
|
+
through the new subprocess provider (unit-level), a real
|
|
88
|
+
`typescript-language-server` probe on a Tier 1 image, and only then a docs
|
|
89
|
+
claim. Until that proof exists, the plugin's README and ROADMAP say nothing
|
|
90
|
+
about LSP support.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Upstream proposal: a session-visible notice seam for plugin advisories
|
|
2
|
+
|
|
3
|
+
Status: proposed upstream; **not filed yet** — this environment has no issue
|
|
4
|
+
channel, so the text below is ready for the maintainers to file against
|
|
5
|
+
`deepseek-ai/deepseek-harness` (version line `0.2.1-rc.x`; the gap was checked
|
|
6
|
+
against `dsh-v0.2.1-rc.2` on 2026-10-19). The exact filing payload (title, body,
|
|
7
|
+
command) is [proposal-session-notice.md](upstream/proposal-session-notice.md). The plugin ships the workaround
|
|
8
|
+
described at the end: the advisory goes to the log **and** into the session's
|
|
9
|
+
runtime context, which is model-visible and appears in the session transcript,
|
|
10
|
+
but is not a dedicated banner.
|
|
11
|
+
|
|
12
|
+
## The gap
|
|
13
|
+
|
|
14
|
+
A plugin that discovers an environment problem at session start has no way to
|
|
15
|
+
tell the person looking at the session. The available channels are:
|
|
16
|
+
|
|
17
|
+
- `ctx.logger.warn(...)` — the host log. A user who never opens the log never
|
|
18
|
+
sees it; `docs/LIMITATIONS.md` records this boundary.
|
|
19
|
+
- a runtime-context entry (`ctx.systemPrompt.context(...)`) — model-visible,
|
|
20
|
+
reaches the transcript on the next assembly, but is not a notification and
|
|
21
|
+
has no dismissal or severity semantics.
|
|
22
|
+
- `agent/error` — reserved for failures; an advisory that must never fail a
|
|
23
|
+
session cannot use it.
|
|
24
|
+
|
|
25
|
+
There is no `ctx.notices`/`ctx.ui`/toast service in `0.2.1-rc.2`, and no
|
|
26
|
+
client slot for one. The plugin's `wsl-preflight` therefore warns into the log
|
|
27
|
+
and the runtime context, and the full check is `pnpm run doctor`
|
|
28
|
+
([`scripts/doctor.mjs`](../scripts/doctor.mjs)).
|
|
29
|
+
|
|
30
|
+
## What an advisory needs
|
|
31
|
+
|
|
32
|
+
`wsl-preflight` runs when a distro session opens. Its findings are advisory:
|
|
33
|
+
a missing `bubblewrap`, a distro without systemd, a missing `systemd-run`, or a
|
|
34
|
+
user manager without Linger. None of them may fail the session — the first
|
|
35
|
+
command fails closed on its own if it must — and none may install anything.
|
|
36
|
+
What the author needs is exactly what the rule says:
|
|
37
|
+
|
|
38
|
+
- **visible in the session** before the first failed command, not only in the
|
|
39
|
+
log;
|
|
40
|
+
- **associated with a session**, so the notice appears in the session that
|
|
41
|
+
triggered the probe (two sessions on two distros can see different notices);
|
|
42
|
+
- **severity and a remedy**, so the UI can render a warning with a
|
|
43
|
+
copy-pasteable fix rather than a bare string;
|
|
44
|
+
- **disposable**, since a probe re-runs and a fixed environment must not keep
|
|
45
|
+
the old warning forever;
|
|
46
|
+
- **non-fatal**: no turn is cancelled, no tool result is changed.
|
|
47
|
+
|
|
48
|
+
## Proposed shape
|
|
49
|
+
|
|
50
|
+
A small service plus a client slot, mirroring the approval service's
|
|
51
|
+
session-scoped contract:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
/** One session-scoped advisory. */
|
|
55
|
+
interface SessionNotice {
|
|
56
|
+
/** Stable producer-owned id; re-notifying replaces by id. */
|
|
57
|
+
readonly id: string
|
|
58
|
+
/** `info` or `warning`; errors stay on `agent/error`. */
|
|
59
|
+
readonly level: 'info' | 'warning'
|
|
60
|
+
/** One-line text and an optional copy-paste remedy. */
|
|
61
|
+
readonly text: string
|
|
62
|
+
readonly remedy?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface NoticeService {
|
|
66
|
+
/**
|
|
67
|
+
* Show or replace one notice for a session. Rejects when the session is
|
|
68
|
+
* not open; the producer owns the id and can dismiss it with `clear`.
|
|
69
|
+
*/
|
|
70
|
+
notify(session: Session, notice: SessionNotice): Promise<void>
|
|
71
|
+
/** Remove a notice the producer published earlier. */
|
|
72
|
+
clear(session: Session, id: string): Promise<void>
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The client renders notices in the session header or above the composer with a
|
|
77
|
+
dismiss control; the host keeps the last value per `(session, id)` so a
|
|
78
|
+
reconnect and a replay agree. Nothing is model-visible unless the producer
|
|
79
|
+
also adds a runtime-context entry, which keeps this seam purely presentational.
|
|
80
|
+
|
|
81
|
+
## What this plugin would do with it
|
|
82
|
+
|
|
83
|
+
`wsl-preflight` would publish one `SessionNotice` per verdict (`id:
|
|
84
|
+
"wsl-preflight"`, `level: "warning"`, `text` = the one-line finding, `remedy` =
|
|
85
|
+
the existing copy-paste command or `/etc/wsl.conf` change), replace it on the
|
|
86
|
+
next verdict, and `clear` it when the probe passes. The runtime-context entry
|
|
87
|
+
would stay as the model-facing half; the notice would be the human-facing one.
|
|
88
|
+
|
|
89
|
+
## Workaround until it lands
|
|
90
|
+
|
|
91
|
+
- `ctx.logger.warn` carries the full remedy.
|
|
92
|
+
- A session-scoped runtime-context entry (`wsl-preflight`) repeats it in the
|
|
93
|
+
transcript, so it is visible to the model and in the context snapshot.
|
|
94
|
+
- `pnpm run doctor` is the deterministic, human-facing check; its `--json`
|
|
95
|
+
output is the issue-template ask.
|
package/lib/agent-errors.d.ts
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module dsh-plugin-wsl/agent-errors
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* The abort reason the execution layer uses when its timer fires. The timeout
|
|
15
|
+
* is enforced in the distro, so the agent layer must not add the out-of-band
|
|
16
|
+
* USR1 on this path: that signal has no request id and would hit whoever runs
|
|
17
|
+
* next.
|
|
18
|
+
*/
|
|
19
|
+
export declare const BASH_TIMEOUT_REASON = "BASH_TIMEOUT";
|
|
13
20
|
export declare class AgentUnavailableError extends Error {
|
|
14
21
|
/**
|
|
15
22
|
* @param message - why the agent is out, for the log.
|
|
@@ -29,3 +36,15 @@ export declare class CwdError extends Error {
|
|
|
29
36
|
*/
|
|
30
37
|
constructor(linuxPath: string);
|
|
31
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The agent could not spawn the request's process at all (reason `spawn`).
|
|
41
|
+
* The native agent reports a missing workdir this way, without naming it; the
|
|
42
|
+
* executor retries such a request on the one-shot path, which can tell a
|
|
43
|
+
* missing directory from a missing executable and report the path.
|
|
44
|
+
*/
|
|
45
|
+
export declare class SpawnError extends Error {
|
|
46
|
+
/**
|
|
47
|
+
* @param message - the agent's spawn diagnostic.
|
|
48
|
+
*/
|
|
49
|
+
constructor(message: string);
|
|
50
|
+
}
|
package/lib/agent-errors.js
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module dsh-plugin-wsl/agent-errors
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* The abort reason the execution layer uses when its timer fires. The timeout
|
|
15
|
+
* is enforced in the distro, so the agent layer must not add the out-of-band
|
|
16
|
+
* USR1 on this path: that signal has no request id and would hit whoever runs
|
|
17
|
+
* next.
|
|
18
|
+
*/
|
|
19
|
+
export const BASH_TIMEOUT_REASON = "BASH_TIMEOUT";
|
|
13
20
|
export class AgentUnavailableError extends Error {
|
|
14
21
|
/**
|
|
15
22
|
* @param message - why the agent is out, for the log.
|
|
@@ -34,3 +41,18 @@ export class CwdError extends Error {
|
|
|
34
41
|
this.linuxPath = linuxPath;
|
|
35
42
|
}
|
|
36
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* The agent could not spawn the request's process at all (reason `spawn`).
|
|
46
|
+
* The native agent reports a missing workdir this way, without naming it; the
|
|
47
|
+
* executor retries such a request on the one-shot path, which can tell a
|
|
48
|
+
* missing directory from a missing executable and report the path.
|
|
49
|
+
*/
|
|
50
|
+
export class SpawnError extends Error {
|
|
51
|
+
/**
|
|
52
|
+
* @param message - the agent's spawn diagnostic.
|
|
53
|
+
*/
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "SpawnError";
|
|
57
|
+
}
|
|
58
|
+
}
|
package/lib/agent-exec.d.ts
CHANGED
|
@@ -129,10 +129,10 @@ export interface AgentExecutionHandle {
|
|
|
129
129
|
* @returns the execution handle (`status`, `exitCode`, `signal`, `observed`,
|
|
130
130
|
* `done`, `readOutput`, `kill`, `result`).
|
|
131
131
|
*/
|
|
132
|
-
export declare function agentExecutionHandle({ agent, cwd, argv, timeoutMs, maxOutputBytes, signal, onStarted }: {
|
|
132
|
+
export declare function agentExecutionHandle({ agent, cwd, argv, timeoutMs, maxOutputBytes, env, signal, onStarted }: {
|
|
133
133
|
/**
|
|
134
134
|
* The {@link WslAgent}-like runner: `exec` accepting
|
|
135
|
-
* `{cwd, argv, timeoutMs, signal}` and resolving `{exitCode, stdout, stderr}`;
|
|
135
|
+
* `{cwd, argv, timeoutMs, env, signal}` and resolving `{exitCode, stdout, stderr}`;
|
|
136
136
|
* throws `AgentUnavailableError` when out.
|
|
137
137
|
*/
|
|
138
138
|
agent: AgentExecRunner;
|
|
@@ -150,6 +150,13 @@ export declare function agentExecutionHandle({ agent, cwd, argv, timeoutMs, maxO
|
|
|
150
150
|
* stream as `truncated: true`.
|
|
151
151
|
*/
|
|
152
152
|
maxOutputBytes?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Environment for this command: the caller's entries with the managed
|
|
155
|
+
* `DSH_*` snapshot merged last. The agent applies it as `SETENV` frames
|
|
156
|
+
* immediately before the command's `EXEC` (see `WslAgent.exec`); dropping it
|
|
157
|
+
* here is what kept every managed fact out of the resident-agent path.
|
|
158
|
+
*/
|
|
159
|
+
env?: Record<string, string>;
|
|
153
160
|
/** The caller's cancellation. */
|
|
154
161
|
signal?: AbortSignal;
|
|
155
162
|
/**
|
package/lib/agent-exec.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*
|
|
29
29
|
* @module dsh-plugin-wsl/agent-exec
|
|
30
30
|
*/
|
|
31
|
-
import { CwdError } from "./agent-errors.js";
|
|
31
|
+
import { BASH_TIMEOUT_REASON, CwdError } from "./agent-errors.js";
|
|
32
32
|
/**
|
|
33
33
|
* A memory-backed output reader in the seam's shape.
|
|
34
34
|
*
|
|
@@ -54,7 +54,7 @@ function memoryReader(buffer) {
|
|
|
54
54
|
* @returns the execution handle (`status`, `exitCode`, `signal`, `observed`,
|
|
55
55
|
* `done`, `readOutput`, `kill`, `result`).
|
|
56
56
|
*/
|
|
57
|
-
export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, maxOutputBytes = 0, signal, onStarted }) {
|
|
57
|
+
export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, maxOutputBytes = 0, env, signal, onStarted }) {
|
|
58
58
|
// Our own controller is the kill channel; the caller's signal races it.
|
|
59
59
|
const controller = new AbortController();
|
|
60
60
|
let timedOut = false;
|
|
@@ -69,10 +69,10 @@ export function agentExecutionHandle({ agent, cwd, argv, timeoutMs = 0, maxOutpu
|
|
|
69
69
|
if (timeoutMs > 0) {
|
|
70
70
|
setTimeout(() => {
|
|
71
71
|
timedOut = true;
|
|
72
|
-
controller.abort(new Error(
|
|
72
|
+
controller.abort(new Error(BASH_TIMEOUT_REASON));
|
|
73
73
|
}, timeoutMs).unref?.();
|
|
74
74
|
}
|
|
75
|
-
const settle = agent.exec({ cwd, argv, timeoutMs, maxOutputBytes, signal: controller.signal }).then((result) => ({ ok: true, result }), (error) => {
|
|
75
|
+
const settle = agent.exec({ cwd, argv, timeoutMs, maxOutputBytes, env, signal: controller.signal }).then((result) => ({ ok: true, result }), (error) => {
|
|
76
76
|
// A workdir the distro cannot enter is the command's own failure, not
|
|
77
77
|
// the agent's: synthesize the relay-shaped stderr the one-shot path
|
|
78
78
|
// produces, so the executor's `workdirFailure` check reports it the
|
package/lib/agent.d.ts
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
* command or a write that was in flight has UNKNOWN state — replaying it
|
|
11
11
|
* would run non-idempotent work twice or report a create that actually
|
|
12
12
|
* succeeded as a refusal — so it fails instead, and the caller re-reads
|
|
13
|
-
* before retrying. A
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* before retrying. A failure that spends the rebuild leaves the agent
|
|
14
|
+
* `dead` with a bounded revival: the next call after a 2/4/8-second backoff
|
|
15
|
+
* gets a fresh transport and its own single rebuild; after three deaths the
|
|
16
|
+
* state is permanent and every call rejects with a restart remedy. While
|
|
17
|
+
* dead, calls reject with {@link AgentUnavailableError} — the executor's cue
|
|
18
|
+
* to fall back to the one-shot `wsl.exe` path it shipped with, so the worst
|
|
19
|
+
* case of the agent is "today's behaviour, plus one failed round trip".
|
|
18
20
|
* 2. **The in-distro timeout is the real one.** The EXEC frame carries the
|
|
19
21
|
* timeout in whole seconds and the agent SIGTERMs the child itself, so a
|
|
20
22
|
* timed-out command dies where it lives instead of leaving an orphan
|
|
@@ -59,6 +61,8 @@ export interface ExecResult {
|
|
|
59
61
|
export interface PendingRequest {
|
|
60
62
|
/** The request id, as the agent echoes it. */
|
|
61
63
|
id: string;
|
|
64
|
+
/** The request's numeric sequence; newer requests have a larger one. */
|
|
65
|
+
seq: number;
|
|
62
66
|
/** Which metric bucket this request belongs to. */
|
|
63
67
|
kind: "fs" | "exec";
|
|
64
68
|
/** When the frame was written, for the queue measurement. */
|
|
@@ -87,6 +91,10 @@ export interface PendingRequest {
|
|
|
87
91
|
cancelled: boolean;
|
|
88
92
|
/** The caller was already settled; a late answer only cleans up. */
|
|
89
93
|
settled: boolean;
|
|
94
|
+
/** Whether the agent has answered (RES or ERR); a late signal is then unsafe. */
|
|
95
|
+
answered: boolean;
|
|
96
|
+
/** The delayed cancel signal, so it can be dropped when the request settles. */
|
|
97
|
+
signalTimer: NodeJS.Timeout | null;
|
|
90
98
|
}
|
|
91
99
|
/** Per-kind request metrics. */
|
|
92
100
|
export interface RequestMetrics {
|
|
@@ -107,6 +115,21 @@ export interface RequestMetrics {
|
|
|
107
115
|
/** Rejected before tracking: queue full, already aborted, or send failed. */
|
|
108
116
|
rejected: number;
|
|
109
117
|
}
|
|
118
|
+
/** A read-only snapshot of the resident's lifecycle, for diagnostics. */
|
|
119
|
+
export interface AgentStatus {
|
|
120
|
+
/** The distro served. */
|
|
121
|
+
distro: string;
|
|
122
|
+
/** Lifecycle state. */
|
|
123
|
+
state: "idle" | "starting" | "ready" | "dead";
|
|
124
|
+
/** The HELLO pid, when one is known and addressable. */
|
|
125
|
+
pid: number | undefined;
|
|
126
|
+
/** The last failure, if any. */
|
|
127
|
+
lastError: string;
|
|
128
|
+
/** Deaths observed; the revival budget is spent after {@link MAX_REVIVALS}. */
|
|
129
|
+
deaths: number;
|
|
130
|
+
/** Requests currently in flight. */
|
|
131
|
+
pending: number;
|
|
132
|
+
}
|
|
110
133
|
/** The agent's cumulative request metrics. */
|
|
111
134
|
export interface AgentMetrics {
|
|
112
135
|
/** FS frames. */
|
|
@@ -227,8 +250,16 @@ export declare class WslAgent {
|
|
|
227
250
|
nextRequestId: number;
|
|
228
251
|
/** The requests in flight, keyed by their current id. */
|
|
229
252
|
pending: Map<string, PendingRequest>;
|
|
253
|
+
/** The newest request the agent has ACKed; a cancel older than this is stale. */
|
|
254
|
+
lastAckedSeq: number;
|
|
230
255
|
/** Whether the one permitted rebuild has been spent. */
|
|
231
256
|
rebuildUsed: boolean;
|
|
257
|
+
/** Deaths observed; each one past the first consumes the revival budget. */
|
|
258
|
+
deaths: number;
|
|
259
|
+
/** When the next revival may start, epoch ms; 0 when not waiting. */
|
|
260
|
+
deadUntil: number;
|
|
261
|
+
/** The first revival delay; tests shorten it. */
|
|
262
|
+
revivalBackoffMs: number;
|
|
232
263
|
/** The idle-shutdown timer. */
|
|
233
264
|
idleTimer: NodeJS.Timeout | null;
|
|
234
265
|
/** The last failure, for `unavailableReason` and log messages. */
|
|
@@ -307,6 +338,8 @@ export declare class WslAgent {
|
|
|
307
338
|
maxPending?: number;
|
|
308
339
|
/** Optional async preparation selecting the entry before the first spawn. */
|
|
309
340
|
prepare?: () => Promise<WslAgentPrepared | undefined>;
|
|
341
|
+
/** The first revival delay in ms; doubles per death. Tests shorten it. */
|
|
342
|
+
revivalBackoffMs?: number;
|
|
310
343
|
});
|
|
311
344
|
/**
|
|
312
345
|
* Whether the agent is permanently out — the executor's fallback cue.
|
|
@@ -316,6 +349,22 @@ export declare class WslAgent {
|
|
|
316
349
|
get unavailable(): boolean;
|
|
317
350
|
/** Human-readable reason for the last transition to `dead`, for logging. */
|
|
318
351
|
get unavailableReason(): string;
|
|
352
|
+
/**
|
|
353
|
+
* Enter the dead state and schedule the next bounded revival attempt.
|
|
354
|
+
*
|
|
355
|
+
* The delay doubles per death (2 s, 4 s, 8 s, … capped at 30 s). After
|
|
356
|
+
* {@link MAX_REVIVALS} deaths the state stays permanent and the refusal
|
|
357
|
+
* carries the restart remedy.
|
|
358
|
+
*/
|
|
359
|
+
markDead(): void;
|
|
360
|
+
/**
|
|
361
|
+
* A read-only lifecycle snapshot for diagnostics. Includes the live pid, the
|
|
362
|
+
* last failure and the remaining revival budget; it never starts or stops
|
|
363
|
+
* anything.
|
|
364
|
+
*
|
|
365
|
+
* @returns the status snapshot.
|
|
366
|
+
*/
|
|
367
|
+
status(): AgentStatus;
|
|
319
368
|
/**
|
|
320
369
|
* Start the process and wait for the handshake. Re-entrant: concurrent
|
|
321
370
|
* callers share the one start.
|
|
@@ -526,6 +575,20 @@ export declare class WslAgent {
|
|
|
526
575
|
* @param pending - the entry to clear.
|
|
527
576
|
*/
|
|
528
577
|
private clearPending;
|
|
578
|
+
/**
|
|
579
|
+
* Deliver the out-of-band cancel signal after a short grace, and only while
|
|
580
|
+
* the request it was meant for is still the agent's current one.
|
|
581
|
+
*
|
|
582
|
+
* The signal has no request id: it interrupts whatever command the agent is
|
|
583
|
+
* blocked in. A timeout request is usually already being killed by the
|
|
584
|
+
* in-distro watcher, so an immediate signal can land after the agent moved
|
|
585
|
+
* on — the next request then dies with exit 138 (SIGUSR1) and looks
|
|
586
|
+
* `completed`. A newer ACK means the agent has answered the old request, so
|
|
587
|
+
* the signal is dropped; an answered request is dropped too.
|
|
588
|
+
*
|
|
589
|
+
* @param pending - the cancelled request.
|
|
590
|
+
*/
|
|
591
|
+
private armCancelSignal;
|
|
529
592
|
}
|
|
530
593
|
/**
|
|
531
594
|
* Rewrite the request id inside a stored frame so a resent request does not
|