@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +108 -16
- package/TODOS.md +0 -3
- package/bench/README.md +55 -46
- package/bench/output-compaction.mjs +65 -0
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/audit/command.js +13 -5
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/git.js +6 -4
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +216 -58
- package/dist/loop/runner.js +67 -32
- package/dist/loop/verify.js +65 -11
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/output/artifact.js +63 -0
- package/dist/output/compact.js +192 -0
- package/dist/output/types.js +4 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +26 -2
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
- package/gemini-extension.json +1 -1
- package/package.json +4 -3
package/dist/loop/runner.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { isAcceptanceCriterion } from './prd.js';
|
|
2
2
|
import { execFileSync, execSync } from 'node:child_process';
|
|
3
|
-
import { existsSync
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
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(
|
|
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: [
|
|
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
|
|
340
|
+
export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
|
|
335
341
|
return (ctx) => {
|
|
336
|
-
const
|
|
337
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
?
|
|
354
|
-
:
|
|
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
|
-
|
|
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) {
|
package/dist/loop/verify.js
CHANGED
|
@@ -1,26 +1,80 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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, {
|
|
8
|
-
|
|
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
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
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
|
}
|
package/dist/loop/watchdog.js
CHANGED
|
@@ -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 : (
|
|
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) => {
|
|
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 {};
|