fix: address ds-review-bot round — insert-aliasing clones, settlement gates, closure module fallback

- Clone patch lists per generation (boot + composeLive): the include pushes
  insert rows by reference and mutates them in place, so a reused object
  baked user overrides into bundle rows and removal could not revert; the
  built-bin hot-reload e2e now asserts an override AND its removal reverting.
- The headless runner awaits Loader settlement before prompting (its inject
  gate covers only apiProxy/httpServer) and abandons cleanly when the tree
  died during the wait.
- healProfilesModuleFallback walks the app's full dependency+peer closure:
  out-of-tree plugins import seam packages (dsh-compact, dsh-subprocess, ...)
  that only implementations reach, and peers are how seams are declared.
- Profile init writes pnpm-workspace.yaml (nodeLinker: hoisted), not .npmrc
  — pnpm >=10 reads settings from the workspace manifest.
- Web dumps reject boot-only flags instead of printing a tree that differs
  from the same invocation's boot; --port validates at the flag;
  --dump-default-config no longer parses the (possibly broken) user layer;
  trustedHosts flag derivation merges over the composed value instead of
  replacing it; web-runtime gains surfaceContext (headless disables the GUI
  prompt/bash-vars the old -p never mounted); 'node_modules' is a reserved
  profile name; plugin-warning names the recovery step; client AGENTS.md
  registration surfaces point at the web-app bundle.
- Ship session-reference/tmux-context/tool-ask-user as app dependencies for
  terminal front-door patch layers (turtle-ui), same stance as mcp-client.
This commit is contained in:
Turtle
2026-08-06 09:27:44 +08:00
parent 273f27260d
commit 925daf141b
20 changed files with 240 additions and 83 deletions

View File

@@ -1,6 +1,7 @@
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
# dsh-web-app. The web composition stays mounted (the session is observable
# in a browser while it runs); this layer silences the URL line, moves the
# in a browser while it runs); this layer silences the URL line and the
# GUI-orientation surface context (this user is not in the GUI), moves the
# webserver to an OS-assigned port so parallel headless runs never collide,
# and mounts the one-shot runner. The launcher patches the runner's `task`.
@@ -13,6 +14,7 @@
config:
mode: production
printUrl: false
surfaceContext: false
- insert:
- id: headless-runner

View File

@@ -41,6 +41,7 @@
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",

View File

@@ -17,6 +17,8 @@ import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apipro
// Empty type imports carry the httpServer and agent/status Context merges used below.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
// Empty type import carries the loader Context merge for the settlement await.
import type {} from '@cordisjs/plugin-loader'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -136,13 +138,20 @@ export function apply(ctx: Context, config: Config): void {
// Fire-and-forget by design: the run outlives plugin activation, and every
// failure path inside ends in io.exit, not a rejection.
void (async () => {
// The Loader mounts sibling rows concurrently and this plugin's inject
// gate covers only apiProxy/httpServer; prompting before the agent loop,
// adapters, and tools settle would fail the turn on a half-mounted tree.
// The old launcher ran strictly after settled boot — preserve that.
// A tree disposed mid-settlement (early SIGTERM) has nothing to run.
await ctx.get('loader')?.await()
if (ctx.get('httpServer') === undefined) return
// The headless session is web-observable while it runs (same composition).
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), io)
// Open the stream before prompting so no frame is lost — kept in this
// order even though in-process delivery has no race, so the code survives
// a move to a remote HTTP carrier unchanged.
// Open the stream before prompting so no frame is lost. The quiescence
// anchor below is an in-process ctx subscription, so a remote-carrier
// port of this runner must replace it with a wire-visible idle signal.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => {

View File

@@ -174,6 +174,36 @@ describe('headless runner', () => {
await ctx.fiber.dispose()
})
it('waits for Loader settlement and abandons the run when the tree died during it', async () => {
const ctx = new Context()
let err = ''
let exited = false
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: () => { exited = true },
} satisfies HeadlessIo)
ctx.provide('apiProxy', scriptedApi([]) as never)
// The webserver is provided by a child fiber whose disposal (early
// SIGTERM during the boot window) removes the service; settlement
// resolves only afterwards, and the runner must abandon rather than
// crash on the torn-down port read.
const webserverFiber = ctx.plugin((childCtx: Context) => {
childCtx.provide('httpServer', { port: 1 } as never)
})
await webserverFiber
let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
ctx.provide('loader', { await: () => settlement } as never)
apply(ctx, { task: 't' })
await webserverFiber.dispose()
release!()
await new Promise(resolve => setTimeout(resolve, 10))
expect(err).toBe('')
expect(exited).toBe(false)
await ctx.fiber.dispose()
})
it('fails loud without the launcher-owned headlessIo seam', () => {
const ctx = new Context()
ctx.provide('apiProxy', scriptedApi([]) as never)

View File

@@ -11,6 +11,9 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/schemastery"
},