@aiwayds/dsh-opentui 0.4.2 → 0.5.0
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 +67 -0
- package/LICENSE +202 -0
- package/NOTICE +63 -0
- package/README.md +132 -0
- package/bin/dsh-opentui.mjs +0 -0
- package/dist-host/index.js +1792 -289
- package/package.json +8 -2
- package/scripts/e2e-client.mjs +1042 -0
- package/scripts/e2e-live.mjs +27 -4
- package/scripts/idle-reaper.mjs +80 -0
- package/scripts/install-profile.mjs +13 -22
- package/scripts/link-dsh-closure.mjs +18 -10
- package/scripts/pack-meta.mjs +17 -0
- package/scripts/provision-opentui-profile.mjs +212 -0
- package/scripts/solid-js-pin.mjs +286 -0
- package/scripts/sync-profile.mjs +62 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Client-mode two-process E2E gate (P6) — drives the REAL topology end to
|
|
3
|
+
// end: `./dsh-opentui --client` inside tmux 120x34 panes, the resident
|
|
4
|
+
// `dsh --profile opentui` host it spawns, and real model turns.
|
|
5
|
+
//
|
|
6
|
+
// Scenarios:
|
|
7
|
+
// a. Boot + turn — status bar renders (provider/model from the profile
|
|
8
|
+
// route), `say ok` answers, the 🧠 context segment increases.
|
|
9
|
+
// b. Multi-TUI attach — a second client in a second tmux session JOINS
|
|
10
|
+
// the same host (same pid via the single-instance lockfile; exactly
|
|
11
|
+
// one host process); both quit → the host exits after the idle grace
|
|
12
|
+
// (the detached idle-reaper path).
|
|
13
|
+
// c. Crash-recovery adoption — kill -9 client 1's bun process → the host
|
|
14
|
+
// survives → client 2 boots and ADOPTS the existing host (lockfile
|
|
15
|
+
// pid unchanged, no respawn) → clean quit → full teardown.
|
|
16
|
+
// d. /reload — from a running client, the slash command drives the
|
|
17
|
+
// RemoteHostSession.restartSamePortHost restart path → the host
|
|
18
|
+
// restarts on the SAME port → the carrier reconnects and the wave-2
|
|
19
|
+
// re-attach resumes → transcript rows survive (no double-painted
|
|
20
|
+
// rows).
|
|
21
|
+
// e. Host death recovery — kill -9 the HOST while a client is connected
|
|
22
|
+
// mid-turn → the client surfaces the "reconnecting…" state → the
|
|
23
|
+
// next ensureHost (a fresh client boot) respawns → that client
|
|
24
|
+
// continues.
|
|
25
|
+
// f. THE ORIGINAL SCENARIO (acceptance) — `用一个markdown表格列出两种语言
|
|
26
|
+
// 和创建年份,再写一句总结` → a real bordered table (│ rows + └─
|
|
27
|
+
// bottom) + a summary line + the bottom anchor (powerline footer
|
|
28
|
+
// still painted). Capture → /tmp/p6-mdtable.txt, PNG →
|
|
29
|
+
// /tmp/p6-mdtable.png via /tmp/tmux2png.py (pyte + Pillow).
|
|
30
|
+
// g. Workspace config effect — a scratch workspace with
|
|
31
|
+
// ./.dsh/config.yml (theme: dracula + model preference) → the boot
|
|
32
|
+
// in that cwd applies the theme (capture -e truecolor SGRs: the
|
|
33
|
+
// mocha user-row color gone, the dracula accent present) and the
|
|
34
|
+
// session carries the workspace cwd (welcome banner path).
|
|
35
|
+
//
|
|
36
|
+
// Host-death UX (scenario e — the chosen design, ADR-0005 v1):
|
|
37
|
+
// A host killed mid-session leaves the carrier retrying a dead port.
|
|
38
|
+
// The client surfaces this as the "reconnecting…" label swap (the
|
|
39
|
+
// wave-1 marker) for as long as the turn is awaited. v1 does NOT
|
|
40
|
+
// respawn into the SAME client process: the carrier is pinned to the
|
|
41
|
+
// authority it was built with (the P3 ShellHostDeps swap is the seam
|
|
42
|
+
// that could re-point it), and after a crash the lifecycle lockfile is
|
|
43
|
+
// already cleared by the client's own death watcher, so the /reload
|
|
44
|
+
// same-port machinery has nothing to restart. Recovery is the
|
|
45
|
+
// terminal-standard quit-and-relaunch: the NEXT client's ensureHost
|
|
46
|
+
// finds no/stale lock and respawns a fresh host; the session store is
|
|
47
|
+
// host-global, so the relaunched TUI continues with the full
|
|
48
|
+
// transcript. This scenario asserts exactly that chain.
|
|
49
|
+
//
|
|
50
|
+
// Model-route reality (provider keys rot): every real-turn scenario uses
|
|
51
|
+
// the wave-3 workingRoute discovery pattern first — boot the host, probe
|
|
52
|
+
// `session/modelCatalog`, `selectModel` and turn until a route actually
|
|
53
|
+
// answers. The winning route is exported to the TUI clients through the
|
|
54
|
+
// documented env override (DSH_OPENTUI_MODEL=provider/model, the
|
|
55
|
+
// bucket-(d) merge's top layer). When NO route answers, the gate still
|
|
56
|
+
// runs every non-turn assertion and marks the turn parts BLOCKED loudly
|
|
57
|
+
// instead of hacking.
|
|
58
|
+
//
|
|
59
|
+
// Skips (exit 0) when the environment cannot run it: no global dsh, no
|
|
60
|
+
// tmux, no bun, or the opentui profile missing — CI runs skip; a
|
|
61
|
+
// configured dev machine runs the full gate for real.
|
|
62
|
+
//
|
|
63
|
+
// Drive mechanics (e2e-live.mjs conventions): typing is fed ONE CHAR PER
|
|
64
|
+
// tmux send-keys with a ~60ms inter-key gap (burst writes drop keys in
|
|
65
|
+
// OpenTUI's input parser); assertions are presence-over-time on captures.
|
|
66
|
+
//
|
|
67
|
+
// Known P1 watches (oldfox review — if seen, record as
|
|
68
|
+
// "reproduced oldfox-P1#N" and move on; fixes are tracked separately):
|
|
69
|
+
// P1#1 transcript rows double-painted after a reconnect replay (no seq
|
|
70
|
+
// dedupe baseline) — watched in scenario d;
|
|
71
|
+
// P1#2 /reload restart does not rewrite the lockfile → unlocked host
|
|
72
|
+
// (next ensureHost would boot a duplicate) — watched in scenario d;
|
|
73
|
+
// P1#3 concurrent dual-TUI cold start spawns two hosts — watched in
|
|
74
|
+
// scenarios b/c (sequential boots here make it unlikely; honest
|
|
75
|
+
// negative is information too);
|
|
76
|
+
// P1#4 events-source closing a CONNECTING-state socket → unhandled
|
|
77
|
+
// error — watched in scenario e (client process must survive).
|
|
78
|
+
|
|
79
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
80
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
81
|
+
import { homedir, tmpdir } from 'node:os'
|
|
82
|
+
import { dirname, join, resolve } from 'node:path'
|
|
83
|
+
import { fileURLToPath } from 'node:url'
|
|
84
|
+
import { connect as netConnect } from 'node:net'
|
|
85
|
+
|
|
86
|
+
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
|
87
|
+
const LAUNCHER = join(REPO, 'dsh-opentui')
|
|
88
|
+
const PROFILE = 'opentui'
|
|
89
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
90
|
+
const LOCK_PATH = join(DSH_HOME, `opentui-host.${PROFILE}.json`)
|
|
91
|
+
const GEOMETRY = { x: 120, y: 34 }
|
|
92
|
+
const TYPE_GAP_MS = 60
|
|
93
|
+
|
|
94
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Preflight + skip contract.
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const preflight = () => {
|
|
101
|
+
const need = (cmd) => spawnSync('which', [cmd], { encoding: 'utf8' })
|
|
102
|
+
for (const [cmd, why] of [['dsh', 'no global dsh on PATH'], ['tmux', 'no tmux on PATH'], ['bun', 'no bun on PATH']]) {
|
|
103
|
+
const w = need(cmd)
|
|
104
|
+
if (w.status !== 0 || !w.stdout.trim()) return `${why}`
|
|
105
|
+
}
|
|
106
|
+
if (!existsSync(join(DSH_HOME, 'profiles', PROFILE))) {
|
|
107
|
+
return `profile '${PROFILE}' not provisioned under ${DSH_HOME}`
|
|
108
|
+
}
|
|
109
|
+
if (!existsSync(LAUNCHER)) return `launcher missing: ${LAUNCHER}`
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SKIP = preflight()
|
|
114
|
+
if (SKIP) {
|
|
115
|
+
console.log(`e2e-client: skipped (${SKIP})`)
|
|
116
|
+
process.exit(0)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Small system helpers (no `timeout` on macOS — all waiting is JS timers).
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const sh = (cmd, args, opts = {}) => spawnSync(cmd, args, { encoding: 'utf8', ...opts })
|
|
124
|
+
|
|
125
|
+
const tmux = (...args) => sh('tmux', args)
|
|
126
|
+
|
|
127
|
+
const tmuxOut = (...args) => {
|
|
128
|
+
const r = tmux(...args)
|
|
129
|
+
return r.status === 0 ? r.stdout : ''
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const pidAlive = (pid) => {
|
|
133
|
+
try {
|
|
134
|
+
process.kill(pid, 0)
|
|
135
|
+
return true
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return err.code === 'EPERM'
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isListening = (port) =>
|
|
142
|
+
new Promise((resolveListen) => {
|
|
143
|
+
const socket = netConnect({ host: '127.0.0.1', port })
|
|
144
|
+
let settled = false
|
|
145
|
+
const done = (v) => {
|
|
146
|
+
if (settled) return
|
|
147
|
+
settled = true
|
|
148
|
+
socket.destroy()
|
|
149
|
+
resolveListen(v)
|
|
150
|
+
}
|
|
151
|
+
socket.setTimeout(750)
|
|
152
|
+
socket.once('connect', () => done(true))
|
|
153
|
+
socket.once('error', () => done(false))
|
|
154
|
+
socket.once('timeout', () => done(false))
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/** Host wrapper process pids: the lifecycle spawns
|
|
158
|
+
* `dsh --profile opentui --no-open --port N` (a node wrapper, detached). */
|
|
159
|
+
const hostPids = () => {
|
|
160
|
+
const r = sh('pgrep', ['-f', `dsh --profile ${PROFILE} --no-open`])
|
|
161
|
+
return r.status === 0 ? r.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const readLock = () => {
|
|
165
|
+
try {
|
|
166
|
+
const raw = JSON.parse(readFileSync(LOCK_PATH, 'utf8'))
|
|
167
|
+
if (typeof raw.pid === 'number' && typeof raw.port === 'number') return raw
|
|
168
|
+
} catch { /* missing/corrupt */ }
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const waitPidGone = async (pid, budgetMs) => {
|
|
173
|
+
const deadline = Date.now() + budgetMs
|
|
174
|
+
while (pidAlive(pid)) {
|
|
175
|
+
if (Date.now() > deadline) return false
|
|
176
|
+
await sleep(250)
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** SIGTERM the host's whole process group (the detached wrapper is a group
|
|
182
|
+
* leader; the server may be a child), escalating to SIGKILL. */
|
|
183
|
+
const killHostTree = async (pid) => {
|
|
184
|
+
const signal = (target, sig) => {
|
|
185
|
+
try { process.kill(-target, sig) } catch { try { process.kill(target, sig) } catch { /* gone */ } }
|
|
186
|
+
}
|
|
187
|
+
signal(pid, 'SIGTERM')
|
|
188
|
+
if (!(await waitPidGone(pid, 5000))) {
|
|
189
|
+
signal(pid, 'SIGKILL')
|
|
190
|
+
await waitPidGone(pid, 3000)
|
|
191
|
+
}
|
|
192
|
+
return !pidAlive(pid)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Port sweep: SIGTERM whatever still listens (group first), for the
|
|
196
|
+
* unlocked /reload-restarted host (oldfox-P1#2 consequence). */
|
|
197
|
+
const sweepPort = async (port) => {
|
|
198
|
+
const r = sh('lsof', ['-ti', `tcp:${port}`])
|
|
199
|
+
const pids = r.status === 0 ? r.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
|
|
200
|
+
for (const pid of pids) await killHostTree(pid)
|
|
201
|
+
return pids
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Full teardown of one host: kill by pid, sweep its port, clear the
|
|
205
|
+
* lockfile only while it still describes that pid. */
|
|
206
|
+
const reapHost = async (host) => {
|
|
207
|
+
if (!host) return
|
|
208
|
+
await killHostTree(host.pid)
|
|
209
|
+
await sweepPort(host.port)
|
|
210
|
+
const lock = readLock()
|
|
211
|
+
if (lock && lock.pid === host.pid) rmSync(LOCK_PATH, { force: true })
|
|
212
|
+
// settle: let dying sockets drain before the next scenario probes
|
|
213
|
+
await sleep(1500)
|
|
214
|
+
if (await isListening(host.port)) await sweepPort(host.port)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// tmux client drive (120x34, slow typing — e2e-live mechanics).
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/** Hygienic env for the client (and the host it spawns): the REAL dsh home
|
|
222
|
+
* (no DSH_HOME override), no repo .bin shadowing, no npm_config_* leaks. */
|
|
223
|
+
const baseClientEnv = () => {
|
|
224
|
+
const env = { ...process.env }
|
|
225
|
+
delete env.DSH_HOME
|
|
226
|
+
delete env.DSH_OPENTUI_THEME
|
|
227
|
+
env.PATH = env.PATH.split(':').filter((s) => s && !s.includes('dsh-opentui/node_modules/.bin')).join(':')
|
|
228
|
+
for (const k of Object.keys(env)) if (k.startsWith('npm_config_')) delete env[k]
|
|
229
|
+
return env
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sessionsCreated = new Set()
|
|
233
|
+
let ownedTmuxServer = false
|
|
234
|
+
|
|
235
|
+
const tmuxServerWasRunning = () => tmux('list-sessions').status === 0
|
|
236
|
+
|
|
237
|
+
const newClientSession = (name, workspace, extraEnv = {}) => {
|
|
238
|
+
const envParts = Object.entries(extraEnv)
|
|
239
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
240
|
+
.join(' ')
|
|
241
|
+
const cmd = `${envParts} ${LAUNCHER} --client`.trim()
|
|
242
|
+
const r = tmux('new-session', '-d', '-x', String(GEOMETRY.x), '-y', String(GEOMETRY.y), '-s', name, '-c', workspace, cmd)
|
|
243
|
+
if (r.status !== 0) throw new Error(`tmux new-session ${name} failed: ${r.stderr.trim()}`)
|
|
244
|
+
sessionsCreated.add(name)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const paneAlive = (name) => tmux('has-session', '-t', name).status === 0
|
|
248
|
+
|
|
249
|
+
const capture = (name, { esc = false, full = false } = {}) => {
|
|
250
|
+
const args = ['capture-pane', '-p', '-t', name]
|
|
251
|
+
if (esc) args.push('-e')
|
|
252
|
+
if (full) args.push('-S', '-2000')
|
|
253
|
+
return tmuxOut(...args)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const sendKeys = (name, ...keys) => tmux('send-keys', '-t', name, ...keys)
|
|
257
|
+
|
|
258
|
+
/** One char per send-keys with a real inter-key gap (parser drops bursts). */
|
|
259
|
+
const typeText = async (name, text) => {
|
|
260
|
+
for (const ch of text) {
|
|
261
|
+
sendKeys(name, '-l', ch)
|
|
262
|
+
await sleep(TYPE_GAP_MS)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const submitLine = async (name, text) => {
|
|
267
|
+
await typeText(name, text)
|
|
268
|
+
await sleep(300)
|
|
269
|
+
sendKeys(name, 'Enter')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const waitPane = async (name, predicate, timeoutMs, everyMs = 500) => {
|
|
273
|
+
const deadline = Date.now() + timeoutMs
|
|
274
|
+
let last = ''
|
|
275
|
+
while (Date.now() < deadline) {
|
|
276
|
+
last = capture(name)
|
|
277
|
+
if (predicate(last)) return last
|
|
278
|
+
await sleep(everyMs)
|
|
279
|
+
}
|
|
280
|
+
return last // caller decides whether the final capture satisfies it
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const waitPaneDead = async (name, timeoutMs) => {
|
|
284
|
+
const deadline = Date.now() + timeoutMs
|
|
285
|
+
while (Date.now() < deadline) {
|
|
286
|
+
if (!paneAlive(name)) return true
|
|
287
|
+
await sleep(400)
|
|
288
|
+
}
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Wait for the boot to reach the rendered powerline (🧠 segment), then for
|
|
293
|
+
* the cold-boot route label (the generic "dsh-host" swaps within the boot
|
|
294
|
+
* label budget; a still-generic label after that is an assertion failure,
|
|
295
|
+
* not a wait failure). */
|
|
296
|
+
const waitRendered = async (name) => {
|
|
297
|
+
const last = await waitPane(name, (out) => out.includes('🧠') && /dsh\(/.test(out), 45_000)
|
|
298
|
+
if (!last.includes('🧠')) {
|
|
299
|
+
throw new Error(`client ${name} never rendered a powerline within 45s — last capture:\n${last.slice(-1500)}`)
|
|
300
|
+
}
|
|
301
|
+
if (last.includes('client boot failed')) {
|
|
302
|
+
throw new Error(`client ${name} boot failed:\n${last.slice(0, 1500)}`)
|
|
303
|
+
}
|
|
304
|
+
const labeled = await waitPane(name, (out) => {
|
|
305
|
+
const r = routeOf(powerlineOf(out))
|
|
306
|
+
return r !== null && r.provider !== 'dsh-host' && r.model !== '·'
|
|
307
|
+
}, 10_000)
|
|
308
|
+
return labeled
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const quitClient = async (name) => {
|
|
312
|
+
if (!paneAlive(name)) return 'already gone'
|
|
313
|
+
await submitLine(name, '/quit')
|
|
314
|
+
if (await waitPaneDead(name, 15_000)) return 'quit'
|
|
315
|
+
tmux('kill-session', '-t', name)
|
|
316
|
+
return 'forced (kill-session)'
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Turn helpers.
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/** The idle editor row shows the placeholder; while a turn runs it is
|
|
324
|
+
* replaced by the busy label — locale-proof busy detection. */
|
|
325
|
+
const IDLE_MARK = '提问,回车发送'
|
|
326
|
+
const isIdle = (out) => out.includes(IDLE_MARK)
|
|
327
|
+
|
|
328
|
+
/** The powerline footer is the 🧠 line that also carries the dsh( segment
|
|
329
|
+
* (the busy status row contains 🧠 too, but never dsh(). */
|
|
330
|
+
const powerlineOf = (out) => out.split('\n').find((l) => l.includes('🧠') && l.includes('dsh(')) ?? ''
|
|
331
|
+
|
|
332
|
+
const contextOf = (line) => {
|
|
333
|
+
const m = /🧠\s*([\d.]+)(k?)\((\d+)%\)/.exec(line)
|
|
334
|
+
if (!m) return null
|
|
335
|
+
return Number(m[1]) * (m[2] === 'k' ? 1000 : 1)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const routeOf = (line) => {
|
|
339
|
+
// segments are separated by the powerline glyph U+E0B0 (private-use area)
|
|
340
|
+
// — normalize every private-use char to a space before parsing
|
|
341
|
+
const clean = line.replace(/[\u{E000}-\u{F8FF}]/gu, ' ').replace(/\s+/g, ' ')
|
|
342
|
+
const m = /dsh\(\w+\) (\S+) 🤖 (\S+) ◕(\S*)/.exec(clean)
|
|
343
|
+
return m ? { provider: m[1], model: m[2], effort: m[3] } : null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Send a turn and wait for it to settle (editor leaves idle → returns to
|
|
347
|
+
* idle). Returns the post-turn capture. */
|
|
348
|
+
const runTurn = async (name, text, settleMs = 90_000) => {
|
|
349
|
+
const before = capture(name)
|
|
350
|
+
await submitLine(name, text)
|
|
351
|
+
const busySeen = await waitPane(name, (out) => !isIdle(out) || /api key|auth/i.test(out), 20_000)
|
|
352
|
+
// a fast auth failure may never paint the busy row — both are settles
|
|
353
|
+
if (isIdle(busySeen) && !/api key|auth/i.test(busySeen)) {
|
|
354
|
+
throw new Error(`turn never started (editor stayed idle, no error) — capture:\n${busySeen.slice(-1200)}`)
|
|
355
|
+
}
|
|
356
|
+
const settled = await waitPane(name, (out) => isIdle(out), settleMs)
|
|
357
|
+
return { after: settled, before }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// WorkingRoute discovery (wave-3 pattern) — probe the catalog, selectModel
|
|
362
|
+
// a route that actually answers. Runs BEFORE the scenarios; the winning
|
|
363
|
+
// route is exported to every TUI client via DSH_OPENTUI_MODEL (bucket-(d)
|
|
364
|
+
// env layer), so the TUI turns ride a live route even with a rotted
|
|
365
|
+
// deployment default.
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
const probeWorkingRoute = async () => {
|
|
369
|
+
const probeSessWs = mkdtempSync(join(tmpdir(), 'p6-probe-sess-'))
|
|
370
|
+
// The probe must live inside packages/shell so its imports of the repo's
|
|
371
|
+
// TS modules (and the dsh-client-connection closure link) resolve.
|
|
372
|
+
const probePath = join(REPO, 'packages', 'shell', `.p6-route-probe-${process.pid}.ts`)
|
|
373
|
+
writeFileSync(
|
|
374
|
+
probePath,
|
|
375
|
+
`// generated by scripts/e2e-client.mjs — deleted after the run
|
|
376
|
+
import { ensureHost } from './src/client/lifecycle.ts'
|
|
377
|
+
import { ClientConnection } from './src/client/transport.ts'
|
|
378
|
+
import { RemoteHostSession } from './src/client/remote-host-session.ts'
|
|
379
|
+
|
|
380
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
381
|
+
const turnOnce = (session, text) => new Promise((res) => {
|
|
382
|
+
let done = false
|
|
383
|
+
const finish = (err) => { if (!done) { done = true; res(err) } }
|
|
384
|
+
session.turn(text, {
|
|
385
|
+
onThinkingDelta: () => {},
|
|
386
|
+
onContentDelta: () => {},
|
|
387
|
+
onTurnEnd: () => finish(undefined),
|
|
388
|
+
onApprove: async () => false,
|
|
389
|
+
onToolRun: () => {},
|
|
390
|
+
onError: (m) => finish(m),
|
|
391
|
+
}).catch((e) => finish(e instanceof Error ? e.message : String(e)))
|
|
392
|
+
const deadline = Date.now() + 90_000
|
|
393
|
+
const poll = setInterval(() => {
|
|
394
|
+
if (done || Date.now() > deadline) { clearInterval(poll); finish('turn deadline exceeded') }
|
|
395
|
+
}, 200)
|
|
396
|
+
poll.unref?.()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const ws = process.env.P6_PROBE_WS
|
|
400
|
+
const host = await ensureHost({ profile: '${PROFILE}', idleExitSec: 0 })
|
|
401
|
+
const out = { hostPid: host.pid, hostPort: host.port, adopted: host.adopted, ok: false, adoptedDefault: false, route: null, otherRoute: null, errors: [] }
|
|
402
|
+
const live = new ClientConnection({ authority: host.authority, cookieHeader: host.cookieHeader })
|
|
403
|
+
const session = new RemoteHostSession({ connection: live, cookieHeader: host.cookieHeader, cwd: ws })
|
|
404
|
+
await session.start()
|
|
405
|
+
let err = await turnOnce(session, 'say ok')
|
|
406
|
+
if (err === undefined) {
|
|
407
|
+
out.ok = true
|
|
408
|
+
out.adoptedDefault = true
|
|
409
|
+
// resolve WHICH route answered: the live session's projection (fallback:
|
|
410
|
+
// the catalog default) — the scenarios need the concrete provider/model
|
|
411
|
+
try {
|
|
412
|
+
const sel = await session.currentModelSelection()
|
|
413
|
+
if (sel && sel.provider && sel.model) out.route = sel.provider + '/' + sel.model
|
|
414
|
+
} catch { /* keep null */ }
|
|
415
|
+
if (!out.route) {
|
|
416
|
+
try {
|
|
417
|
+
const catalog = await session.modelCatalog(true)
|
|
418
|
+
if (catalog.default && catalog.default.provider && catalog.default.model) {
|
|
419
|
+
out.route = catalog.default.provider + '/' + catalog.default.model
|
|
420
|
+
}
|
|
421
|
+
} catch { /* keep null */ }
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
out.errors.push('default: ' + err.slice(0, 120))
|
|
425
|
+
if (/api key|auth/i.test(err)) {
|
|
426
|
+
const catalog = await session.modelCatalog(true)
|
|
427
|
+
for (const group of catalog.groups) {
|
|
428
|
+
if (group.models.length === 0) continue
|
|
429
|
+
const candidate = { provider: group.id, model: group.models[0].id }
|
|
430
|
+
try {
|
|
431
|
+
await session.selectModel(candidate)
|
|
432
|
+
} catch (e) { out.errors.push('selectModel ' + group.id + ': ' + String(e).slice(0, 80)); continue }
|
|
433
|
+
err = await turnOnce(session, 'say ok')
|
|
434
|
+
if (err === undefined) {
|
|
435
|
+
out.ok = true
|
|
436
|
+
out.route = candidate.provider + '/' + candidate.model
|
|
437
|
+
break
|
|
438
|
+
}
|
|
439
|
+
out.errors.push(group.id + ': ' + err.slice(0, 120))
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
out.route = null
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// a DIFFERENT catalog route for scenario g's model-pref overlay (the label
|
|
446
|
+
// must then provably come from the workspace config, not the cold-boot
|
|
447
|
+
// default; the route only needs a distinct id — no working key required)
|
|
448
|
+
if (out.route) {
|
|
449
|
+
try {
|
|
450
|
+
const catalog = await session.modelCatalog(true)
|
|
451
|
+
const rp = out.route.split('/')[0]
|
|
452
|
+
const alt = catalog.groups.find((g) => g.models.length > 0 && g.id !== rp)
|
|
453
|
+
if (alt) out.otherRoute = alt.id + '/' + alt.models[0].id
|
|
454
|
+
} catch { /* keep null */ }
|
|
455
|
+
}
|
|
456
|
+
session.abort()
|
|
457
|
+
await session.dispose()
|
|
458
|
+
await live.close()
|
|
459
|
+
console.log('PROBE_JSON' + JSON.stringify(out))
|
|
460
|
+
process.exit(0)
|
|
461
|
+
`,
|
|
462
|
+
)
|
|
463
|
+
try {
|
|
464
|
+
const r = sh('bun', [probePath], {
|
|
465
|
+
encoding: 'utf8',
|
|
466
|
+
timeout: 300_000,
|
|
467
|
+
cwd: join(REPO, 'packages', 'shell'),
|
|
468
|
+
env: { ...process.env, P6_PROBE_WS: probeSessWs },
|
|
469
|
+
})
|
|
470
|
+
const line = (r.stdout + '\n' + r.stderr).split('\n').find((l) => l.startsWith('PROBE_JSON'))
|
|
471
|
+
if (!line) throw new Error(`probe produced no result (exit ${r.status}):\n${(r.stdout + r.stderr).slice(-1200)}`)
|
|
472
|
+
return JSON.parse(line.slice('PROBE_JSON'.length))
|
|
473
|
+
} finally {
|
|
474
|
+
rmSync(probePath, { force: true })
|
|
475
|
+
rmSync(probeSessWs, { recursive: true, force: true })
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Scenario scaffold.
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
const scenarios = []
|
|
484
|
+
const record = (name, verdict, notes = [], p1 = []) => {
|
|
485
|
+
scenarios.push({ name, verdict, notes, p1 })
|
|
486
|
+
console.log(`\n=== ${name}: ${verdict} ===`)
|
|
487
|
+
for (const n of notes) console.log(` ${n}`)
|
|
488
|
+
for (const p of p1) console.log(` !! ${p}`)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const assert = (cond, message) => {
|
|
492
|
+
if (!cond) throw new Error(message)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Between-scenario hygiene: nothing of ours should be left running. If a
|
|
496
|
+
* host IS still alive here, it is a leftover — reap it so the next
|
|
497
|
+
* scenario starts from the quiet topology. */
|
|
498
|
+
const preClean = async (label) => {
|
|
499
|
+
const lock = readLock()
|
|
500
|
+
if (lock && pidAlive(lock.pid)) {
|
|
501
|
+
console.log(` [${label}] reaping leftover host pid=${lock.pid} port=${lock.port}`)
|
|
502
|
+
await reapHost(lock)
|
|
503
|
+
} else if (lock) {
|
|
504
|
+
rmSync(LOCK_PATH, { force: true })
|
|
505
|
+
}
|
|
506
|
+
for (const pid of hostPids()) {
|
|
507
|
+
if (!lock || pid !== lock.pid) {
|
|
508
|
+
console.log(` [${label}] killing stray host wrapper pid=${pid}`)
|
|
509
|
+
await killHostTree(pid)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Scenarios.
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
let ROUTE = null // concrete "provider/model" | null — the workingRoute discovery product
|
|
519
|
+
let OTHER_ROUTE = null // a DIFFERENT catalog route (scenario g's overlay pref)
|
|
520
|
+
let TURN_OK = false // a route answers (default or fallback) — turns may run
|
|
521
|
+
|
|
522
|
+
const scenarioA = async () => {
|
|
523
|
+
const notes = []
|
|
524
|
+
const ws = mkdtempSync(join(tmpdir(), 'p6-ws-a-'))
|
|
525
|
+
const name = 'p6a'
|
|
526
|
+
try {
|
|
527
|
+
newClientSession(name, ws, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
528
|
+
const rendered = await waitRendered(name)
|
|
529
|
+
const line = powerlineOf(rendered)
|
|
530
|
+
const route = routeOf(line)
|
|
531
|
+
notes.push(`powerline: ${line.trim().slice(0, 120)}`)
|
|
532
|
+
assert(route !== null, `powerline route segment not parseable: ${line}`)
|
|
533
|
+
assert(route.provider !== 'dsh-host' && route.provider !== '', `status bar shows the generic label (cold-boot route never applied): ${line}`)
|
|
534
|
+
assert(route.model !== '·' && route.model !== '', `status bar shows no model: ${line}`)
|
|
535
|
+
const ctxBefore = contextOf(line)
|
|
536
|
+
notes.push(`status bar route: ${route.provider} / ${route.model} · context before: ${ctxBefore}`)
|
|
537
|
+
|
|
538
|
+
if (!TURN_OK) {
|
|
539
|
+
record('a. boot + turn', 'BLOCKED (turn)', [
|
|
540
|
+
...notes,
|
|
541
|
+
'boot + status bar asserts PASSED; the turn half is blocked: no route answers (key rot) — see the probe report above',
|
|
542
|
+
])
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
const { after } = await runTurn(name, 'say ok')
|
|
546
|
+
const line2 = powerlineOf(after)
|
|
547
|
+
const ctxAfter = contextOf(line2)
|
|
548
|
+
const reply = after.includes('❯ say ok')
|
|
549
|
+
notes.push(`post-turn powerline: ${line2.trim().slice(0, 120)}`)
|
|
550
|
+
notes.push(`turn: user row painted=${reply} · context after: ${ctxAfter}`)
|
|
551
|
+
assert(reply, 'user row "❯ say ok" not painted')
|
|
552
|
+
assert(ctxBefore === 0 && ctxAfter > ctxBefore, `context did not increase across the turn (${ctxBefore} → ${ctxAfter})`)
|
|
553
|
+
record('a. boot + turn', 'PASS', notes)
|
|
554
|
+
} catch (e) {
|
|
555
|
+
record('a. boot + turn', 'FAIL', [...notes, String(e.message)])
|
|
556
|
+
} finally {
|
|
557
|
+
await quitClient(name)
|
|
558
|
+
await reapHost(readLock())
|
|
559
|
+
rmSync(ws, { recursive: true, force: true })
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const scenarioB = async () => {
|
|
564
|
+
const notes = []
|
|
565
|
+
const p1 = []
|
|
566
|
+
const ws1 = mkdtempSync(join(tmpdir(), 'p6-ws-b1-'))
|
|
567
|
+
const ws2 = mkdtempSync(join(tmpdir(), 'p6-ws-b2-'))
|
|
568
|
+
const n1 = 'p6b1'
|
|
569
|
+
const n2 = 'p6b2'
|
|
570
|
+
try {
|
|
571
|
+
newClientSession(n1, ws1, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
572
|
+
await waitRendered(n1)
|
|
573
|
+
const lock1 = readLock()
|
|
574
|
+
assert(lock1 !== null, 'no lockfile after client 1 boot (host not lifecycle-managed?)')
|
|
575
|
+
notes.push(`client 1 booted; host pid=${lock1.pid} port=${lock1.port}`)
|
|
576
|
+
assert(hostPids().length === 1, `expected exactly 1 host wrapper after boot 1, got ${hostPids().join(',')}`)
|
|
577
|
+
|
|
578
|
+
newClientSession(n2, ws2, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
579
|
+
await waitRendered(n2)
|
|
580
|
+
const lock2 = readLock()
|
|
581
|
+
notes.push(`client 2 booted; lockfile pid=${lock2?.pid} port=${lock2?.port}`)
|
|
582
|
+
assert(lock2 && lock2.pid === lock1.pid, `second client spawned/replaced the host (lock pid ${lock1.pid} → ${lock2?.pid}) — expected a JOIN`)
|
|
583
|
+
const wrappers = hostPids()
|
|
584
|
+
if (wrappers.length !== 1) {
|
|
585
|
+
p1.push(`reproduced oldfox-P1#3: ${wrappers.length} host wrapper pids after a second client joined: ${wrappers.join(',')}`)
|
|
586
|
+
} else {
|
|
587
|
+
notes.push('exactly one host process (join, no second spawn)')
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const q1 = await quitClient(n1)
|
|
591
|
+
const q2 = await quitClient(n2)
|
|
592
|
+
notes.push(`quits: client1=${q1} client2=${q2}`)
|
|
593
|
+
|
|
594
|
+
// the idle-reaper path: the last quit spawned the detached reaper with
|
|
595
|
+
// graceSec = idleExitSec (60) — the host exits inside ~60-75s
|
|
596
|
+
const hostGone = await waitPidGone(lock1.pid, 110_000)
|
|
597
|
+
const lockAfter = readLock()
|
|
598
|
+
notes.push(`host dead after grace=${hostGone} · lockfile now: ${lockAfter ? `pid=${lockAfter.pid}` : 'gone'}`)
|
|
599
|
+
assert(hostGone, `host pid=${lock1.pid} still alive 110s after both clients quit — idle-exit hand-off failed`)
|
|
600
|
+
if (lockAfter && lockAfter.pid === lock1.pid) rmSync(LOCK_PATH, { force: true })
|
|
601
|
+
assert(!await isListening(lock1.port), `port ${lock1.port} still listening after the host died`)
|
|
602
|
+
record('b. multi-TUI attach', 'PASS', notes, p1)
|
|
603
|
+
} catch (e) {
|
|
604
|
+
record('b. multi-TUI attach', 'FAIL', [...notes, String(e.message)], p1)
|
|
605
|
+
} finally {
|
|
606
|
+
await quitClient(n1)
|
|
607
|
+
await quitClient(n2)
|
|
608
|
+
await reapHost(readLock())
|
|
609
|
+
rmSync(ws1, { recursive: true, force: true })
|
|
610
|
+
rmSync(ws2, { recursive: true, force: true })
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const scenarioC = async () => {
|
|
615
|
+
const notes = []
|
|
616
|
+
const ws1 = mkdtempSync(join(tmpdir(), 'p6-ws-c1-'))
|
|
617
|
+
const ws2 = mkdtempSync(join(tmpdir(), 'p6-ws-c2-'))
|
|
618
|
+
const n1 = 'p6c1'
|
|
619
|
+
const n2 = 'p6c2'
|
|
620
|
+
try {
|
|
621
|
+
newClientSession(n1, ws1, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
622
|
+
await waitRendered(n1)
|
|
623
|
+
const lock1 = readLock()
|
|
624
|
+
assert(lock1 !== null, 'no lockfile after client 1 boot')
|
|
625
|
+
|
|
626
|
+
// kill -9 the CLIENT's bun process (not the pane shell)
|
|
627
|
+
const pg = sh('pgrep', ['-f', 'client-entry.tsx'])
|
|
628
|
+
const clientPids = pg.status === 0 ? pg.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
|
|
629
|
+
assert(clientPids.length === 1, `expected exactly 1 client bun process, got: ${clientPids.join(',')}`)
|
|
630
|
+
try { process.kill(clientPids[0], 'SIGKILL') } catch { /* gone */ }
|
|
631
|
+
notes.push(`client 1 bun pid=${clientPids[0]} SIGKILLed`)
|
|
632
|
+
await waitPaneDead(n1, 10_000)
|
|
633
|
+
await sleep(2000)
|
|
634
|
+
assert(pidAlive(lock1.pid), `host pid=${lock1.pid} died with its client — the resident-host contract broke`)
|
|
635
|
+
assert(await isListening(lock1.port), `host port ${lock1.port} no longer listening after the client crashed`)
|
|
636
|
+
notes.push(`host survived the client crash (pid=${lock1.pid} alive, port ${lock1.port} listening)`)
|
|
637
|
+
|
|
638
|
+
newClientSession(n2, ws2, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
639
|
+
await waitRendered(n2)
|
|
640
|
+
const lock2 = readLock()
|
|
641
|
+
notes.push(`client 2 booted; lockfile pid=${lock2?.pid} port=${lock2?.port}`)
|
|
642
|
+
assert(lock2 && lock2.pid === lock1.pid, `client 2 did NOT adopt: lock pid ${lock1.pid} → ${lock2?.pid} (a fresh boot happened instead)`)
|
|
643
|
+
const wrappers = hostPids()
|
|
644
|
+
if (wrappers.length !== 1) {
|
|
645
|
+
notes.push(`WARNING: host wrapper count after adoption = ${wrappers.length} (${wrappers.join(',')}) — expected 1`)
|
|
646
|
+
} else {
|
|
647
|
+
notes.push('adoption path confirmed: same pid, exactly one host process, no respawn')
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const q = await quitClient(n2)
|
|
651
|
+
notes.push(`client 2 quit: ${q}`)
|
|
652
|
+
await reapHost(lock2)
|
|
653
|
+
notes.push(`teardown: host pid=${lock2.pid} reaped, lockfile ${readLock() ? 'STILL PRESENT' : 'gone'}`)
|
|
654
|
+
assert(readLock() === null, 'lockfile survived the full teardown')
|
|
655
|
+
record('c. crash-recovery adoption', 'PASS', notes)
|
|
656
|
+
} catch (e) {
|
|
657
|
+
record('c. crash-recovery adoption', 'FAIL', [...notes, String(e.message)])
|
|
658
|
+
} finally {
|
|
659
|
+
await quitClient(n1)
|
|
660
|
+
await quitClient(n2)
|
|
661
|
+
await reapHost(readLock())
|
|
662
|
+
rmSync(ws1, { recursive: true, force: true })
|
|
663
|
+
rmSync(ws2, { recursive: true, force: true })
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const scenarioD = async () => {
|
|
668
|
+
const notes = []
|
|
669
|
+
const p1 = []
|
|
670
|
+
const ws = mkdtempSync(join(tmpdir(), 'p6-ws-d-'))
|
|
671
|
+
const name = 'p6d'
|
|
672
|
+
let dLock0 = null // kept for the finally: the restarted host may be unmanaged
|
|
673
|
+
try {
|
|
674
|
+
newClientSession(name, ws, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
675
|
+
await waitRendered(name)
|
|
676
|
+
const lock0 = readLock()
|
|
677
|
+
dLock0 = lock0
|
|
678
|
+
assert(lock0 !== null, 'no lockfile before /reload')
|
|
679
|
+
|
|
680
|
+
// cheap transcript rows without a model call
|
|
681
|
+
await submitLine(name, '/usage')
|
|
682
|
+
await waitPane(name, (out) => out.includes('turns 0'), 15_000)
|
|
683
|
+
notes.push('/usage rows painted (system transcript rows)')
|
|
684
|
+
|
|
685
|
+
// one real turn row when a route answers
|
|
686
|
+
if (TURN_OK) {
|
|
687
|
+
await runTurn(name, 'say ok')
|
|
688
|
+
notes.push('say-ok turn completed before the reload')
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
await submitLine(name, '/reload')
|
|
692
|
+
const reloaded = await waitPane(name, (out) => out.includes('plugins reloaded') || out.includes('reload failed'), 90_000)
|
|
693
|
+
assert(reloaded.includes('plugins reloaded'), `/reload did not complete cleanly:\n${reloaded.slice(-1200)}`)
|
|
694
|
+
notes.push('"plugins reloaded" painted')
|
|
695
|
+
|
|
696
|
+
// same-port restart: old pid dead, a listener STILL on lock0.port, new pid
|
|
697
|
+
const listeners = sh('lsof', ['-ti', `tcp:${lock0.port}`])
|
|
698
|
+
const listenerPids = listeners.status === 0 ? listeners.stdout.split('\n').map((s) => Number(s.trim())).filter(Boolean) : []
|
|
699
|
+
const newPid = listenerPids.find((p) => p !== lock0.pid) ?? listenerPids[0]
|
|
700
|
+
notes.push(`restart: old pid dead=${await waitPidGone(lock0.pid, 100)} · port ${lock0.port} listener pid(s)=${listenerPids.join(',') || 'NONE'}`)
|
|
701
|
+
assert(listenerPids.length > 0, `port ${lock0.port} has no listener after /reload — the host did not come back`)
|
|
702
|
+
assert(newPid !== undefined && newPid !== lock0.pid, `expected a NEW host process on the same port (listener=${newPid}, old=${lock0.pid})`)
|
|
703
|
+
notes.push(`same-port restart confirmed: ${lock0.pid} → ${newPid} on port ${lock0.port}`)
|
|
704
|
+
|
|
705
|
+
// oldfox-P1#2 watch: the restart path removes the lockfile and never
|
|
706
|
+
// rewrites it (writeLock lives only in bootHost) — the restarted host is
|
|
707
|
+
// UNMANAGED and the next ensureHost would boot a duplicate on a new port.
|
|
708
|
+
const lockAfter = readLock()
|
|
709
|
+
if (!lockAfter || lockAfter.pid !== newPid) {
|
|
710
|
+
p1.push(`reproduced oldfox-P1#2: after /reload the lockfile is ${lockAfter ? `stale (pid=${lockAfter.pid}, listener=${newPid})` : 'GONE'} — the restarted host pid=${newPid} is unmanaged (next ensureHost would boot a duplicate host; teardown below reaps by port)`)
|
|
711
|
+
} else {
|
|
712
|
+
notes.push('lockfile rewritten by the restart (P1#2 not hit on this build)')
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// transcript survival + double-paint watch (oldfox-P1#1)
|
|
716
|
+
await sleep(3000) // let the re-attach replay settle
|
|
717
|
+
const full = capture(name, { full: true })
|
|
718
|
+
const sayOkRows = (full.match(/❯ say ok/g) ?? []).length
|
|
719
|
+
const usageRows = (full.match(/turns 0/g) ?? []).length
|
|
720
|
+
notes.push(`post-reload transcript: "❯ say ok" rows=${sayOkRows} · "turns 0" rows=${usageRows}`)
|
|
721
|
+
if (ROUTE && sayOkRows > 1) p1.push(`reproduced oldfox-P1#1: "❯ say ok" painted ${sayOkRows}x after the /reload re-attach (no seq dedupe on replay)`)
|
|
722
|
+
if (usageRows > 1) p1.push(`reproduced oldfox-P1#1: "turns 0" system row painted ${usageRows}x`)
|
|
723
|
+
assert(sayOkRows >= (TURN_OK ? 1 : 0) && usageRows >= 1, 'pre-reload transcript rows lost across the restart')
|
|
724
|
+
record('d. /reload same-port restart', p1.length > 0 ? 'PASS (with P1 reproductions)' : 'PASS', notes, p1)
|
|
725
|
+
} catch (e) {
|
|
726
|
+
record('d. /reload same-port restart', 'FAIL', [...notes, String(e.message)], p1)
|
|
727
|
+
} finally {
|
|
728
|
+
await quitClient(name)
|
|
729
|
+
// the restarted host may be UNMANAGED (P1#2, no lockfile): reap by lock
|
|
730
|
+
// AND by port sweep so nothing leaks
|
|
731
|
+
await reapHost(readLock())
|
|
732
|
+
if (dLock0) await sweepPort(dLock0.port)
|
|
733
|
+
rmSync(ws, { recursive: true, force: true })
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const scenarioE = async () => {
|
|
738
|
+
const notes = []
|
|
739
|
+
const p1 = []
|
|
740
|
+
const ws1 = mkdtempSync(join(tmpdir(), 'p6-ws-e1-'))
|
|
741
|
+
const ws2 = mkdtempSync(join(tmpdir(), 'p6-ws-e2-'))
|
|
742
|
+
const n1 = 'p6e1'
|
|
743
|
+
const n2 = 'p6e2'
|
|
744
|
+
try {
|
|
745
|
+
newClientSession(n1, ws1, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
746
|
+
await waitRendered(n1)
|
|
747
|
+
const lock1 = readLock()
|
|
748
|
+
assert(lock1 !== null, 'no lockfile before the host kill')
|
|
749
|
+
|
|
750
|
+
if (TURN_OK) {
|
|
751
|
+
// stage a long-running turn so the follow stream dies mid-await
|
|
752
|
+
await submitLine(n1, '写一篇 300 字左右的短文,介绍秋天的湖面。不要使用任何工具。')
|
|
753
|
+
const busy = await waitPane(n1, (out) => !isIdle(out), 20_000)
|
|
754
|
+
assert(!isIdle(busy), `long turn never became busy:\n${busy.slice(-800)}`)
|
|
755
|
+
await sleep(4000) // let the follow attach and content stream
|
|
756
|
+
notes.push('long turn in flight (editor left idle)')
|
|
757
|
+
} else {
|
|
758
|
+
notes.push('no working route: skipping the mid-turn staging — the death surface degrades (documented in the header)')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// kill -9 the HOST process group (wrapper is a detached group leader)
|
|
762
|
+
try { process.kill(-lock1.pid, 'SIGKILL') } catch { try { process.kill(lock1.pid, 'SIGKILL') } catch { /* gone */ } }
|
|
763
|
+
notes.push(`host pid=${lock1.pid} SIGKILLed (process group)`)
|
|
764
|
+
const listenerGone = await (async () => {
|
|
765
|
+
const deadline = Date.now() + 10_000
|
|
766
|
+
while (Date.now() < deadline) {
|
|
767
|
+
if (!(await isListening(lock1.port))) return true
|
|
768
|
+
await sleep(300)
|
|
769
|
+
}
|
|
770
|
+
return false
|
|
771
|
+
})()
|
|
772
|
+
notes.push(`host port ${lock1.port} released=${listenerGone}`)
|
|
773
|
+
assert(listenerGone, 'host port still listening after SIGKILL — the server process survived the group kill')
|
|
774
|
+
|
|
775
|
+
// the client must SURFACE the death and must NOT crash (oldfox-P1#4 watch)
|
|
776
|
+
let surfaced = 'nothing'
|
|
777
|
+
if (TURN_OK) {
|
|
778
|
+
const surfacedPane = await waitPane(n1, (out) => out.includes('reconnecting') || !paneAlive(n1), 20_000)
|
|
779
|
+
if (surfacedPane.includes('reconnecting')) {
|
|
780
|
+
surfaced = 'reconnecting label painted'
|
|
781
|
+
const line = powerlineOf(surfacedPane)
|
|
782
|
+
notes.push(`powerline during the outage: ${line.trim().slice(0, 120)}`)
|
|
783
|
+
} else {
|
|
784
|
+
notes.push(
|
|
785
|
+
'FINDING (renderer sampling gap, not an oldfox P1): the session-level reconnecting marker exists (RemoteHostSession sets label="reconnecting…" and status.turn="reconnecting" on the mid-turn follow detach) but the PANE never paints it — ShellHost samples session.label only at onTurnEnd / /think (setLabel, ShellHost.tsx) and the turn never settles during an unresolved outage, so the swap never re-renders. Recorded for the UI-follow-up task.',
|
|
786
|
+
)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// next ensureHost respawns — a FRESH client boot (the chosen recovery UX)
|
|
791
|
+
newClientSession(n2, ws2, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
792
|
+
await waitRendered(n2)
|
|
793
|
+
const lock2 = readLock()
|
|
794
|
+
notes.push(`client 2 booted; lockfile pid=${lock2?.pid} port=${lock2?.port}`)
|
|
795
|
+
assert(lock2 !== null, 'no lockfile after the recovery boot')
|
|
796
|
+
assert(lock2.pid !== lock1.pid, `recovery boot reused the dead pid?! ${lock2.pid}`)
|
|
797
|
+
assert(await isListening(lock2.port), `recovery host port ${lock2.port} not listening`)
|
|
798
|
+
|
|
799
|
+
if (TURN_OK) {
|
|
800
|
+
// the recovered client continues (a real turn through the fresh host)
|
|
801
|
+
await runTurn(n2, 'say ok')
|
|
802
|
+
const line2 = powerlineOf(capture(n2))
|
|
803
|
+
const ctx = contextOf(line2)
|
|
804
|
+
notes.push(`recovered client completed a turn (context after: ${ctx})`)
|
|
805
|
+
assert(ctx !== null && ctx > 0, 'recovered client turn did not register usage')
|
|
806
|
+
}
|
|
807
|
+
record('e. host death recovery', 'PASS', notes, p1)
|
|
808
|
+
} catch (e) {
|
|
809
|
+
record('e. host death recovery', 'FAIL', [...notes, String(e.message)], p1)
|
|
810
|
+
} finally {
|
|
811
|
+
await quitClient(n1)
|
|
812
|
+
await quitClient(n2)
|
|
813
|
+
await reapHost(readLock())
|
|
814
|
+
rmSync(ws1, { recursive: true, force: true })
|
|
815
|
+
rmSync(ws2, { recursive: true, force: true })
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const scenarioF = async () => {
|
|
820
|
+
const notes = []
|
|
821
|
+
const ws = mkdtempSync(join(tmpdir(), 'p6-ws-f-'))
|
|
822
|
+
const name = 'p6f'
|
|
823
|
+
try {
|
|
824
|
+
newClientSession(name, ws, ROUTE ? { DSH_OPENTUI_MODEL: ROUTE } : {})
|
|
825
|
+
await waitRendered(name)
|
|
826
|
+
if (!TURN_OK) {
|
|
827
|
+
const cap = capture(name, { esc: true })
|
|
828
|
+
writeFileSync('/tmp/p6-mdtable.txt', cap)
|
|
829
|
+
record('f. acceptance (markdown table)', 'BLOCKED', [
|
|
830
|
+
...notes,
|
|
831
|
+
'THE acceptance scenario is BLOCKED: no model route answers (probe report above) — nothing was hacked; capture saved to /tmp/p6-mdtable.txt for the record',
|
|
832
|
+
])
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
await submitLine(name, '用一个markdown表格列出两种语言和创建年份,再写一句总结')
|
|
836
|
+
// the model may ask a clarifying user-question (ask_user_question
|
|
837
|
+
// waterfall → the "模型提问" overlay) — answer with the highlighted
|
|
838
|
+
// (recommended) option and keep waiting for the table
|
|
839
|
+
const tableDone = (out) => {
|
|
840
|
+
const lines = out.split('\n').filter((l) => l.includes('│') && !l.includes('❯') && !/[╭╰╮╯]/.test(l))
|
|
841
|
+
return lines.length >= 2 && out.includes('└─') && isIdle(out)
|
|
842
|
+
}
|
|
843
|
+
let done = ''
|
|
844
|
+
let asked = 0
|
|
845
|
+
const deadline = Date.now() + 150_000
|
|
846
|
+
while (Date.now() < deadline) {
|
|
847
|
+
done = capture(name)
|
|
848
|
+
if (tableDone(done)) break
|
|
849
|
+
if (done.includes('模型提问') && Date.now() - asked > 3000) {
|
|
850
|
+
asked = Date.now()
|
|
851
|
+
notes.push('model asked a clarifying question (ask_user_question overlay) — answering with the highlighted option')
|
|
852
|
+
sendKeys(name, 'Enter')
|
|
853
|
+
await sleep(2500)
|
|
854
|
+
continue
|
|
855
|
+
}
|
|
856
|
+
await sleep(800)
|
|
857
|
+
}
|
|
858
|
+
const rows = done.split('\n').filter((l) => l.includes('│') && !l.includes('❯') && !/[╭╰╮╯]/.test(l))
|
|
859
|
+
const bottomIdx = done.split('\n').findIndex((l) => l.includes('└─'))
|
|
860
|
+
assert(rows.length >= 2, `no bordered table rows (│) in the reply pane`)
|
|
861
|
+
assert(bottomIdx !== -1, 'no └─ table bottom border in the reply pane')
|
|
862
|
+
// summary line: non-table text AFTER the table bottom
|
|
863
|
+
const afterBottom = done.split('\n').slice(bottomIdx + 1).map((l) => l.trim()).filter((l) => l.length > 0 && !l.includes('│'))
|
|
864
|
+
const summary = afterBottom.find((l) => !/^╰|^enter send|^ dsh\(|^\d{1,2}:\d{2}:\d{2}$/.test(l) && !l.includes('❯'))
|
|
865
|
+
notes.push(`table rows painted: ${rows.length}; first: ${JSON.stringify(rows[0].trim().slice(0, 80))}`)
|
|
866
|
+
notes.push(`table bottom: ${JSON.stringify(done.split('\n')[bottomIdx].trim().slice(0, 60))}`)
|
|
867
|
+
notes.push(`summary line after the table: ${summary ? JSON.stringify(summary.trim().slice(0, 100)) : 'NOT FOUND'}`)
|
|
868
|
+
assert(summary !== undefined, 'no summary line after the table bottom')
|
|
869
|
+
// bottom anchor: the powerline footer is still the last chrome row
|
|
870
|
+
const tail = done.split('\n').filter((l) => l.trim().length > 0).slice(-3).join(' | ')
|
|
871
|
+
notes.push(`pane tail (bottom anchor): ${tail.slice(0, 140)}`)
|
|
872
|
+
assert(tail.includes('🧠'), `powerline footer missing from the pane tail — UI died or scrolled: ${tail}`)
|
|
873
|
+
assert(/:\d{2}:\d{2}/.test(tail), `powerline clock missing from the tail: ${tail}`)
|
|
874
|
+
|
|
875
|
+
// artifacts: ANSI capture + PNG render for eyeballing
|
|
876
|
+
const esc = capture(name, { esc: true })
|
|
877
|
+
writeFileSync('/tmp/p6-mdtable.txt', esc)
|
|
878
|
+
const png = sh('python3', ['/tmp/tmux2png.py', '/tmp/p6-mdtable.txt', '/tmp/p6-mdtable.png', String(GEOMETRY.x), String(GEOMETRY.y)], { encoding: 'utf8' })
|
|
879
|
+
notes.push(png.status === 0 ? `PNG rendered: ${png.stdout.trim()}` : `PNG render FAILED (exit ${png.status}): ${png.stderr.slice(0, 200)}`)
|
|
880
|
+
record('f. acceptance (markdown table)', 'PASS', notes)
|
|
881
|
+
} catch (e) {
|
|
882
|
+
try { writeFileSync('/tmp/p6-mdtable.txt', capture(name, { esc: true })) } catch { /* keep whatever exists */ }
|
|
883
|
+
record('f. acceptance (markdown table)', 'FAIL', [...notes, String(e.message)])
|
|
884
|
+
} finally {
|
|
885
|
+
await quitClient(name)
|
|
886
|
+
await reapHost(readLock())
|
|
887
|
+
rmSync(ws, { recursive: true, force: true })
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const scenarioG = async () => {
|
|
892
|
+
const notes = []
|
|
893
|
+
const ws = mkdtempSync(join(tmpdir(), 'p6-ws-g-'))
|
|
894
|
+
const name = 'p6g'
|
|
895
|
+
try {
|
|
896
|
+
// the workspace overlay: a DIFFERENT shipped theme + the model preference
|
|
897
|
+
mkdirSync(join(ws, '.dsh'), { recursive: true })
|
|
898
|
+
// the overlay: theme is the strong assertion; the model pref uses a
|
|
899
|
+
// DIFFERENT catalog route when one exists — the powerline label then
|
|
900
|
+
// provably comes from the workspace config, never the cold-boot default
|
|
901
|
+
// (a dead key on that route is fine: scenario g sends no turn)
|
|
902
|
+
const route = OTHER_ROUTE ?? ROUTE
|
|
903
|
+
const [provider, model] = route ? route.split('/') : [undefined, undefined]
|
|
904
|
+
const configBody =
|
|
905
|
+
`# p6 scenario g workspace overlay\ntheme: dracula\n` +
|
|
906
|
+
(route ? `model:\n provider: ${provider}\n model: ${model}\n` : `# (no concrete route resolvable — model pref omitted)\n`)
|
|
907
|
+
writeFileSync(join(ws, '.dsh', 'config.yml'), configBody)
|
|
908
|
+
notes.push(`workspace config.yml: theme=dracula${route ? ` · model=${route}${OTHER_ROUTE ? ' (differs from the cold-boot default → strong assertion)' : ''}` : ' · (model pref omitted: no concrete route)'}`)
|
|
909
|
+
// boot WITHOUT the env override — the overlay is the only model source
|
|
910
|
+
newClientSession(name, ws, {})
|
|
911
|
+
const rendered = await waitRendered(name)
|
|
912
|
+
const line = powerlineOf(rendered)
|
|
913
|
+
const rt = routeOf(line)
|
|
914
|
+
notes.push(`powerline: ${line.trim().slice(0, 120)}`)
|
|
915
|
+
assert(rt !== null && rt.model !== '·' && rt.provider !== 'dsh-host', `status bar shows a generic route — the overlay label never applied: ${line}`)
|
|
916
|
+
if (route) {
|
|
917
|
+
assert(rt.model === model, `workspace model pref not applied (want ${model}, got ${rt.model})`)
|
|
918
|
+
if (rt.provider !== provider) notes.push(`note: provider seg ${rt.provider} != overlay ${provider} (the label carries the live session route)`)
|
|
919
|
+
}
|
|
920
|
+
// workspace cwd carried: the banner prints `<model> · <cwd>`
|
|
921
|
+
const banner = rendered.split('\n').find((l) => l.includes(' · ') && l.includes(ws.split('/').pop()))
|
|
922
|
+
assert(banner !== undefined || rendered.includes(ws), `welcome banner does not show the workspace cwd (${ws})`)
|
|
923
|
+
notes.push(`banner cwd line: ${JSON.stringify((banner ?? rendered.split('\n').find((l) => l.includes(ws)))?.trim().slice(0, 100))}`)
|
|
924
|
+
|
|
925
|
+
// theme actually applied: paint a user row, then compare truecolor SGRs
|
|
926
|
+
await submitLine(name, 'theme probe')
|
|
927
|
+
await waitPane(name, (out) => out.includes('❯ theme probe'), 15_000)
|
|
928
|
+
await sleep(2000)
|
|
929
|
+
const esc = capture(name, { esc: true })
|
|
930
|
+
const draculaAccent = esc.includes('38;2;189;147;249') // #bd93f9 — dracula accent (user-row fallback: no userMark token)
|
|
931
|
+
const mochaUserMark = esc.includes('38;2;137;180;250') // #89b4fa — catppuccin-mocha userMark
|
|
932
|
+
notes.push(`capture -e: dracula accent painted=${draculaAccent} · mocha userMark painted=${mochaUserMark}`)
|
|
933
|
+
assert(draculaAccent, 'dracula accent color not found in capture -e — the workspace theme did not apply')
|
|
934
|
+
assert(!mochaUserMark, 'the default mocha user-row color is still painted — theme override ineffective')
|
|
935
|
+
|
|
936
|
+
// cross-check against scenario f's default-theme capture when available
|
|
937
|
+
if (existsSync('/tmp/p6-mdtable.txt')) {
|
|
938
|
+
const f = readFileSync('/tmp/p6-mdtable.txt', 'utf8')
|
|
939
|
+
notes.push(`cross-check: scenario f (default theme) capture paints mocha userMark=${f.includes('38;2;137;180;250')} — g differs=${f.includes('38;2;137;180;250') && !mochaUserMark}`)
|
|
940
|
+
}
|
|
941
|
+
record('g. workspace config effect', 'PASS', notes)
|
|
942
|
+
} catch (e) {
|
|
943
|
+
record('g. workspace config effect', 'FAIL', [...notes, String(e.message)])
|
|
944
|
+
} finally {
|
|
945
|
+
await quitClient(name)
|
|
946
|
+
await reapHost(readLock())
|
|
947
|
+
rmSync(ws, { recursive: true, force: true })
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------------------------------------------------------------------------
|
|
952
|
+
// Main.
|
|
953
|
+
// ---------------------------------------------------------------------------
|
|
954
|
+
|
|
955
|
+
const main = async () => {
|
|
956
|
+
ownedTmuxServer = !tmuxServerWasRunning()
|
|
957
|
+
console.log(`e2e-client: repo=${REPO} · dshHome=${DSH_HOME} · tmux ${GEOMETRY.x}x${GEOMETRY.y}`)
|
|
958
|
+
const startLock = readLock()
|
|
959
|
+
if (startLock && pidAlive(startLock.pid)) {
|
|
960
|
+
console.log(`NOTE: a host is ALREADY resident (pid=${startLock.pid} port=${startLock.port}) — the gate adopts it and its teardown may idle-exit it; relaunching the TUI respawns it`)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
console.log('\n--- workingRoute discovery (probe before any scenario) ---')
|
|
964
|
+
const probe = await probeWorkingRoute()
|
|
965
|
+
console.log(` host: pid=${probe.hostPid} port=${probe.hostPort} adopted=${probe.adopted}`)
|
|
966
|
+
if (probe.ok) {
|
|
967
|
+
TURN_OK = true
|
|
968
|
+
ROUTE = probe.route
|
|
969
|
+
OTHER_ROUTE = probe.otherRoute
|
|
970
|
+
console.log(` route: ${ROUTE ? (probe.adoptedDefault ? `deployment default answers → ${ROUTE}` : `fallback selected → ${ROUTE}`) : 'a route answers but its concrete id was not resolvable — turns run without the env override'}${OTHER_ROUTE ? ` · other catalog route: ${OTHER_ROUTE}` : ''}`)
|
|
971
|
+
} else {
|
|
972
|
+
TURN_OK = false
|
|
973
|
+
ROUTE = null
|
|
974
|
+
console.log(' !! NO ROUTE ANSWERS — real turns are BLOCKED; running everything except real turns (loud, no hacks)')
|
|
975
|
+
for (const e of probe.errors) console.log(` ${e}`)
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
for (const pid of hostPids()) {
|
|
979
|
+
if (!probe.hostPid || pid !== probe.hostPid) {
|
|
980
|
+
console.log(` stray host wrapper pid=${pid} — killing before the scenarios`)
|
|
981
|
+
await killHostTree(pid)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
await scenarioA()
|
|
986
|
+
await preClean('between a→b')
|
|
987
|
+
await scenarioB()
|
|
988
|
+
await preClean('between b→c')
|
|
989
|
+
await scenarioC()
|
|
990
|
+
await preClean('between c→d')
|
|
991
|
+
await scenarioD()
|
|
992
|
+
await preClean('between d→e')
|
|
993
|
+
await scenarioE()
|
|
994
|
+
await preClean('between e→f')
|
|
995
|
+
await scenarioF()
|
|
996
|
+
await preClean('between f→g')
|
|
997
|
+
await scenarioG()
|
|
998
|
+
await preClean('final')
|
|
999
|
+
|
|
1000
|
+
// tmux hygiene: our sessions are already dead (every scenario quits/kills
|
|
1001
|
+
// its panes); kill the server only when THIS run created it.
|
|
1002
|
+
for (const s of sessionsCreated) {
|
|
1003
|
+
if (paneAlive(s)) tmux('kill-session', '-t', s)
|
|
1004
|
+
}
|
|
1005
|
+
const leftovers = tmuxOut('list-sessions')
|
|
1006
|
+
if (ownedTmuxServer) {
|
|
1007
|
+
tmux('kill-server')
|
|
1008
|
+
console.log('\ntmux: server killed (this run created it)')
|
|
1009
|
+
} else if (leftovers.trim()) {
|
|
1010
|
+
console.log(`\ntmux: pre-existing server left running with sessions:\n${leftovers.trim()}`)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
console.log('\n==================== e2e-client summary ====================')
|
|
1014
|
+
const fails = scenarios.filter((s) => s.verdict.startsWith('FAIL'))
|
|
1015
|
+
const blocked = scenarios.filter((s) => s.verdict.startsWith('BLOCKED'))
|
|
1016
|
+
for (const s of scenarios) console.log(`${s.verdict.padEnd(28)} ${s.name}`)
|
|
1017
|
+
const allP1 = scenarios.flatMap((s) => s.p1.map((p) => `${s.name}: ${p}`))
|
|
1018
|
+
if (allP1.length > 0) {
|
|
1019
|
+
console.log('\noldfox-P1 reproductions:')
|
|
1020
|
+
for (const p of allP1) console.log(` ${p}`)
|
|
1021
|
+
} else {
|
|
1022
|
+
console.log('\noldfox-P1 reproductions: NONE — the known P1 triggers did not fire under this gate (narrower trigger surface than estimated)')
|
|
1023
|
+
}
|
|
1024
|
+
if (blocked.length > 0) {
|
|
1025
|
+
console.log(`\n!! BLOCKED scenarios: ${blocked.map((b) => b.name).join(', ')} — environment limitation, reported loudly, nothing hacked`)
|
|
1026
|
+
}
|
|
1027
|
+
if (fails.length > 0) {
|
|
1028
|
+
console.log(`\nRESULT: FAIL (${fails.length} failed / ${scenarios.length} scenarios)`)
|
|
1029
|
+
process.exit(1)
|
|
1030
|
+
}
|
|
1031
|
+
if (blocked.some((b) => b.name.startsWith('f.'))) {
|
|
1032
|
+
console.log('\nRESULT: INCOMPLETE — the acceptance scenario (f) is blocked by dead provider keys')
|
|
1033
|
+
process.exit(1)
|
|
1034
|
+
}
|
|
1035
|
+
console.log(`\nRESULT: PASS (${scenarios.length - fails.length - blocked.length} pass / ${blocked.length} blocked / ${scenarios.length} total)`)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
main().catch((e) => {
|
|
1039
|
+
console.error(`e2e-client: crashed: ${e.stack ?? e.message}`)
|
|
1040
|
+
for (const s of sessionsCreated) if (paneAlive(s)) tmux('kill-session', '-t', s)
|
|
1041
|
+
process.exit(1)
|
|
1042
|
+
})
|