@hecer/yoke 1.3.0 → 1.4.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 +22 -0
- package/README.md +67 -15
- package/TODOS.md +0 -3
- 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/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/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 +204 -51
- package/dist/loop/runner.js +67 -32
- 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/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 +4 -2
- package/dist/retrofit/gitignore.js +3 -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/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
package/dist/loop/run-command.js
CHANGED
|
@@ -12,9 +12,12 @@ import { maybeAutoUpgrade } from '../update/upgrade.js';
|
|
|
12
12
|
import { resolveCommitIdentity } from './identity.js';
|
|
13
13
|
import { runAudit } from '../audit/command.js';
|
|
14
14
|
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
15
|
-
import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
|
|
15
|
+
import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
|
|
16
16
|
import { makeAdaptiveRunner } from '../routing/router.js';
|
|
17
17
|
import { runChangeApply } from '../change/inbox.js';
|
|
18
|
+
import { createQualityCommandHooks } from '../quality/command.js';
|
|
19
|
+
import { resolveQualityPolicy } from '../quality/types.js';
|
|
20
|
+
import { runParallelLoopCommand } from './parallel-command.js';
|
|
18
21
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
19
22
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
20
23
|
export function relativeTime(fromIso, now) {
|
|
@@ -61,6 +64,24 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
61
64
|
}
|
|
62
65
|
if (st.reason)
|
|
63
66
|
lines.push(` reason: ${st.reason}`);
|
|
67
|
+
if (st.quality)
|
|
68
|
+
lines.push(` quality: round ${st.quality.currentRound} · ${st.quality.usedRepairs}${st.quality.unbounded ? ' unbounded repairs' : `/${st.quality.maxRepairs ?? 0} repairs`} · ${st.quality.policy}`);
|
|
69
|
+
if (st.parallel)
|
|
70
|
+
lines.push(` parallel ${st.parallel.dispatcherId}: ${st.parallel.activeWorkers}/${st.parallel.maxConcurrency} workers · ${st.parallel.queuedCandidates} queued · ${st.parallel.integrated} integrated · ${st.parallel.reopened} reopened`);
|
|
71
|
+
const integrator = st.parallel?.integrator;
|
|
72
|
+
if (integrator) {
|
|
73
|
+
const quality = integrator.quality
|
|
74
|
+
? ` · quality round ${integrator.quality.currentRound} · ${integrator.quality.usedRepairs}${integrator.quality.unbounded ? ' unbounded repairs' : `/${integrator.quality.maxRepairs ?? 0} repairs`}`
|
|
75
|
+
: '';
|
|
76
|
+
lines.push(` integrator ${integrator.story} "${integrator.storyTitle}" (${integrator.provider}${integrator.model ? `/${integrator.model}` : ''}) · ${integrator.phase ?? 'working'}${quality}`);
|
|
77
|
+
}
|
|
78
|
+
for (const worker of st.parallel?.workers ?? []) {
|
|
79
|
+
const quality = worker.quality
|
|
80
|
+
? ` · quality round ${worker.quality.currentRound} · ${worker.quality.usedRepairs}${worker.quality.unbounded ? ' unbounded repairs' : `/${worker.quality.maxRepairs ?? 0} repairs`}`
|
|
81
|
+
: '';
|
|
82
|
+
const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
|
|
83
|
+
lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
|
|
84
|
+
}
|
|
64
85
|
const ageMs = now().getTime() - Date.parse(st.updatedAt);
|
|
65
86
|
if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000) {
|
|
66
87
|
lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
|
|
@@ -72,11 +93,27 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
|
72
93
|
return minutes > 0 ? minutes * 60_000 : 0;
|
|
73
94
|
}
|
|
74
95
|
export function runLoopCommand(targetDir, opts) {
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
const parallel = opts.parallel ?? 1;
|
|
97
|
+
const candidates = opts.candidates ?? 1;
|
|
98
|
+
if (!Number.isInteger(parallel) || parallel < 1) {
|
|
99
|
+
console.error('--parallel must be a positive integer');
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isInteger(candidates) || candidates < 1 || candidates > 5) {
|
|
103
|
+
console.error('--candidates must be an integer from 1 to 5');
|
|
77
104
|
return 2;
|
|
78
105
|
}
|
|
79
106
|
const config = loadConfig(targetDir);
|
|
107
|
+
const maxParallelCandidates = config?.quality?.maxParallelCandidates ?? 1;
|
|
108
|
+
if (candidates > maxParallelCandidates) {
|
|
109
|
+
console.error(`--candidates=${candidates} exceeds quality.maxParallelCandidates=${maxParallelCandidates}`);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const qualityDisabled = opts.quality === false || (!config?.quality?.enabled && opts.quality !== true && opts.qualityUnbounded !== true);
|
|
113
|
+
if (candidates > 1 && qualityDisabled) {
|
|
114
|
+
console.error(`--candidates=${candidates} requires quality; quality cannot be disabled for candidate dispatch.`);
|
|
115
|
+
return 2;
|
|
116
|
+
}
|
|
80
117
|
if (!config?.loop.enabled) {
|
|
81
118
|
console.error('Loop is disabled. Enable it with: yoke loop on');
|
|
82
119
|
return 2;
|
|
@@ -100,6 +137,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
100
137
|
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
|
|
101
138
|
return 2;
|
|
102
139
|
}
|
|
140
|
+
if (candidates > 1) {
|
|
141
|
+
const missingQuality = loadPrd(path).find(story => !story.passes && !story.quality);
|
|
142
|
+
if (missingQuality) {
|
|
143
|
+
console.error(`Story ${missingQuality.id} needs a quality declaration before --candidates=${candidates} can dispatch.`);
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
103
147
|
let verify = opts.verify;
|
|
104
148
|
if (!verify) {
|
|
105
149
|
const command = resolveVerifyCommand(targetDir, config);
|
|
@@ -146,8 +190,84 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
146
190
|
announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
|
|
147
191
|
}
|
|
148
192
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
|
|
193
|
+
const qualityOverrides = {
|
|
194
|
+
...(opts.qualityUnbounded ? { quality: true, qualityUnbounded: true } : opts.quality !== undefined ? { quality: opts.quality } : {}),
|
|
195
|
+
...(opts.qualityRounds !== undefined ? { qualityRounds: opts.qualityRounds } : {}),
|
|
196
|
+
...(opts.qualityMinutes !== undefined ? { qualityMinutes: opts.qualityMinutes } : {}),
|
|
197
|
+
...(opts.qualityPolicy ? { qualityPolicy: opts.qualityPolicy } : {}),
|
|
198
|
+
...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
|
|
199
|
+
};
|
|
200
|
+
if (qualityOverrides.qualityUnbounded) {
|
|
201
|
+
console.error('WARNING: Quality repair limits are unbounded for this invocation. Mechanical gates, watchdog, isolation, and commit safety remain active.');
|
|
202
|
+
}
|
|
203
|
+
const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
|
|
204
|
+
const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
|
|
205
|
+
if (candidates > 1 && !configuredCriticModel) {
|
|
206
|
+
console.error('Quality candidate selection requires quality.critic.model (or legacy quality.criticModel) before any runner or worktree is started.');
|
|
207
|
+
return 2;
|
|
208
|
+
}
|
|
209
|
+
const quality = createQualityCommandHooks({
|
|
210
|
+
targetDir,
|
|
211
|
+
config,
|
|
212
|
+
runnerAgent,
|
|
213
|
+
idleMs,
|
|
214
|
+
policy: qualityOverrides,
|
|
215
|
+
...(opts.qualityRuntime ? { runtime: opts.qualityRuntime } : {}),
|
|
216
|
+
});
|
|
217
|
+
if (quality) {
|
|
218
|
+
const resolved = resolveQualityPolicy({ defaults: config.quality, overrides: qualityOverrides });
|
|
219
|
+
const criticAgent = configuredCriticAgent;
|
|
220
|
+
const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
|
|
221
|
+
const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
|
|
222
|
+
const announce = opts.json ? console.error : console.log;
|
|
223
|
+
announce(`Quality: ${resolved.policy} · critic: ${criticAgent}${configuredCriticModel ? `/${configuredCriticModel}` : '/provider-default'} · repair: ${repairAgent}${config.quality?.repair?.model ?? config.quality?.repairModel ? `/${config.quality?.repair?.model ?? config.quality?.repairModel}` : '/provider-default'} · permissions: read-only critic/safe repair · budget: ${limit}`);
|
|
224
|
+
}
|
|
149
225
|
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
150
226
|
const routingEnabled = opts.routing ?? config.routing?.enabled ?? false;
|
|
227
|
+
const runnerSelection = {
|
|
228
|
+
model: config.runner?.model,
|
|
229
|
+
reasoningEffort: config.runner?.reasoningEffort,
|
|
230
|
+
bare: config.runner?.bare,
|
|
231
|
+
...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
|
|
232
|
+
};
|
|
233
|
+
if ((parallel > 1 || candidates > 1) && routingEnabled) {
|
|
234
|
+
console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
237
|
+
const parallelProviders = [{
|
|
238
|
+
provider: runnerAgent,
|
|
239
|
+
...(runnerSelection.model ? { model: runnerSelection.model } : {}),
|
|
240
|
+
...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
|
|
241
|
+
}];
|
|
242
|
+
const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
|
|
243
|
+
provider: worker.agent,
|
|
244
|
+
...(worker.model ? { model: worker.model } : {}),
|
|
245
|
+
...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
|
|
246
|
+
}));
|
|
247
|
+
const parallelStories = parallel > 1 || candidates > 1 ? loadPrd(path).filter(story => !story.passes) : [];
|
|
248
|
+
const ambiguousAffinityProvider = [...new Set(parallelStories.flatMap(story => story.agent ? [story.agent] : []))]
|
|
249
|
+
.find(agent => parallelAffinityProviders.filter(provider => provider.provider === agent).length > 1);
|
|
250
|
+
if (ambiguousAffinityProvider) {
|
|
251
|
+
console.error(`Parallel affinity provider "${ambiguousAffinityProvider}" has multiple profiles. Configure exactly one profile for each story agent.`);
|
|
252
|
+
return 2;
|
|
253
|
+
}
|
|
254
|
+
if (opts.runner) {
|
|
255
|
+
const mismatchedAffinity = parallelStories.find(story => {
|
|
256
|
+
if (!story.agent)
|
|
257
|
+
return false;
|
|
258
|
+
const provider = parallelAffinityProviders.find(candidate => candidate.provider === story.agent)
|
|
259
|
+
?? parallelProviders.find(candidate => candidate.provider === story.agent);
|
|
260
|
+
return !provider
|
|
261
|
+
|| provider.provider !== runnerAgent
|
|
262
|
+
|| provider.model !== runnerSelection.model
|
|
263
|
+
|| provider.reasoningEffort !== runnerSelection.reasoningEffort;
|
|
264
|
+
});
|
|
265
|
+
if (mismatchedAffinity) {
|
|
266
|
+
console.error(`Injected runner cannot truthfully execute affinity provider for story ${mismatchedAffinity.id}. Remove the affinity or use the configured provider runner.`);
|
|
267
|
+
return 2;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const ambiguityPolicy = opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto';
|
|
151
271
|
if (routingEnabled && (!config.routing || config.routing.workers.length === 0)) {
|
|
152
272
|
console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
|
|
153
273
|
return 2;
|
|
@@ -167,23 +287,22 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
167
287
|
}));
|
|
168
288
|
let runner = opts.runner;
|
|
169
289
|
if (!runner) {
|
|
170
|
-
|
|
171
|
-
|
|
290
|
+
const requiredProviders = parallel > 1 || candidates > 1
|
|
291
|
+
? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
|
|
292
|
+
: [runnerAgent];
|
|
293
|
+
const unavailableProvider = requiredProviders.find(agent => !available(agent));
|
|
294
|
+
if (unavailableProvider) {
|
|
295
|
+
console.error(`Agent CLI "${unavailableProvider}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
|
|
172
296
|
return 2;
|
|
173
297
|
}
|
|
174
298
|
// Token reporting is part of the machine interface: in --json mode a claude
|
|
175
299
|
// runner switches to stream-json so cumulative usage rides on every status.
|
|
176
300
|
const runnerOpts = {
|
|
177
301
|
tokenReport: opts.json === true,
|
|
178
|
-
onAmbiguity:
|
|
302
|
+
onAmbiguity: ambiguityPolicy,
|
|
179
303
|
perfCommand: config.perf?.command,
|
|
180
304
|
permissions,
|
|
181
|
-
selection:
|
|
182
|
-
model: config.runner?.model,
|
|
183
|
-
reasoningEffort: config.runner?.reasoningEffort,
|
|
184
|
-
bare: config.runner?.bare,
|
|
185
|
-
...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
|
|
186
|
-
},
|
|
305
|
+
selection: runnerSelection,
|
|
187
306
|
};
|
|
188
307
|
runner = routingEnabled && config.routing
|
|
189
308
|
? makeAdaptiveRunner({
|
|
@@ -237,8 +356,75 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
237
356
|
if (lock.stalePid !== undefined) {
|
|
238
357
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
239
358
|
}
|
|
359
|
+
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
360
|
+
const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
|
|
361
|
+
storyId,
|
|
362
|
+
requestId,
|
|
363
|
+
...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
|
|
364
|
+
agent: runnerAgent,
|
|
365
|
+
isolate: parallel > 1 || candidates > 1 || (opts.isolate ?? false),
|
|
366
|
+
reviewer: opts.reviewer,
|
|
367
|
+
review: opts.review === true || opts.reviewRunner !== undefined,
|
|
368
|
+
allowSelfReview: opts.allowSelfReview ?? false,
|
|
369
|
+
timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
|
|
370
|
+
json: opts.json ?? false,
|
|
371
|
+
onAmbiguity: opts.decisionPolicy ? undefined : opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort' ? opts.onAmbiguity : (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
|
|
372
|
+
decisionPolicy: opts.decisionPolicy ?? (opts.onAmbiguity ? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined) : config.loop.decisionPolicy),
|
|
373
|
+
permissions,
|
|
374
|
+
parallel,
|
|
375
|
+
routing: routingEnabled,
|
|
376
|
+
...(qualityOverrides.quality !== undefined ? { quality: qualityOverrides.quality } : {}),
|
|
377
|
+
...(opts.qualityRounds !== undefined ? { qualityRounds: opts.qualityRounds } : {}),
|
|
378
|
+
...(opts.qualityMinutes !== undefined ? { qualityMinutes: opts.qualityMinutes } : {}),
|
|
379
|
+
...(opts.qualityPolicy ? { qualityPolicy: opts.qualityPolicy } : {}),
|
|
380
|
+
...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
|
|
381
|
+
});
|
|
382
|
+
const reconcileDecisionResume = () => {
|
|
383
|
+
try {
|
|
384
|
+
const pendingDecision = readPendingDecision(targetDir);
|
|
385
|
+
if (pendingDecision) {
|
|
386
|
+
writeDecisionResume(targetDir, buildResume(pendingDecision.storyId, decisionRequestId(pendingDecision)));
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
clearDecisionResume(targetDir);
|
|
390
|
+
}
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
reporter.blocked(`could not persist trusted decision resume state: ${error instanceof Error ? error.message : String(error)}`);
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
if (parallel > 1 || candidates > 1) {
|
|
399
|
+
return runParallelLoopCommand({
|
|
400
|
+
targetDir,
|
|
401
|
+
prdPath: path,
|
|
402
|
+
maxConcurrency: parallel,
|
|
403
|
+
candidateCount: candidates,
|
|
404
|
+
maxIterations: opts.maxIterations ?? Number.POSITIVE_INFINITY,
|
|
405
|
+
runner: opts.runner,
|
|
406
|
+
runnerAgent,
|
|
407
|
+
idleMs,
|
|
408
|
+
permissions,
|
|
409
|
+
selection: runnerSelection,
|
|
410
|
+
providers: parallelProviders,
|
|
411
|
+
affinityProviders: parallelAffinityProviders,
|
|
412
|
+
onAmbiguity: ambiguityPolicy,
|
|
413
|
+
git: opts.git,
|
|
414
|
+
identity: commitIdentity,
|
|
415
|
+
verify,
|
|
416
|
+
verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify)(dir),
|
|
417
|
+
requireCriterionEvidence: config.verify?.requireCriteria ?? false,
|
|
418
|
+
perf,
|
|
419
|
+
audit,
|
|
420
|
+
review,
|
|
421
|
+
reporter,
|
|
422
|
+
completion,
|
|
423
|
+
quality,
|
|
424
|
+
onCriticalDecision: decision => writeDecisionResume(targetDir, buildResume(decision.storyId, decisionRequestId(decision))),
|
|
425
|
+
}).then(code => reconcileDecisionResume() ?? code).finally(() => releaseLock(targetDir, lock.ownerToken));
|
|
426
|
+
}
|
|
240
427
|
try {
|
|
241
|
-
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
242
428
|
const maxIterations = opts.maxIterations ?? Number.POSITIVE_INFINITY;
|
|
243
429
|
const result = runLoop({
|
|
244
430
|
prdPath: path,
|
|
@@ -257,45 +443,12 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
257
443
|
isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
|
|
258
444
|
review,
|
|
259
445
|
reporter,
|
|
446
|
+
...(quality ?? {}),
|
|
447
|
+
...(quality ? { qualityEnabled: quality.qualityEnabled, qualityMetadata: quality.qualityMetadata } : {}),
|
|
260
448
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
writeDecisionResume(targetDir, {
|
|
265
|
-
version: 1,
|
|
266
|
-
storyId: pendingDecision.storyId,
|
|
267
|
-
requestId: decisionRequestId(pendingDecision),
|
|
268
|
-
answered: false,
|
|
269
|
-
...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
|
|
270
|
-
agent: runnerAgent,
|
|
271
|
-
isolate: opts.isolate ?? false,
|
|
272
|
-
reviewer: opts.reviewer,
|
|
273
|
-
review: opts.review === true || opts.reviewRunner !== undefined,
|
|
274
|
-
allowSelfReview: opts.allowSelfReview ?? false,
|
|
275
|
-
timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
|
|
276
|
-
json: opts.json ?? false,
|
|
277
|
-
onAmbiguity: opts.decisionPolicy
|
|
278
|
-
? undefined
|
|
279
|
-
: opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort'
|
|
280
|
-
? opts.onAmbiguity
|
|
281
|
-
: (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
|
|
282
|
-
decisionPolicy: opts.decisionPolicy
|
|
283
|
-
?? (opts.onAmbiguity
|
|
284
|
-
? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined)
|
|
285
|
-
: config.loop.decisionPolicy),
|
|
286
|
-
permissions,
|
|
287
|
-
parallel: opts.parallel ?? 1,
|
|
288
|
-
routing: routingEnabled,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
else
|
|
292
|
-
clearDecisionResume(targetDir);
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
const reason = `could not persist trusted decision resume state: ${error.message}`;
|
|
296
|
-
reporter.blocked(reason);
|
|
297
|
-
return 1;
|
|
298
|
-
}
|
|
449
|
+
const resumeCode = reconcileDecisionResume();
|
|
450
|
+
if (resumeCode !== undefined)
|
|
451
|
+
return resumeCode;
|
|
299
452
|
// In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
|
|
300
453
|
const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
|
|
301
454
|
say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
|
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/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
|
}
|