@hecer/yoke 1.3.0 → 1.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.
Files changed (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +33 -0
  4. package/README.md +108 -16
  5. package/TODOS.md +0 -3
  6. package/bench/README.md +55 -46
  7. package/bench/output-compaction.mjs +65 -0
  8. package/canon/loop/loop-spec.md +22 -8
  9. package/canon/manifest.yaml +1 -1
  10. package/dist/agents/contracts.js +50 -0
  11. package/dist/agents/process-incarnation.js +15 -0
  12. package/dist/agents/process-record.js +65 -0
  13. package/dist/agents/process-streams.js +40 -0
  14. package/dist/agents/process.js +177 -0
  15. package/dist/agents/providers.js +10 -7
  16. package/dist/agents/telemetry.js +62 -0
  17. package/dist/audit/command.js +13 -5
  18. package/dist/cli.js +55 -3
  19. package/dist/loop/candidate-boundaries.js +43 -0
  20. package/dist/loop/candidate-cleanup.js +98 -0
  21. package/dist/loop/candidate-contracts.js +1 -0
  22. package/dist/loop/candidate-selection.js +84 -0
  23. package/dist/loop/candidates.js +228 -0
  24. package/dist/loop/claim-lease.js +131 -0
  25. package/dist/loop/claims.js +177 -40
  26. package/dist/loop/cleanup.js +117 -15
  27. package/dist/loop/decision.js +31 -0
  28. package/dist/loop/dispatcher.js +334 -0
  29. package/dist/loop/git.js +6 -4
  30. package/dist/loop/loop.js +109 -16
  31. package/dist/loop/merge-queue.js +12 -6
  32. package/dist/loop/parallel-adapters.js +185 -0
  33. package/dist/loop/parallel-command.js +287 -0
  34. package/dist/loop/parallel.js +2 -4
  35. package/dist/loop/prd.js +4 -1
  36. package/dist/loop/reporter.js +86 -5
  37. package/dist/loop/run-command.js +216 -58
  38. package/dist/loop/runner.js +67 -32
  39. package/dist/loop/verify.js +65 -11
  40. package/dist/loop/watchdog.js +67 -8
  41. package/dist/loop/worker-cancellation.js +17 -0
  42. package/dist/loop/worker-cleanup.js +23 -0
  43. package/dist/loop/worker-contracts.js +1 -0
  44. package/dist/loop/worker.js +254 -0
  45. package/dist/output/artifact.js +63 -0
  46. package/dist/output/compact.js +192 -0
  47. package/dist/output/types.js +4 -0
  48. package/dist/quality/artifacts.js +59 -0
  49. package/dist/quality/candidate-comparison.js +130 -0
  50. package/dist/quality/command.js +316 -0
  51. package/dist/quality/loop.js +86 -0
  52. package/dist/quality/process-command.js +57 -0
  53. package/dist/quality/reference.js +187 -0
  54. package/dist/quality/repair.js +11 -0
  55. package/dist/quality/runner.js +66 -0
  56. package/dist/quality/types.js +60 -0
  57. package/dist/quality/verdict.js +142 -0
  58. package/dist/retrofit/config.js +26 -2
  59. package/dist/retrofit/gitignore.js +4 -0
  60. package/dist/review/command.js +27 -38
  61. package/dist/review/verdict.js +38 -7
  62. package/docs/MIGRATING-TO-1.4.md +70 -0
  63. package/docs/PUBLISHING.md +16 -2
  64. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  65. package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
  66. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  67. package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
  68. package/gemini-extension.json +1 -1
  69. package/package.json +4 -3
@@ -1,12 +1,13 @@
1
1
  import { isAcceptanceCriterion } from './prd.js';
2
2
  import { execFileSync, execSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { existsSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
4
5
  import { join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
7
  import { loadContext, formatForPrompt, contextDir } from '../context/context.js';
7
- import { buildProviderInvocation } from '../agents/providers.js';
8
- import { parseProviderTelemetry } from '../agents/telemetry.js';
9
- import { formatReviewContract, readReviewVerdict, reviewVerdictPath } from '../review/verdict.js';
8
+ import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
9
+ import { parseProviderResult, parseProviderTelemetry } from '../agents/telemetry.js';
10
+ import { formatReviewContract, formatReviewStdoutContract, parseReviewVerdict } from '../review/verdict.js';
10
11
  export function contextBlockFor(targetDir) {
11
12
  return formatForPrompt(loadContext(contextDir(targetDir)));
12
13
  }
@@ -40,7 +41,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
40
41
  lines.push('- Keep your final message to a few short sentences: what changed and what you verified.');
41
42
  return lines.join('\n');
42
43
  }
43
- export function buildReviewPrompt(story, context, verdictPath) {
44
+ export function buildReviewPrompt(story, context, verdictPath, provider) {
44
45
  const criteria = formatAcceptance(story);
45
46
  const lines = [
46
47
  'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
@@ -51,11 +52,10 @@ export function buildReviewPrompt(story, context, verdictPath) {
51
52
  lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve ONLY if every acceptance criterion is met and the change is sound.', 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), reject.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', verdictPath
52
53
  ? 'Do not modify project source, tests, configuration, or generated artifacts. The verdict file named below is the only permitted write. Do not commit.'
53
54
  : 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
54
- if (verdictPath)
55
- lines.push('', formatReviewContract(verdictPath));
55
+ lines.push('', verdictPath ? formatReviewContract(verdictPath, provider) : formatReviewStdoutContract(provider ?? 'claude'));
56
56
  return lines.join('\n');
57
57
  }
58
- export function buildStandaloneReviewPrompt(scope, focus, verdictPath) {
58
+ export function buildStandaloneReviewPrompt(scope, focus, verdictPath, provider) {
59
59
  const lines = [
60
60
  'You are an independent reviewer. You did NOT write this change.',
61
61
  `Review ${scope}. Run git yourself to see the diff (e.g. \`git diff\`, or \`git diff <base>..HEAD\`).`,
@@ -66,8 +66,7 @@ export function buildStandaloneReviewPrompt(scope, focus, verdictPath) {
66
66
  lines.push('', 'Approve by exiting 0 ONLY if the change is sound and complete.', 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', verdictPath
67
67
  ? 'Do not modify project source, tests, configuration, or generated artifacts. The verdict file named below is the only permitted write. Do not commit.'
68
68
  : 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
69
- if (verdictPath)
70
- lines.push('', formatReviewContract(verdictPath));
69
+ lines.push('', verdictPath ? formatReviewContract(verdictPath, provider) : formatReviewStdoutContract(provider ?? 'claude'));
71
70
  return lines.join('\n');
72
71
  }
73
72
  // Headless agents must run non-interactively: with plain `-p` the CLI denies
@@ -150,9 +149,13 @@ export function parseClaudeStreamUsage(lines) {
150
149
  const usage = result ?? { inputTokens: assistantIn, outputTokens: assistantOut };
151
150
  return model ? { ...usage, model } : usage;
152
151
  }
153
- function watchdogPath() {
154
- // runner.js and watchdog.js sit side by side (dist/loop/ at runtime, src/loop/ under tsx)
155
- return fileURLToPath(new URL('./watchdog.js', import.meta.url));
152
+ function watchdogArgs() {
153
+ const compiled = fileURLToPath(new URL('./watchdog.js', import.meta.url));
154
+ if (existsSync(compiled))
155
+ return [compiled];
156
+ const source = fileURLToPath(new URL('./watchdog.ts', import.meta.url));
157
+ const tsxLoader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href;
158
+ return ['--import', tsxLoader, source];
156
159
  }
157
160
  // When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
158
161
  // killed after idleTimeoutMs of no output. The prompt still flows via stdin.
@@ -161,14 +164,14 @@ function watchdogPath() {
161
164
  // killing by process-name/command-line pattern takes down other projects'
162
165
  // runners too. (Plain repos, e.g. `yoke review` outside a yoke project, get
163
166
  // no pid file rather than a littered .yoke dir.)
164
- export function buildWatchdogInvocation(inv, idleTimeoutMs) {
167
+ export function buildWatchdogInvocation(inv, idleTimeoutMs, ownershipRoot = inv.cwd) {
165
168
  if (idleTimeoutMs <= 0)
166
169
  return inv;
167
- const yokeDir = join(inv.cwd, '.yoke');
170
+ const yokeDir = join(ownershipRoot, '.yoke');
168
171
  const pidArgs = existsSync(yokeDir) ? [`--pid-file=${join(yokeDir, 'runner.pid')}`] : [];
169
172
  return {
170
173
  command: 'node',
171
- args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, ...pidArgs, '--', inv.command, ...inv.args],
174
+ args: [...watchdogArgs(), `--idle-ms=${idleTimeoutMs}`, ...pidArgs, '--', inv.command, ...inv.args],
172
175
  input: inv.input,
173
176
  cwd: inv.cwd,
174
177
  };
@@ -187,7 +190,7 @@ export function win32CommandString(command, args) {
187
190
  return [command, ...args].map(q).join(' ');
188
191
  }
189
192
  function runCli(inv) {
190
- if (process.platform === 'win32') {
193
+ if (process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node') {
191
194
  execSync(win32CommandString(inv.command, inv.args), {
192
195
  cwd: inv.cwd,
193
196
  input: inv.input,
@@ -208,7 +211,7 @@ function runCli(inv) {
208
211
  // through it. Throws on a non-zero exit; the error carries the partial stdout.
209
212
  function runCliCapture(inv) {
210
213
  const opts = { cwd: inv.cwd, input: inv.input, stdio: ['pipe', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
211
- return process.platform === 'win32'
214
+ return process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node'
212
215
  ? execSync(win32CommandString(inv.command, inv.args), opts)
213
216
  : execFileSync(inv.command, inv.args, opts);
214
217
  }
@@ -225,7 +228,7 @@ function runReviewCli(inv) {
225
228
  encoding: 'utf8',
226
229
  maxBuffer: 64 * 1024 * 1024,
227
230
  };
228
- if (process.platform === 'win32')
231
+ if (process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node')
229
232
  execSync(win32CommandString(inv.command, inv.args), opts);
230
233
  else
231
234
  execFileSync(inv.command, inv.args, opts);
@@ -296,6 +299,9 @@ export function runReviewAgent(inv) {
296
299
  return { success: false, summary: processFailureSummary(error) };
297
300
  }
298
301
  }
302
+ export function makeAsyncRunner(agent, opts = {}) {
303
+ return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), opts.process);
304
+ }
299
305
  export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
300
306
  // Claude always streams (see runnerInvocation) — capture the stream so tokens are
301
307
  // always reported; other agents keep inherit stdio. opts.tokenReport is now
@@ -331,33 +337,62 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
331
337
  };
332
338
  }
333
339
  export const claudeRunner = makeRunner('claude');
334
- export function makeReviewRunner(agent, idleTimeoutMs = 0, exec = runReviewCli) {
340
+ export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
335
341
  return (ctx) => {
336
- const verdictPath = reviewVerdictPath(ctx.targetDir);
337
- mkdirSync(join(ctx.targetDir, '.yoke'), { recursive: true });
338
- rmSync(verdictPath, { force: true });
339
- const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir), verdictPath), ctx.targetDir, 'safe');
342
+ const before = repositoryFingerprint(ctx.targetDir);
343
+ const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir), undefined, agent), ctx.targetDir, 'read-only');
340
344
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
341
345
  let processFailure;
346
+ let actualModel;
347
+ let output = '';
342
348
  try {
343
- exec(inv);
349
+ const result = exec?.(inv) ?? runCapturedAgent(agent, inv);
350
+ if (!result.success)
351
+ processFailure = result.summary;
352
+ actualModel = result.tokens?.model;
353
+ output = result.output;
354
+ if (!exec && !actualModel && !processFailure)
355
+ processFailure = 'review provider did not report its model';
344
356
  }
345
357
  catch (e) {
346
358
  processFailure = processFailureSummary(e);
347
359
  }
348
360
  try {
349
- const verdict = readReviewVerdict(verdictPath);
350
- if (processFailure)
351
- return { success: false, summary: `review process failed: ${processFailure}; verdict: ${verdict.summary}` };
361
+ if (repositoryFingerprint(ctx.targetDir) !== before)
362
+ throw new Error('reviewer modified the repository during a read-only review');
363
+ const expected = { provider: agent, ...(actualModel ? { model: actualModel } : {}) };
364
+ const verdict = parseReviewVerdict(parseProviderResult(agent, output), expected);
365
+ if (processFailure) {
366
+ return {
367
+ success: false,
368
+ summary: `review process failed: ${processFailure}; verdict: ${verdict.summary}`,
369
+ reviewOutcome: { kind: 'infrastructure', summary: processFailure },
370
+ };
371
+ }
352
372
  return verdict.approved
353
- ? { success: true, summary: `${agent} approved ${ctx.story.id}: ${verdict.summary}` }
354
- : { success: false, summary: `${agent} rejected ${ctx.story.id}: ${verdict.summary}` };
373
+ ? reviewResult(agent, ctx.story.id, verdict, { kind: 'approved', verdict })
374
+ : reviewResult(agent, ctx.story.id, verdict, { kind: 'rejected', verdict });
355
375
  }
356
376
  catch (e) {
357
- return { success: false, summary: `${processFailure ? `review process failed: ${processFailure}; ` : ''}${e.message}` };
377
+ const summary = `${processFailure ? `review process failed: ${processFailure}; ` : ''}${e.message}`;
378
+ return { success: false, summary, reviewOutcome: processFailure ? { kind: 'infrastructure', summary } : { kind: 'malformed', summary } };
358
379
  }
359
380
  };
360
381
  }
382
+ export function repositoryFingerprint(targetDir) {
383
+ try {
384
+ return execFileSync('git', ['diff', '--binary', 'HEAD'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
385
+ + execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
386
+ }
387
+ catch {
388
+ return '';
389
+ }
390
+ }
391
+ function reviewResult(agent, storyId, verdict, reviewOutcome) {
392
+ return verdict.approved
393
+ ? { success: true, summary: `${agent} approved ${storyId}: ${verdict.summary}`, reviewOutcome }
394
+ : { success: false, summary: `${agent} rejected ${storyId}: ${verdict.summary}`, reviewOutcome };
395
+ }
361
396
  // Probe whether the agent's CLI is on PATH (so the loop can refuse upfront with a
362
397
  // clear message instead of failing mid-run with spawn ENOENT). Never throws.
363
398
  export function isAgentAvailable(agent) {
@@ -1,26 +1,80 @@
1
1
  import { execSync } from 'node:child_process';
2
- // Runs a shell command in the target dir; passed = exit 0. execSync goes through the
3
- // shell, so `npm test` resolves npm.cmd on Windows. Output is captured (not streamed).
4
- export function commandVerifier(command) {
2
+ import { compactCommandOutput } from '../output/compact.js';
3
+ import { writeOutputArtifact } from '../output/artifact.js';
4
+ import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
5
+ function outputText(value) {
6
+ if (Buffer.isBuffer(value))
7
+ return value.toString('utf8');
8
+ return typeof value === 'string' ? value : '';
9
+ }
10
+ function labelledOutput(stdout, stderr) {
11
+ const sections = [];
12
+ if (stdout)
13
+ sections.push(`=== stdout ===\n${stdout}`);
14
+ if (stderr)
15
+ sections.push(`=== stderr ===\n${stderr}`);
16
+ return sections.join(stdout.endsWith('\n') ? '' : '\n');
17
+ }
18
+ function errorMessage(error) {
19
+ return (error instanceof Error ? error.message : String(error))
20
+ .replace(/[\u0000-\u001F\u007F]/gu, ' ')
21
+ .slice(0, 240);
22
+ }
23
+ const COMMAND_CAPTURE_BYTES = 16 * 1024 * 1024;
24
+ // Runs a shell command in the target dir; passed = exit 0. The explicit capture quota
25
+ // avoids Node's 1 MiB default without allowing noisy commands to consume unbounded memory.
26
+ export function commandVerifier(command, options = {}) {
5
27
  return (targetDir) => {
28
+ const phase = options.phase ?? 'verify';
6
29
  try {
7
- execSync(command, { cwd: targetDir, stdio: 'pipe', timeout: 600_000 });
8
- return { passed: true, summary: `verify passed: ${command}` };
30
+ execSync(command, {
31
+ cwd: targetDir,
32
+ stdio: 'pipe',
33
+ timeout: options.timeoutMs ?? 600_000,
34
+ maxBuffer: COMMAND_CAPTURE_BYTES,
35
+ });
36
+ return { passed: true, summary: `${phase} passed: ${command}` };
9
37
  }
10
38
  catch (e) {
11
39
  const err = e;
12
- const out = (err.stderr?.toString('utf8') ?? '') || (err.stdout?.toString('utf8') ?? '');
13
- const tail = out.trim().split('\n').slice(-5).join('\n');
14
- const suffix = err.signal === 'SIGTERM' ? ' (timed out)' : (tail ? `\n${tail}` : '');
15
- return { passed: false, summary: `verify failed: ${command}${suffix}` };
40
+ const captureExceeded = err.code === 'ENOBUFS';
41
+ const captured = labelledOutput(outputText(err.stdout), outputText(err.stderr));
42
+ const captureNotice = `[output truncated: exceeded ${COMMAND_CAPTURE_BYTES}-byte per-stream capture limit]`;
43
+ const raw = captureExceeded
44
+ ? `${captured}${captured ? '\n' : ''}=== capture ===\n${captureNotice}`
45
+ : captured;
46
+ const policy = options.policy ?? DEFAULT_OUTPUT_POLICY;
47
+ const compacted = compactCommandOutput(raw, { previewBytes: policy.previewBytes });
48
+ const timedOut = !captureExceeded && (err.signal === 'SIGTERM' || err.code === 'ETIMEDOUT');
49
+ const qualifier = captureExceeded
50
+ ? ' (capture limit exceeded)'
51
+ : timedOut ? ' (timed out)' : '';
52
+ const parts = [`${phase} failed: ${command}${qualifier}`];
53
+ if (compacted.preview)
54
+ parts.push(compacted.preview);
55
+ if (compacted.originalBytes > policy.artifactThresholdBytes) {
56
+ try {
57
+ const artifact = (options.artifactWriter ?? writeOutputArtifact)(targetDir, raw, {
58
+ phase,
59
+ storyId: process.env.YOKE_STORY,
60
+ });
61
+ parts.push(captureExceeded
62
+ ? artifact.marker.replace('[full output:', '[truncated output:')
63
+ : artifact.marker);
64
+ }
65
+ catch (error) {
66
+ parts.push(`[artifact unavailable: ${errorMessage(error)}]`);
67
+ }
68
+ }
69
+ return { passed: false, summary: parts.join('\n') };
16
70
  }
17
71
  };
18
72
  }
19
73
  /** Execute the proof commands attached to one acceptance criterion. */
20
- export function commandsVerifier(commands) {
74
+ export function commandsVerifier(commands, options = {}) {
21
75
  return (targetDir) => {
22
76
  for (const command of commands) {
23
- const result = commandVerifier(command)(targetDir);
77
+ const result = commandVerifier(command, options)(targetDir);
24
78
  if (!result.passed)
25
79
  return result;
26
80
  }
@@ -1,9 +1,11 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { constants } from 'node:os';
2
3
  import { writeFileSync, rmSync } from 'node:fs';
3
4
  import { pathToFileURL } from 'node:url';
5
+ import { processIncarnation } from '../agents/process-incarnation.js';
4
6
  // Kill one recorded process tree, platform-appropriately. Exported for
5
7
  // `yoke loop cleanup` (scoped reaping of recorded runner pids).
6
- export function killProcessTree(pid) {
8
+ export function killProcessTree(pid, force = true) {
7
9
  if (process.platform === 'win32') {
8
10
  try {
9
11
  spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
@@ -12,11 +14,51 @@ export function killProcessTree(pid) {
12
14
  }
13
15
  else {
14
16
  try {
15
- process.kill(pid, 'SIGKILL');
17
+ process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
16
18
  }
17
19
  catch { /* already gone */ }
18
20
  }
19
21
  }
22
+ function waitForCleanupRetry() {
23
+ spawnSync(process.execPath, ['-e', 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)'], { stdio: 'ignore' });
24
+ }
25
+ export function killProcessForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
26
+ try {
27
+ process.kill(target, 0);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }) {
34
+ if (platform === 'win32')
35
+ return runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) === 0;
36
+ try {
37
+ sendSignal(pid, 'SIGKILL');
38
+ }
39
+ catch (error) {
40
+ return error.code === 'ESRCH';
41
+ }
42
+ for (let attempt = 0; attempt < 3; attempt++) {
43
+ if (!isProcessAlive(pid))
44
+ return true;
45
+ waitForCleanupRetry();
46
+ }
47
+ return false;
48
+ }
49
+ export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }) {
50
+ if (platform === 'win32')
51
+ return runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) === 0;
52
+ try {
53
+ // Provider processes run detached on POSIX, so their PID is also the
54
+ // process-group leader. Signal the group to reap descendants as well.
55
+ sendSignal(-pid, 'SIGKILL');
56
+ }
57
+ catch (error) {
58
+ return error.code === 'ESRCH';
59
+ }
60
+ return true;
61
+ }
20
62
  // win32 default: kill the whole tree. Console apps have no reliable soft-close
21
63
  // on Windows, so both phases force (/F) — the grace pass is a retry, not an
22
64
  // escalation. Best-effort by design: taskkill is a standard Windows component.
@@ -30,7 +72,7 @@ export function runWatchdog(opts) {
30
72
  const spawnFn = opts.spawnFn ?? spawn;
31
73
  const out = opts.out ?? ((d) => process.stdout.write(d));
32
74
  const err = opts.err ?? ((d) => process.stderr.write(d));
33
- const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32' });
75
+ const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32', detached: process.platform !== 'win32' });
34
76
  if (opts.stdin && child.stdin) {
35
77
  try {
36
78
  opts.stdin.pipe(child.stdin);
@@ -39,7 +81,7 @@ export function runWatchdog(opts) {
39
81
  }
40
82
  if (opts.pidFile && child.pid !== undefined) {
41
83
  try {
42
- writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, childPid: child.pid, startedAt: new Date().toISOString() }));
84
+ writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, watchdogIncarnation: processIncarnation(process.pid), childPid: child.pid, childIncarnation: processIncarnation(child.pid), startedAt: new Date().toISOString() }));
43
85
  }
44
86
  catch { /* best-effort — cleanup falls back to worktree/lock handling */ }
45
87
  }
@@ -54,12 +96,15 @@ export function runWatchdog(opts) {
54
96
  const graceMs = opts.graceMs ?? 5000;
55
97
  // Explicitly-passed killTree wins (including an explicit undefined, which pins
56
98
  // the per-process signal path — tests use this to be platform-independent).
57
- const killTree = 'killTree' in opts ? opts.killTree : (process.platform === 'win32' ? taskkillTree : undefined);
99
+ const killTree = 'killTree' in opts ? opts.killTree : (pid, _force) => killProcessTreeForCleanup(pid);
58
100
  // Terminate the child — via the tree-killer when we have one and a pid,
59
101
  // otherwise per-process signals (POSIX default; SIGKILL is uncatchable).
102
+ let terminationRequested = false;
103
+ let terminationConfirmed = false;
60
104
  const terminate = (child, force) => {
105
+ terminationRequested = true;
61
106
  if (killTree && child.pid !== undefined) {
62
- killTree(child.pid, force);
107
+ terminationConfirmed = killTree(child.pid, force) === true || terminationConfirmed;
63
108
  return;
64
109
  }
65
110
  try {
@@ -111,7 +156,21 @@ export function runWatchdog(opts) {
111
156
  child.stdout.on('data', (d) => { out(d); arm(); });
112
157
  child.stderr.on('data', (d) => { err(d); arm(); });
113
158
  child.on('error', () => { clear(); removePidFile(); resolve(127); });
114
- child.on('close', (code) => { clear(); removePidFile(); resolve(killedForIdle ? 124 : (code ?? 0)); });
159
+ child.on('close', (code, signal) => {
160
+ clear();
161
+ if (!terminationRequested || terminationConfirmed)
162
+ removePidFile();
163
+ if (killedForIdle) {
164
+ resolve(124);
165
+ return;
166
+ }
167
+ if (code !== null) {
168
+ resolve(code);
169
+ return;
170
+ }
171
+ const signalNumber = signal ? constants.signals[signal] : undefined;
172
+ resolve(signalNumber === undefined ? 1 : 128 + signalNumber);
173
+ });
115
174
  arm();
116
175
  });
117
176
  }
@@ -0,0 +1,17 @@
1
+ export function bindStoryWorkerCancellation(cancellation, handle) {
2
+ const signal = cancellation?.signal;
3
+ if (!signal)
4
+ return () => { };
5
+ const cancel = () => {
6
+ const reason = typeof signal.reason === 'string' && signal.reason.length > 0
7
+ ? signal.reason
8
+ : 'worker cancellation requested';
9
+ handle.cancel(reason);
10
+ };
11
+ if (signal.aborted) {
12
+ cancel();
13
+ return () => { };
14
+ }
15
+ signal.addEventListener('abort', cancel, { once: true });
16
+ return () => signal.removeEventListener('abort', cancel);
17
+ }
@@ -0,0 +1,23 @@
1
+ export function createWorkerCleanup(steps) {
2
+ const completedByWorker = new WeakMap();
3
+ const orderedSteps = [steps.cleanupProcess ?? (() => undefined), steps.removeWorktree, steps.releaseClaim];
4
+ return input => {
5
+ const completed = completedByWorker.get(input) ?? new Set();
6
+ completedByWorker.set(input, completed);
7
+ for (const [index, step] of orderedSteps.entries()) {
8
+ if (completed.has(index))
9
+ continue;
10
+ for (let attempt = 0; attempt < 2; attempt += 1) {
11
+ try {
12
+ step(input);
13
+ completed.add(index);
14
+ break;
15
+ }
16
+ catch (error) {
17
+ if (attempt === 1)
18
+ throw error instanceof Error ? error : new Error(String(error));
19
+ }
20
+ }
21
+ }
22
+ };
23
+ }
@@ -0,0 +1 @@
1
+ export {};