@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28

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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +47 -3
  4. package/dist/capabilities/anything.js +26 -0
  5. package/dist/capabilities/doctor.js +11 -4
  6. package/dist/capabilities/ground-transfer.js +168 -1
  7. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  8. package/dist/capabilities/route-provider-conformance.js +341 -0
  9. package/dist/scripts/anything-path-measure-tests.js +133 -0
  10. package/dist/scripts/anything-path-measure.js +150 -0
  11. package/dist/scripts/anything-tests.js +136 -2
  12. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  13. package/dist/scripts/bootstrap-tests.js +71 -1
  14. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  15. package/dist/scripts/doctor-tests.js +37 -1
  16. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  17. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  18. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  19. package/dist/scripts/dsh-llm-tests.js +437 -0
  20. package/dist/scripts/g5-guard-tests.js +20 -1
  21. package/dist/scripts/g5-guard.js +5 -1
  22. package/dist/scripts/m3-cohort-tests.js +714 -0
  23. package/dist/scripts/m3-cohort.js +310 -0
  24. package/dist/scripts/nightly-evidence-tests.js +44 -2
  25. package/dist/scripts/nightly-evidence.js +21 -4
  26. package/dist/scripts/outcome-projection-tests.js +427 -0
  27. package/dist/scripts/persona-sim-tests.js +603 -0
  28. package/dist/scripts/persona-sim.js +1081 -0
  29. package/dist/scripts/replay-async.js +2 -0
  30. package/dist/scripts/replay.js +2 -0
  31. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  32. package/dist/scripts/session-zone-crash.js +49 -0
  33. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  34. package/dist/scripts/session-zone-observation-tests.js +492 -0
  35. package/dist/scripts/session-zone-observe.js +135 -0
  36. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  37. package/dist/scripts/session-zones-tests.js +17 -1
  38. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  39. package/dist/scripts/state-cli.js +16 -0
  40. package/dist/scripts/time-eval-tests.js +2 -0
  41. package/dist/scripts/unified-tests.js +37 -1
  42. package/dist/scripts/z3-race-tests.js +6 -1
  43. package/dist/src/city-scenario-tier.js +378 -0
  44. package/dist/src/dsh-llm.js +84 -14
  45. package/dist/src/index.js +160 -1
  46. package/dist/src/loop.js +16 -5
  47. package/dist/src/m3-cohort.js +1577 -0
  48. package/dist/src/outcome-projection.js +746 -0
  49. package/dist/src/session-zone-ledger.js +312 -0
  50. package/dist/src/session-zone-observation.js +354 -0
  51. package/dist/src/session-zone-wiring.js +658 -0
  52. package/dist/src/session-zones.js +7 -4
  53. package/dist/src/state-ledger.js +293 -40
  54. package/dist/src/unified.js +69 -5
  55. package/extension/manifest.json +2 -2
  56. package/package.json +1 -1
  57. package/ts/capabilities/anything.ts +27 -0
  58. package/ts/scripts/state-cli.ts +18 -0
  59. package/ts/src/contracts.ts +4 -0
  60. package/ts/src/dsh-llm.ts +111 -9
  61. package/ts/src/index.ts +175 -2
  62. package/ts/src/loop.ts +15 -5
  63. package/ts/src/state-ledger.ts +294 -40
  64. package/ts/src/unified.ts +91 -18
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Simulated-trigger drill and RE-BASELINE for the dormant dsh SDK descendant
3
+ * cleanup tracker (issue #422, docs/architecture.md section 10; scoped #271
4
+ * acceptance preserved).
5
+ *
6
+ * HONESTY CONTRACT — read before trusting any line of output:
7
+ * Every result below is `simulated_trigger_drill` / `synthetic`. Driving the
8
+ * SDK's direct transport against a FIXTURE `dshBin` is NOT a real product
9
+ * callsite, NOT a recorded architecture/founder decision on upstream
10
+ * ownership, and NOT a direct-connect activation. Issue #422's trigger is "a
11
+ * concrete proposal to put the dsh SDK direct-connect form into a product
12
+ * runtime, followed by the recorded decision"; this file supplies neither, so
13
+ * #422 stays open and nothing is activated, forked or upgraded here.
14
+ *
15
+ * What the drill DOES supply is a re-baseline: the dsh family moved
16
+ * 0.1.5-rc.1 -> 0.2.0-rc.2 on 2026-10-02, so the installed version's actual
17
+ * behaviour is measured and PINNED. Every pinned assertion carries an
18
+ * "if this flips, #422's premise changed" message.
19
+ *
20
+ * Sections:
21
+ * S1 static evidence: the installed SDK's spawn options and dispose ladder
22
+ * S2 SDK arm, unresponsive runtime: start -> real close() dispose ladder
23
+ * (shutdown timeout -> stdin EOF -> SIGTERM -> SIGKILL) -> who survives
24
+ * S3 SDK arm, cooperative runtime: the leader answers `shutdown` and exits
25
+ * on stdin EOF -> who survives
26
+ * S4 high-level arm: DeepSeekHarness.start()/close() over the same fixture
27
+ * S5 missing-binary semantics (an explicit #422 acceptance item)
28
+ * S6 no unsafe retry: close() is idempotent and terminal, start() after
29
+ * close is refused, and no second runtime process appears
30
+ * S7 CONTROL ARM: the GoTry CLI launcher's tested process-group cleanup
31
+ * (bin/gotry-process-liveness.js) over the SAME fixture leader
32
+ *
33
+ * Leak discipline: every pid this drill learns about is force-reaped in the
34
+ * final `finally`, including on assertion failure, and the suite asserts at the
35
+ * end that nothing it spawned is still alive.
36
+ *
37
+ * No vendor edits, no upstream proposals, no network, no model, no credentials.
38
+ *
39
+ * Run: cd ts && npx tsx scripts/drill-sdk-descendant-cleanup-tests.ts
40
+ */
41
+ import { execFileSync } from 'node:child_process';
42
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
43
+ import { createRequire } from 'node:module';
44
+ import { tmpdir } from 'node:os';
45
+ import { dirname, join, resolve } from 'node:path';
46
+ import { HarnessClient, DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client';
47
+ import { spawnOwnedChild, signalOwnedChild, isOwnedProcessGroupEmpty, terminateOwnedChild } from '../../bin/gotry-process-liveness.js';
48
+ const DRILL_LABEL = 'simulated_trigger_drill';
49
+ const TS_DIR = resolve(import.meta.dirname, '..');
50
+ const REPO_ROOT = resolve(TS_DIR, '..');
51
+ const SUITE_BUDGET_MS = 90_000;
52
+ const FLIP = 'IF THIS FLIPS, #422 premise changed';
53
+ const require_ = createRequire(import.meta.url);
54
+ const SDK_MANIFEST_PATH = require_.resolve('@deepseek-ai/dsh-sdk-client/package.json');
55
+ const SDK_VERSION = require_(SDK_MANIFEST_PATH).version;
56
+ /**
57
+ * Read the source from the SAME package the version came from. There are two
58
+ * installed copies (`node_modules` and `ts/node_modules`); deriving the path
59
+ * from the resolved manifest makes the version assertion and the static-source
60
+ * assertions describe one package instead of two.
61
+ */
62
+ const SDK_SOURCE_PATH = join(dirname(SDK_MANIFEST_PATH), 'lib', 'index.js');
63
+ const EXPECTED_SDK_VERSION = '0.2.0-rc.2';
64
+ let pass = 0;
65
+ const failures = [];
66
+ const observations = [];
67
+ /** Every pid the drill ever learns about; all are force-reaped at the end. */
68
+ const trackedPids = new Set();
69
+ function ok(condition, message) {
70
+ if (condition) {
71
+ pass += 1;
72
+ return;
73
+ }
74
+ failures.push(message);
75
+ console.error(` FAIL - ${message}`);
76
+ }
77
+ function eq(actual, expected, message) {
78
+ ok(actual === expected, `${message} (actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)})`);
79
+ }
80
+ function observe(line) {
81
+ observations.push(line);
82
+ console.log(` observed: ${line}`);
83
+ }
84
+ function pidAlive(pid) {
85
+ if (!pid)
86
+ return false;
87
+ try {
88
+ process.kill(pid, 0);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ /** macOS-compatible process-table facts for a pid set (ps -o pid,ppid,pgid). */
96
+ function psFacts(pids) {
97
+ const live = pids.filter((p) => pidAlive(p));
98
+ if (live.length === 0)
99
+ return [];
100
+ try {
101
+ const out = execFileSync('ps', ['-o', 'pid=,ppid=,pgid=', '-p', live.join(',')], { encoding: 'utf8' });
102
+ return out.split('\n').map((l) => l.trim()).filter(Boolean).map((line) => {
103
+ const [pid, ppid, pgid] = line.split(/\s+/).map(Number);
104
+ return { pid: pid, ppid: ppid, pgid: pgid };
105
+ });
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ /**
112
+ * Is this pid still one of OUR processes? Guards the reap path against pid
113
+ * reuse: a recorded pid can be recycled by the OS for an unrelated process
114
+ * between the observation and the kill, and SIGKILLing that would be a real
115
+ * side effect outside the drill's blast radius.
116
+ *
117
+ * Ownership evidence, either is enough: the command line still names one of
118
+ * this drill's fixture files (they all live under the drill's own temp root),
119
+ * or the parent chain reaches this drill process.
120
+ */
121
+ function isDrillOwned(pid) {
122
+ try {
123
+ const argv = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
124
+ if (argv.includes('fixture-dsh-runtime') || argv.includes('fixture-descendant') || argv.includes('fixture-grandchild'))
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ // Walk the parent chain, bounded, looking for this process.
131
+ let current = pid;
132
+ for (let hop = 0; hop < 8; hop += 1) {
133
+ const facts = psFacts([current]);
134
+ const parent = facts[0]?.ppid;
135
+ if (parent === undefined || parent <= 1)
136
+ return false;
137
+ if (parent === process.pid)
138
+ return true;
139
+ current = parent;
140
+ }
141
+ return false;
142
+ }
143
+ /** SIGKILL one recorded pid, but only while it is still ours. */
144
+ function reapIfOwned(pid) {
145
+ if (!pid || !pidAlive(pid))
146
+ return;
147
+ if (!isDrillOwned(pid))
148
+ return;
149
+ try {
150
+ process.kill(pid, 'SIGKILL');
151
+ }
152
+ catch { /* already gone */ }
153
+ }
154
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
155
+ async function waitFor(predicate, budgetMs) {
156
+ const deadline = Date.now() + budgetMs;
157
+ while (Date.now() < deadline) {
158
+ if (predicate())
159
+ return true;
160
+ await sleep(10);
161
+ }
162
+ return predicate();
163
+ }
164
+ function readPid(path) {
165
+ try {
166
+ return Number(readFileSync(path, 'utf8').trim()) || null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ function trackIfAlive(...pids) {
173
+ for (const pid of pids)
174
+ if (pid)
175
+ trackedPids.add(pid);
176
+ }
177
+ // ---- fixtures ---------------------------------------------------------------
178
+ /**
179
+ * The grandchild: ignores SIGTERM and lives on a timer. It is reachable only by
180
+ * the process tree, never by the SDK's direct-child handle.
181
+ */
182
+ const GRANDCHILD_FIXTURE = `
183
+ import { writeFileSync } from 'node:fs'
184
+ process.on('SIGTERM', () => {})
185
+ process.on('SIGINT', () => {})
186
+ writeFileSync(process.env.GRANDCHILD_PID_FILE, String(process.pid))
187
+ setInterval(() => {}, 60_000)
188
+ `;
189
+ /** The TERM-ignoring descendant, which itself spawns the grandchild. */
190
+ const DESCENDANT_FIXTURE = `
191
+ import { spawn } from 'node:child_process'
192
+ import { writeFileSync } from 'node:fs'
193
+ process.on('SIGTERM', () => {})
194
+ process.on('SIGINT', () => {})
195
+ spawn(process.execPath, [process.env.GRANDCHILD_FIXTURE], { stdio: 'ignore', env: process.env })
196
+ writeFileSync(process.env.DESCENDANT_PID_FILE, String(process.pid))
197
+ setInterval(() => {}, 60_000)
198
+ `;
199
+ /**
200
+ * The fixture dsh runtime (`dshBin`). It is what the SDK's direct transport
201
+ * spawns and owns. It immediately creates a TERM-resistant descendant subtree,
202
+ * so a direct-child-only teardown cannot pass by accident.
203
+ *
204
+ * MODE=unresponsive: never answers JSON-RPC and ignores SIGTERM, which forces
205
+ * the SDK ladder all the way to SIGKILL.
206
+ * MODE=cooperative: answers `initialize` and `shutdown`, and exits 0 on stdin
207
+ * EOF, so the cooperative tier of the ladder is the one that runs.
208
+ */
209
+ const LEADER_FIXTURE = `
210
+ import { spawn } from 'node:child_process'
211
+ import { writeFileSync } from 'node:fs'
212
+
213
+ const mode = process.env.LEADER_MODE
214
+ if (mode === 'unresponsive') {
215
+ process.on('SIGTERM', () => {})
216
+ process.on('SIGINT', () => {})
217
+ }
218
+
219
+ spawn(process.execPath, [process.env.DESCENDANT_FIXTURE], { stdio: 'ignore', env: process.env })
220
+ writeFileSync(process.env.LEADER_PID_FILE, String(process.pid))
221
+
222
+ if (mode === 'cooperative') {
223
+ let buffer = ''
224
+ process.stdin.setEncoding('utf8')
225
+ process.stdin.on('data', (chunk) => {
226
+ buffer += chunk
227
+ let index = buffer.indexOf('\\n')
228
+ while (index >= 0) {
229
+ const line = buffer.slice(0, index).trim()
230
+ buffer = buffer.slice(index + 1)
231
+ if (line) {
232
+ let message
233
+ try { message = JSON.parse(line) } catch { message = null }
234
+ if (message && message.id !== undefined) {
235
+ const result = message.method === 'initialize'
236
+ ? { serverInfo: { name: 'gotry-drill-fixture-runtime', version: '0.0.0-drill' } }
237
+ : {}
238
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\\n')
239
+ }
240
+ }
241
+ index = buffer.indexOf('\\n')
242
+ }
243
+ })
244
+ process.stdin.on('end', () => { process.exit(0) })
245
+ }
246
+
247
+ setInterval(() => {}, 60_000)
248
+ `;
249
+ function writeFixtures(root, mode) {
250
+ mkdirSync(root, { recursive: true });
251
+ const leader = join(root, 'fixture-dsh-runtime.mjs');
252
+ const descendantFixture = join(root, 'fixture-descendant.mjs');
253
+ const grandchildFixture = join(root, 'fixture-grandchild.mjs');
254
+ const leaderPidFile = join(root, 'leader.pid');
255
+ const descendantPidFile = join(root, 'descendant.pid');
256
+ const grandchildPidFile = join(root, 'grandchild.pid');
257
+ writeFileSync(leader, LEADER_FIXTURE);
258
+ writeFileSync(descendantFixture, DESCENDANT_FIXTURE);
259
+ writeFileSync(grandchildFixture, GRANDCHILD_FIXTURE);
260
+ return {
261
+ leader,
262
+ descendantFixture,
263
+ grandchildFixture,
264
+ leaderPidFile,
265
+ descendantPidFile,
266
+ grandchildPidFile,
267
+ env: {
268
+ PATH: process.env.PATH ?? '',
269
+ LEADER_MODE: mode,
270
+ DESCENDANT_FIXTURE: descendantFixture,
271
+ GRANDCHILD_FIXTURE: grandchildFixture,
272
+ LEADER_PID_FILE: leaderPidFile,
273
+ DESCENDANT_PID_FILE: descendantPidFile,
274
+ GRANDCHILD_PID_FILE: grandchildPidFile,
275
+ },
276
+ };
277
+ }
278
+ async function awaitTree(fixtures, budgetMs = 8_000) {
279
+ await waitFor(() => readPid(fixtures.leaderPidFile) !== null && readPid(fixtures.descendantPidFile) !== null && readPid(fixtures.grandchildPidFile) !== null, budgetMs);
280
+ const tree = {
281
+ leader: readPid(fixtures.leaderPidFile),
282
+ descendant: readPid(fixtures.descendantPidFile),
283
+ grandchild: readPid(fixtures.grandchildPidFile),
284
+ };
285
+ trackIfAlive(tree.leader, tree.descendant, tree.grandchild);
286
+ return tree;
287
+ }
288
+ // Generous diagnostic backstop, not a performance assertion. `process.exit`
289
+ // skips `finally`, so the deadline path reaps the fixture tree itself —
290
+ // otherwise it would strand exactly the TERM-ignoring descendants this suite
291
+ // creates on purpose.
292
+ const suiteDeadline = setTimeout(() => {
293
+ console.error(`DRILL BUDGET EXCEEDED (${SUITE_BUDGET_MS}ms) — reaping fixture tree before exit`);
294
+ for (const pid of trackedPids)
295
+ reapIfOwned(pid);
296
+ try {
297
+ rmSync(workRoot, { recursive: true, force: true });
298
+ }
299
+ catch { /* best effort */ }
300
+ process.exit(1);
301
+ }, SUITE_BUDGET_MS);
302
+ suiteDeadline.unref();
303
+ const workRoot = realpathSync(mkdtempSync(join(tmpdir(), 'gotry-drill-422-')));
304
+ try {
305
+ console.log(`-- S1 static evidence from the installed SDK [${DRILL_LABEL}]`);
306
+ {
307
+ eq(SDK_VERSION, EXPECTED_SDK_VERSION, `S1-1 the installed @deepseek-ai/dsh-sdk-client is the re-baseline target ${EXPECTED_SDK_VERSION}. ${FLIP}: a different version invalidates every pinned result below`);
308
+ console.log(` observed: S1 version and source both read from ${SDK_MANIFEST_PATH.replace(REPO_ROOT, '<repo>')}`);
309
+ const sdkSource = readFileSync(SDK_SOURCE_PATH, 'utf8');
310
+ const spawnBlock = sdkSource.slice(sdkSource.indexOf('const child = spawn(this.runtime.command'), sdkSource.indexOf('this.child = child;'));
311
+ ok(spawnBlock.length > 0, 'S1-2 located the transport spawn call in the installed SDK');
312
+ eq(/detached/.test(spawnBlock), false, `S1-3 PINNED: the SDK transport spawn passes no \`detached\` option, so the runtime is NOT a process-group leader. ${FLIP}`);
313
+ eq(/setsid|process\.kill\(-/.test(sdkSource), false, `S1-4 PINNED: the SDK never signals a process group (no \`process.kill(-pid)\`, no setsid). ${FLIP}`);
314
+ ok(/child\.kill\("SIGTERM"\)/.test(sdkSource) && /child\.kill\("SIGKILL"\)/.test(sdkSource), 'S1-5 the dispose ladder signals the DIRECT child only (SIGTERM then SIGKILL)');
315
+ ok(sdkSource.includes('documented exception for SDK-managed transports'), 'S1-6 the SDK documents that it runs outside any harness context and spawns directly');
316
+ observe(`S1 SDK ${SDK_VERSION}: direct spawn with stdio pipes, no detached, no process-group signalling; teardown ladder = stdin EOF -> SIGTERM -> SIGKILL on the direct child handle only`);
317
+ }
318
+ console.log('-- S2 SDK arm, unresponsive TERM-ignoring runtime: the real close() dispose path');
319
+ {
320
+ const root = join(workRoot, 'sdk-unresponsive');
321
+ const fixtures = writeFixtures(root, 'unresponsive');
322
+ const client = new HarnessClient({
323
+ profile: 'sdk-minimal',
324
+ dshBin: fixtures.leader,
325
+ dshHome: join(root, 'dsh-home'),
326
+ processCwd: root,
327
+ env: fixtures.env,
328
+ initializeTimeoutMs: 300,
329
+ shutdownTimeoutMs: 200,
330
+ disposeEofGraceMs: 300,
331
+ disposeGraceMs: 400,
332
+ });
333
+ let tree = { leader: null, descendant: null, grandchild: null };
334
+ let closeMs = -1;
335
+ try {
336
+ client.start();
337
+ tree = await awaitTree(fixtures);
338
+ ok(tree.leader !== null && tree.descendant !== null && tree.grandchild !== null, `S2-1 the fixture runtime really built a 3-deep tree (${JSON.stringify(tree)})`);
339
+ eq(pidAlive(tree.descendant), true, 'S2-2 the TERM-ignoring descendant is alive before teardown');
340
+ eq(pidAlive(tree.grandchild), true, 'S2-3 the grandchild is alive before teardown');
341
+ const before = psFacts([tree.leader, tree.descendant, tree.grandchild].filter((p) => p !== null));
342
+ const leaderFact = before.find((f) => f.pid === tree.leader);
343
+ const descendantFact = before.find((f) => f.pid === tree.descendant);
344
+ ok(leaderFact !== undefined, 'S2-4 ps reported the leader');
345
+ if (leaderFact && descendantFact) {
346
+ eq(descendantFact.ppid, leaderFact.pid, 'S2-5 the descendant really is a child of the SDK-owned leader');
347
+ eq(descendantFact.pgid, leaderFact.pgid, `S2-6 PINNED: leader and descendant share this process group (the SDK created no private group). ${FLIP}`);
348
+ eq(leaderFact.pgid === process.pid || leaderFact.pgid !== leaderFact.pid, true, `S2-7 PINNED: the leader is not its own group leader. ${FLIP}`);
349
+ observe(`S2 process table before teardown: ${JSON.stringify(before)} (drill pid ${process.pid}, pgid ${psFacts([process.pid])[0]?.pgid ?? 'unknown'})`);
350
+ }
351
+ const startedAt = Date.now();
352
+ await client.close();
353
+ closeMs = Date.now() - startedAt;
354
+ eq(await waitFor(() => !pidAlive(tree.leader), 2_000), true, 'S2-8 the SDK dispose ladder really reaped the direct child it owns');
355
+ ok(closeMs < 4_000, `S2-9 close() is bounded: ${closeMs}ms against a runtime that ignores SIGTERM and answers nothing`);
356
+ // THE #422 QUESTION, answered against 0.2.0-rc.2.
357
+ const descendantSurvived = pidAlive(tree.descendant);
358
+ const grandchildSurvived = pidAlive(tree.grandchild);
359
+ eq(descendantSurvived, true, `S2-10 PINNED RE-BASELINE: on SDK ${SDK_VERSION} the TERM-ignoring DESCENDANT SURVIVES a full SDK dispose of its leader. ${FLIP} — if this reads false, descendant cleanup was fixed upstream and #422 can be closed with evidence`);
360
+ eq(grandchildSurvived, true, `S2-11 PINNED RE-BASELINE: the GRANDCHILD also survives. ${FLIP}`);
361
+ const after = psFacts([tree.descendant, tree.grandchild].filter((p) => p !== null));
362
+ observe(`S2 after SDK close() (${closeMs}ms): leader ${tree.leader} gone=${!pidAlive(tree.leader)}, descendant ${tree.descendant} alive=${descendantSurvived}, grandchild ${tree.grandchild} alive=${grandchildSurvived}; survivors reparented to ${JSON.stringify(after.map((f) => ({ pid: f.pid, ppid: f.ppid })))}`);
363
+ observe(`S2 #422 GAP CONFIRMED on ${SDK_VERSION} (synthetic fixture, not a product callsite): the SDK-owned leader is reaped, its descendants are not. The CLI launcher's guarantee (S7) is NOT inherited by the direct transport.`);
364
+ }
365
+ finally {
366
+ for (const pid of [tree.descendant, tree.grandchild]) {
367
+ reapIfOwned(pid);
368
+ }
369
+ await client.close().catch(() => { });
370
+ }
371
+ }
372
+ console.log('-- S3 SDK arm, cooperative runtime: shutdown answered, exit on stdin EOF');
373
+ {
374
+ const root = join(workRoot, 'sdk-cooperative');
375
+ const fixtures = writeFixtures(root, 'cooperative');
376
+ const client = new HarnessClient({
377
+ profile: 'sdk-minimal',
378
+ dshBin: fixtures.leader,
379
+ dshHome: join(root, 'dsh-home'),
380
+ processCwd: root,
381
+ env: fixtures.env,
382
+ initializeTimeoutMs: 2_000,
383
+ shutdownTimeoutMs: 1_000,
384
+ disposeEofGraceMs: 1_500,
385
+ disposeGraceMs: 500,
386
+ });
387
+ let tree = { leader: null, descendant: null, grandchild: null };
388
+ try {
389
+ client.start();
390
+ const identity = await client.initialize({ cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-flash' });
391
+ eq(identity.serverInfo.name, 'gotry-drill-fixture-runtime', 'S3-1 the cooperative fixture completed a real JSON-RPC initialize handshake');
392
+ tree = await awaitTree(fixtures);
393
+ ok(tree.descendant !== null && tree.grandchild !== null, `S3-2 the cooperative runtime also built the descendant subtree (${JSON.stringify(tree)})`);
394
+ const startedAt = Date.now();
395
+ await client.close();
396
+ const closeMs = Date.now() - startedAt;
397
+ eq(await waitFor(() => !pidAlive(tree.leader), 2_000), true, 'S3-3 the cooperative leader exited on the shutdown/stdin-EOF tier');
398
+ ok(closeMs < 2_500, `S3-4 the cooperative teardown is faster than the forced one: ${closeMs}ms`);
399
+ eq(pidAlive(tree.descendant), true, `S3-5 PINNED: a COOPERATIVE clean exit also leaves the descendant running — the gap is ownership, not signal strength. ${FLIP}`);
400
+ eq(pidAlive(tree.grandchild), true, `S3-6 PINNED: and the grandchild. ${FLIP}`);
401
+ observe(`S3 cooperative teardown ${closeMs}ms: leader exited cleanly, descendant alive=${pidAlive(tree.descendant)}, grandchild alive=${pidAlive(tree.grandchild)}`);
402
+ }
403
+ finally {
404
+ for (const pid of [tree.descendant, tree.grandchild]) {
405
+ reapIfOwned(pid);
406
+ }
407
+ await client.close().catch(() => { });
408
+ }
409
+ }
410
+ console.log('-- S4 high-level arm: DeepSeekHarness start()/close() over the same fixture');
411
+ {
412
+ const root = join(workRoot, 'sdk-harness');
413
+ const fixtures = writeFixtures(root, 'cooperative');
414
+ const harness = new DeepSeekHarness({
415
+ profile: 'sdk-minimal',
416
+ dshBin: fixtures.leader,
417
+ dshHome: join(root, 'dsh-home'),
418
+ cwd: root,
419
+ processCwd: root,
420
+ env: fixtures.env,
421
+ initializeTimeoutMs: 2_000,
422
+ shutdownTimeoutMs: 1_000,
423
+ disposeEofGraceMs: 1_500,
424
+ disposeGraceMs: 500,
425
+ });
426
+ let tree = { leader: null, descendant: null, grandchild: null };
427
+ try {
428
+ await harness.start();
429
+ tree = await awaitTree(fixtures);
430
+ ok(tree.leader !== null, `S4-1 DeepSeekHarness.start() booted the fixture runtime (${JSON.stringify(tree)})`);
431
+ await harness.close();
432
+ eq(await waitFor(() => !pidAlive(tree.leader), 2_000), true, 'S4-2 DeepSeekHarness.close() reaped the direct child');
433
+ eq(pidAlive(tree.descendant), true, `S4-3 PINNED: the high-level API inherits the same descendant gap as the low-level client. ${FLIP}`);
434
+ observe(`S4 DeepSeekHarness: leader gone=${!pidAlive(tree.leader)}, descendant alive=${pidAlive(tree.descendant)} — the gap is in the transport, not in the API layer`);
435
+ }
436
+ finally {
437
+ for (const pid of [tree.descendant, tree.grandchild]) {
438
+ reapIfOwned(pid);
439
+ }
440
+ await harness.close().catch(() => { });
441
+ }
442
+ }
443
+ console.log('-- S5 missing-binary semantics (explicit #422 acceptance item)');
444
+ {
445
+ const root = join(workRoot, 'sdk-missing-bin');
446
+ mkdirSync(root, { recursive: true });
447
+ const missing = join(root, 'does-not-exist-dsh-runtime.mjs');
448
+ const client = new HarnessClient({
449
+ profile: 'sdk-minimal',
450
+ dshBin: missing,
451
+ dshHome: join(root, 'dsh-home'),
452
+ processCwd: root,
453
+ env: { PATH: process.env.PATH ?? '' },
454
+ initializeTimeoutMs: 1_000,
455
+ shutdownTimeoutMs: 200,
456
+ disposeEofGraceMs: 200,
457
+ disposeGraceMs: 300,
458
+ });
459
+ let errorName = 'none';
460
+ let errorMessage = '';
461
+ const startedAt = Date.now();
462
+ try {
463
+ client.start();
464
+ await client.initialize({ cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-flash' });
465
+ }
466
+ catch (error) {
467
+ errorName = error instanceof Error ? error.name : 'unknown';
468
+ errorMessage = error instanceof Error ? error.message : String(error);
469
+ }
470
+ const failMs = Date.now() - startedAt;
471
+ await client.close();
472
+ eq(errorName, 'TransportClosedError', `S5-1 PINNED: a missing dshBin surfaces as TransportClosedError, not a hang and not a silent success. ${FLIP}`);
473
+ ok(failMs < 3_000, `S5-2 the missing-binary failure is bounded: ${failMs}ms`);
474
+ // The precise semantics #422 asks to document: a missing binary is NOT an
475
+ // ENOENT spawn error. Because `dshBin` is launched as `node <path>`, node
476
+ // itself exits 1 and the SDK reports a transport closure; the real cause
477
+ // survives only in the retained stderr tail.
478
+ ok(/JSON-RPC input closed/.test(errorMessage), `S5-3 PINNED: the failure is reported as a transport closure, not as a spawn ENOENT. ${FLIP} (got: ${errorMessage.slice(0, 120)})`);
479
+ ok(/exit code: 1/.test(errorMessage), `S5-4 the report carries the runtime exit code (got: ${errorMessage.slice(0, 160)})`);
480
+ ok(/Cannot find module/.test(errorMessage), `S5-5 the real cause is recoverable ONLY from the retained stderr tail — a caller that logs just error.name loses it (got: ${errorMessage.slice(0, 200)})`);
481
+ observe(`S5 missing dshBin: ${errorName} after ${failMs}ms, reported as "${errorMessage.split('\n')[0]?.slice(0, 120)}" with exit code 1; the actual "Cannot find module" cause appears only in the stderr tail, never in the error class — a direct-connect product callsite must retain the tail or it will mis-diagnose a packaging failure as a protocol failure`);
482
+ }
483
+ console.log('-- S6 no unsafe retry: close() is terminal and spawns nothing new');
484
+ {
485
+ const root = join(workRoot, 'sdk-no-retry');
486
+ const fixtures = writeFixtures(root, 'cooperative');
487
+ const client = new HarnessClient({
488
+ profile: 'sdk-minimal',
489
+ dshBin: fixtures.leader,
490
+ dshHome: join(root, 'dsh-home'),
491
+ processCwd: root,
492
+ env: fixtures.env,
493
+ initializeTimeoutMs: 2_000,
494
+ shutdownTimeoutMs: 500,
495
+ disposeEofGraceMs: 800,
496
+ disposeGraceMs: 400,
497
+ });
498
+ let tree = { leader: null, descendant: null, grandchild: null };
499
+ try {
500
+ client.start();
501
+ tree = await awaitTree(fixtures);
502
+ const firstLeader = tree.leader;
503
+ await client.close();
504
+ await client.close();
505
+ eq(pidAlive(firstLeader), false, 'S6-1 the first (and only) runtime is gone after close()');
506
+ let reuseError = 'none';
507
+ try {
508
+ client.start();
509
+ }
510
+ catch (error) {
511
+ reuseError = error instanceof Error ? error.name : 'unknown';
512
+ }
513
+ eq(reuseError, 'TransportClosedError', `S6-2 PINNED: start() after close() is refused — the SDK never silently respawns a runtime. ${FLIP}`);
514
+ const leaderAfter = readPid(fixtures.leaderPidFile);
515
+ eq(leaderAfter, firstLeader, 'S6-3 no second runtime process was ever launched (the pid file never changed)');
516
+ observe(`S6 close() idempotent and terminal; reuse rejected with ${reuseError}; exactly one runtime pid (${firstLeader}) over the whole lifecycle`);
517
+ }
518
+ finally {
519
+ for (const pid of [tree.descendant, tree.grandchild]) {
520
+ reapIfOwned(pid);
521
+ }
522
+ await client.close().catch(() => { });
523
+ }
524
+ }
525
+ console.log('-- S7 CONTROL ARM: the GoTry CLI launcher process-group cleanup, same fixture');
526
+ {
527
+ const root = join(workRoot, 'cli-control');
528
+ const fixtures = writeFixtures(root, 'unresponsive');
529
+ const { child, groupPid } = spawnOwnedChild(process.execPath, [fixtures.leader], {
530
+ cwd: root,
531
+ env: fixtures.env,
532
+ stdio: ['ignore', 'ignore', 'ignore'],
533
+ });
534
+ let tree = { leader: null, descendant: null, grandchild: null };
535
+ try {
536
+ tree = await awaitTree(fixtures);
537
+ ok(tree.leader !== null && tree.descendant !== null && tree.grandchild !== null, `S7-1 the control arm built the same 3-deep tree (${JSON.stringify(tree)})`);
538
+ const facts = psFacts([tree.leader, tree.descendant, tree.grandchild].filter((p) => p !== null));
539
+ const leaderFact = facts.find((f) => f.pid === tree.leader);
540
+ if (leaderFact) {
541
+ eq(leaderFact.pgid, leaderFact.pid, 'S7-2 the CLI launcher made the leader its OWN process-group leader (this is the difference from the SDK transport)');
542
+ eq(facts.every((f) => f.pgid === leaderFact.pgid), true, `S7-3 the whole subtree shares that private group (${JSON.stringify(facts)})`);
543
+ }
544
+ eq(isOwnedProcessGroupEmpty(groupPid), false, 'S7-4 the group is observably non-empty before cleanup');
545
+ const startedAt = Date.now();
546
+ await terminateOwnedChild({ child, groupPid, signal: 'SIGTERM', termGraceMs: 1_200, killWaitMs: 800 });
547
+ const cleanupMs = Date.now() - startedAt;
548
+ eq(await waitFor(() => !pidAlive(tree.leader) && !pidAlive(tree.descendant) && !pidAlive(tree.grandchild), 2_000), true, 'S7-5 CONTROL: bounded group cleanup reaped the leader AND the TERM-ignoring descendant AND the grandchild');
549
+ eq(isOwnedProcessGroupEmpty(groupPid), true, 'S7-6 CONTROL: the private process group is observably empty');
550
+ ok(cleanupMs < 4_000, `S7-7 CONTROL: cleanup is bounded: ${cleanupMs}ms`);
551
+ observe(`S7 CONTROL ARM (bin/gotry-process-liveness.js): private group cleanup in ${cleanupMs}ms left zero survivors — the guarantee the SDK direct transport does not provide`);
552
+ }
553
+ finally {
554
+ signalOwnedChild(child, 'SIGKILL', groupPid);
555
+ for (const pid of [tree.leader, tree.descendant, tree.grandchild]) {
556
+ reapIfOwned(pid);
557
+ }
558
+ }
559
+ }
560
+ }
561
+ finally {
562
+ clearTimeout(suiteDeadline);
563
+ // Leak discipline: reap every pid this drill ever learned about, twice,
564
+ // regardless of which assertion failed.
565
+ //
566
+ // Identity guard against pid reuse: only signal a pid whose process is still
567
+ // a descendant of this drill (its own pid, or a parent chain reaching this
568
+ // process), or whose argv still names one of this drill's fixtures. A pid the
569
+ // OS has since handed to an unrelated process must never be killed.
570
+ for (const round of [0, 1]) {
571
+ for (const pid of trackedPids) {
572
+ if (!pidAlive(pid))
573
+ continue;
574
+ if (!isDrillOwned(pid)) {
575
+ console.error(` note: pid ${pid} is alive but no longer owned by this drill (pid reuse) — not signalling it`);
576
+ continue;
577
+ }
578
+ try {
579
+ process.kill(pid, 'SIGKILL');
580
+ }
581
+ catch { /* already gone */ }
582
+ }
583
+ if (round === 0)
584
+ await sleep(120);
585
+ }
586
+ rmSync(workRoot, { recursive: true, force: true });
587
+ }
588
+ {
589
+ const survivors = [...trackedPids].filter((pid) => pidAlive(pid));
590
+ eq(survivors.length, 0, `Z every process this drill spawned is reaped (survivors=${JSON.stringify(psFacts(survivors))})`);
591
+ observe(`Z tracked ${trackedPids.size} fixture pids across all arms; ${survivors.length} survived the final reap`);
592
+ }
593
+ console.log(`\nDRILL #422 SDK DESCENDANT CLEANUP (${DRILL_LABEL}): ${pass} ok, ${failures.length} fail`);
594
+ console.log(`RE-BASELINE TARGET: @deepseek-ai/dsh-sdk-client ${SDK_VERSION}`);
595
+ console.log('OBSERVATIONS (facts, not acceptance):');
596
+ for (const line of observations)
597
+ console.log(` - ${line}`);
598
+ console.log('EVIDENCE BOUNDARY: a fixture dshBin and synthetic descendants only. No real product');
599
+ console.log('callsite, no recorded ownership decision, no direct-connect activation, no vendor edit.');
600
+ console.log('Issue #422 stays open; this drill documents behaviour and proposes nothing upstream.');
601
+ if (failures.length > 0) {
602
+ console.error(`\n${failures.length} FAILURE(S):`);
603
+ for (const failure of failures)
604
+ console.error(` - ${failure}`);
605
+ process.exit(1);
606
+ }