agentic-workflow-manager 9.13.0 → 9.13.2
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/dist/src/commands/watch/supervisor.js +25 -11
- package/dist/src/core/journal/process.js +54 -2
- package/dist/tests/commands/watch/plan-lifecycle.test.js +1 -1
- package/dist/tests/commands/watch/reopened-196.test.js +1 -1
- package/dist/tests/commands/watch/supervisor-loop.test.js +124 -1
- package/dist/tests/core/journal/process.test.js +64 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -245,14 +245,18 @@ function recoveryWhitelistBlocker(state) {
|
|
|
245
245
|
// Pending work is the normal reason to launch (or retry launching) the
|
|
246
246
|
// controller. It is not recovery evidence and therefore cannot turn a
|
|
247
247
|
// transient launch failure into permanent custody. Conversely, once a
|
|
248
|
-
// non-empty task set claims completion,
|
|
249
|
-
//
|
|
248
|
+
// non-empty task set claims completion, a required verifier that no plan
|
|
249
|
+
// contains, or a task-level item left unsatisfied, is an unsafe recovery
|
|
250
|
+
// fact and must remain fail-closed. A cycle-level item that was planned
|
|
251
|
+
// but never requested is not: it is the pending closure a relaunched
|
|
252
|
+
// controller exists to perform (#196, v1 smoke), and the gate still
|
|
253
|
+
// demands its evidence before COMPLETE.
|
|
250
254
|
if (state.tasks.length === 0 || state.tasks.some(task => task.status !== 'done'))
|
|
251
255
|
return undefined;
|
|
252
256
|
const verificationItems = [...state.cycleVerificationPlan, ...state.tasks.flatMap(task => task.verificationPlan)];
|
|
253
257
|
for (const required of state.requiredVerifiers) {
|
|
254
258
|
const requiredItems = verificationItems.filter(item => item.kind === required);
|
|
255
|
-
if (requiredItems.length === 0 || requiredItems.some(item => item.satisfiedBy === undefined))
|
|
259
|
+
if (requiredItems.length === 0 || requiredItems.some(item => item.satisfiedBy === undefined && !state.cycleVerificationPlan.includes(item)))
|
|
256
260
|
return `falta evidencia del verificador requerido: ${required}`;
|
|
257
261
|
}
|
|
258
262
|
return undefined;
|
|
@@ -483,18 +487,23 @@ class Supervisor {
|
|
|
483
487
|
const job = before0.state.jobs[id];
|
|
484
488
|
return job !== undefined && job.verdict === 'pass' && !staleJob;
|
|
485
489
|
};
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
|
|
490
|
+
const cycleItems = before0.state.cycleVerificationPlan;
|
|
491
|
+
const verificationItems = [...cycleItems, ...before0.state.tasks.flatMap(task => task.verificationPlan)];
|
|
492
|
+
// Planned cycle items never requested are closure work, not
|
|
493
|
+
// evidence; every requested or task-level item is still judged.
|
|
494
|
+
const judged = (items) => items.filter(item => item.satisfiedBy !== undefined || !cycleItems.includes(item));
|
|
495
|
+
const tests = judged(verificationItems.filter(item => item.kind === 'test'));
|
|
496
|
+
const sensorItems = judged(verificationItems.filter(item => item.kind === 'sensors'));
|
|
489
497
|
// `computeGate` is the single authority for every verification
|
|
490
498
|
// kind (review, QA and interlock included). Reuse its evidence
|
|
491
499
|
// semantics instead of maintaining a weaker recovery subset.
|
|
492
500
|
const evidenceGate = (0, gate_1.computeGate)(before0.state, false, this.fingerprintNow);
|
|
493
501
|
const terminalTaskClaims = before0.state.tasks.length > 0 && before0.state.tasks.every(task => task.status === 'done');
|
|
494
|
-
const
|
|
495
|
-
|
|
502
|
+
const closurePending = terminalTaskClaims && cycleItems.some(item => item.satisfiedBy === undefined);
|
|
503
|
+
const unresolvedVerification = terminalTaskClaims && (evidenceGate.reasons.some(reason => [
|
|
504
|
+
'dangling-reference', 'adverse-verdict',
|
|
496
505
|
'stale-fingerprint', 'open-obligation', 'open-fix',
|
|
497
|
-
].includes(reason.category));
|
|
506
|
+
].includes(reason.category)) || verificationItems.some(item => item.satisfiedBy === undefined && !cycleItems.includes(item)));
|
|
498
507
|
const hasStaleReview = before0.state.verdicts.some(verdict => verdict.fingerprint === '' || (verdict.argv.length > 0 && (() => {
|
|
499
508
|
try {
|
|
500
509
|
return (0, fingerprint_1.computeFingerprint)(this.repoRoot, verdict.argv, verdict.paths, verdict.cwd).fingerprint !== verdict.fingerprint;
|
|
@@ -524,7 +533,7 @@ class Supervisor {
|
|
|
524
533
|
sensors: !terminalTaskClaims || sensorItems.length === 0 || sensorItems.every(item => passed(item.satisfiedBy)) ? 'pass' : 'missing',
|
|
525
534
|
verdicts: hasStaleReview ? 'stale' : (openReviewOrFix || unresolvedVerification) ? 'missing' : 'current',
|
|
526
535
|
});
|
|
527
|
-
(0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'unattended-recovery', nextAction: recovery.nextAction, activeJobIds: recovery.activeJobIds, diagnostics: recovery.diagnostics });
|
|
536
|
+
(0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'unattended-recovery', nextAction: recovery.nextAction, activeJobIds: recovery.activeJobIds, diagnostics: recovery.diagnostics, closurePending });
|
|
528
537
|
if (recovery.state !== 'ready') {
|
|
529
538
|
const staleReasons = terminalTaskClaims ? evidenceGate.reasons.filter(reason => reason.category === 'stale-fingerprint') : [];
|
|
530
539
|
const remedy = staleReasons.length > 0
|
|
@@ -533,7 +542,7 @@ class Supervisor {
|
|
|
533
542
|
(0, generations_1.enterCustody)(this.repoRoot, this.branch, `recovery no autorizado: ${recovery.diagnostics.join(', ')}${remedy}`);
|
|
534
543
|
return 'custody';
|
|
535
544
|
}
|
|
536
|
-
recoveryResumeAction = { schema: 'controller-recovery/v1', kind: recovery.nextAction === 'reconcile-active-jobs' ? 'reconcile-active-jobs' : 'resume-next-action' };
|
|
545
|
+
recoveryResumeAction = { schema: 'controller-recovery/v1', kind: recovery.nextAction === 'reconcile-active-jobs' ? 'reconcile-active-jobs' : closurePending ? 'resume-cycle' : 'resume-next-action' };
|
|
537
546
|
}
|
|
538
547
|
// R6.2/R6.8/C7 (Task 11): reconciliar un `MERGE_HEAD` abierto por un
|
|
539
548
|
// crash a mitad de un merge ANTES de cualquier guard general — hoy
|
|
@@ -792,6 +801,11 @@ class Supervisor {
|
|
|
792
801
|
const decision = snap === null && safeToReplace === 'safe' ? 'resolve-generation' : (0, generations_1.decideStall)({ heartbeatAgeMs, activityFrozenMs, safeToReplace }, { heartbeatTimeoutMs: this.cfg.heartbeatTimeoutMs, activityWindowMs: this.cfg.activityWindowMs });
|
|
793
802
|
if (decision === 'healthy') {
|
|
794
803
|
this.backoff.reset();
|
|
804
|
+
if (gen.state === 'controller-suspected-stall') {
|
|
805
|
+
gen.state = 'active'; // heartbeat reanudado: la sospecha se levanta
|
|
806
|
+
(0, store_1.writeJournal)(this.repoRoot, this.branch, s);
|
|
807
|
+
(0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'controller-stall-cleared', n: gen.n });
|
|
808
|
+
}
|
|
795
809
|
return 'ok';
|
|
796
810
|
}
|
|
797
811
|
if (decision === 'suspected-stall-observe') {
|
|
@@ -12,6 +12,7 @@ exports.spawnStructured = spawnStructured;
|
|
|
12
12
|
exports.refIsAlive = refIsAlive;
|
|
13
13
|
exports.processStatesAreGone = processStatesAreGone;
|
|
14
14
|
exports.groupIsGone = groupIsGone;
|
|
15
|
+
exports.descendantActivity = descendantActivity;
|
|
15
16
|
exports.activitySnapshot = activitySnapshot;
|
|
16
17
|
exports.terminateGroupConfirmed = terminateGroupConfirmed;
|
|
17
18
|
exports.terminatePreviouslyOwnedGroup = terminatePreviouslyOwnedGroup;
|
|
@@ -455,6 +456,57 @@ function groupIsGone(pgid) {
|
|
|
455
456
|
return false; // sin observacion completa, falla cerrado
|
|
456
457
|
}
|
|
457
458
|
}
|
|
459
|
+
const MAX_PS_ROWS = 65536;
|
|
460
|
+
/** Activity of every process descending from `rootPid` in a `ps -A -o
|
|
461
|
+
* pid=,ppid=,time=` table. The process group alone misses work a controller
|
|
462
|
+
* waits on when the provider runs it in its own group or session (sandboxed
|
|
463
|
+
* tool commands), which turned a healthy wait on a native subagent into a
|
|
464
|
+
* "frozen" controller and a false custody (#196, v1 smoke). Root excluded;
|
|
465
|
+
* malformed rows ignored; a ppid cycle cannot loop because each pid is
|
|
466
|
+
* visited once. */
|
|
467
|
+
function descendantActivity(psTable, rootPid) {
|
|
468
|
+
if (!Number.isInteger(rootPid) || rootPid <= 0)
|
|
469
|
+
throw new Error(`descendantActivity: root pid must be a positive integer, got ${rootPid}`);
|
|
470
|
+
const children = new Map();
|
|
471
|
+
let rows = 0;
|
|
472
|
+
for (const line of psTable.split('\n')) {
|
|
473
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
|
|
474
|
+
if (match === null)
|
|
475
|
+
continue;
|
|
476
|
+
if (++rows > MAX_PS_ROWS)
|
|
477
|
+
break;
|
|
478
|
+
const pid = Number(match[1]);
|
|
479
|
+
const ppid = Number(match[2]);
|
|
480
|
+
const siblings = children.get(ppid) ?? [];
|
|
481
|
+
siblings.push({ pid, time: match[3] });
|
|
482
|
+
children.set(ppid, siblings);
|
|
483
|
+
}
|
|
484
|
+
const seen = new Set([rootPid]);
|
|
485
|
+
const found = [];
|
|
486
|
+
const queue = [rootPid];
|
|
487
|
+
while (queue.length > 0) {
|
|
488
|
+
for (const child of children.get(queue.shift()) ?? []) {
|
|
489
|
+
if (seen.has(child.pid))
|
|
490
|
+
continue;
|
|
491
|
+
seen.add(child.pid);
|
|
492
|
+
found.push(`${child.pid}:${child.time}`);
|
|
493
|
+
queue.push(child.pid);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (found.length === 0)
|
|
497
|
+
return '0:none';
|
|
498
|
+
found.sort();
|
|
499
|
+
return `${found.length}:${crypto_1.default.createHash('sha256').update(found.join('\n')).digest('hex')}`;
|
|
500
|
+
}
|
|
501
|
+
function observeDescendants(rootPid) {
|
|
502
|
+
try {
|
|
503
|
+
const table = (0, child_process_1.execFileSync)('ps', ['-A', '-o', 'pid=,ppid=,time='], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
|
|
504
|
+
return descendantActivity(table, rootPid);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return 'unknown';
|
|
508
|
+
}
|
|
509
|
+
}
|
|
458
510
|
/** Llamada UNA VEZ POR TICK por el supervisor (ver superviseController en
|
|
459
511
|
* commands/watch/supervisor.ts) mientras un controlador este activo — el
|
|
460
512
|
* path mas caliente de todo el modulo. En win32, `ps`/`pgrep`, cuando
|
|
@@ -474,7 +526,7 @@ function activitySnapshot(ref) {
|
|
|
474
526
|
if (!refIsAlive(ref))
|
|
475
527
|
return null;
|
|
476
528
|
if ((0, paths_1.isWindowsNative)())
|
|
477
|
-
return { cpuTime: 'unknown', groupSize: 1 };
|
|
529
|
+
return { cpuTime: 'unknown', groupSize: 1, descendants: 'unknown' };
|
|
478
530
|
let cpu = '0';
|
|
479
531
|
try {
|
|
480
532
|
cpu = psField(ref.pid, 'time') ?? '0';
|
|
@@ -490,7 +542,7 @@ function activitySnapshot(ref) {
|
|
|
490
542
|
catch {
|
|
491
543
|
groupSize = 1;
|
|
492
544
|
}
|
|
493
|
-
return { cpuTime: cpu, groupSize };
|
|
545
|
+
return { cpuTime: cpu, groupSize, descendants: observeDescendants(ref.pid) };
|
|
494
546
|
}
|
|
495
547
|
/** win32: sin process groups POSIX ni convencion de pid negativo, y sin
|
|
496
548
|
* distincion real SIGTERM/SIGKILL (Node mapea ambas a TerminateProcess
|
|
@@ -183,7 +183,7 @@ describe('compact lifecycle proof and unused bootstrap archival', () => {
|
|
|
183
183
|
(0, store_1.writeJournal)(repo, 'main', launched);
|
|
184
184
|
const adapter = adapterModule.adapterFor('codex');
|
|
185
185
|
jest.spyOn(adapterModule, 'adapterFor').mockReturnValue({ ...adapter,
|
|
186
|
-
activity: () => ({ cpuTime: '0', groupSize: 1 }), safeToReplace: () => 'indeterminate' });
|
|
186
|
+
activity: () => ({ cpuTime: '0', groupSize: 1, descendants: '0:none' }), safeToReplace: () => 'indeterminate' });
|
|
187
187
|
for (let i = 0; i < 100; i++) {
|
|
188
188
|
expect(await supervisor.tick()).toBe('continue');
|
|
189
189
|
if (Object.values((0, store_1.readJournal)(repo, 'main').state.jobs).some(job => job.verdict === 'pass'))
|
|
@@ -489,7 +489,7 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
|
|
|
489
489
|
(0, store_1.writeJournal)(repo, 'main', launched);
|
|
490
490
|
const adapter = adapterModule.adapterFor('codex');
|
|
491
491
|
jest.spyOn(adapterModule, 'adapterFor').mockReturnValue({ ...adapter,
|
|
492
|
-
activity: () => ({ cpuTime: '0', groupSize: 1 }), safeToReplace: () => 'indeterminate' });
|
|
492
|
+
activity: () => ({ cpuTime: '0', groupSize: 1, descendants: '0:none' }), safeToReplace: () => 'indeterminate' });
|
|
493
493
|
fs_1.default.writeFileSync(path_1.default.join(repo, 's1-red.test.ts'), 'test("RED", () => { throw new Error("RED"); });\n');
|
|
494
494
|
(0, request_1.requestJob)(repo, 'main', gen.token, [process.execPath, '-e', 'process.exit(0)'], [], '.');
|
|
495
495
|
report = redSensor;
|
|
@@ -104,7 +104,7 @@ function adoptFixtureController(repo) {
|
|
|
104
104
|
(0, store_1.writeJournal)(repo, 'main', state);
|
|
105
105
|
const adapter = adapterModule.adapterFor('codex');
|
|
106
106
|
jest.spyOn(adapterModule, 'adapterFor').mockReturnValue({ ...adapter,
|
|
107
|
-
activity: () => ({ cpuTime: '0', groupSize: 1 }), safeToReplace: () => 'indeterminate' });
|
|
107
|
+
activity: () => ({ cpuTime: '0', groupSize: 1, descendants: '0:none' }), safeToReplace: () => 'indeterminate' });
|
|
108
108
|
}
|
|
109
109
|
/** Legacy fixtures predate compact-only admission; make their execution contract explicit. */
|
|
110
110
|
function initUnattendedFixture(repo) {
|
|
@@ -621,6 +621,88 @@ describe('supervisor loop', () => {
|
|
|
621
621
|
expect(spawns).toBe(0);
|
|
622
622
|
expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
|
|
623
623
|
});
|
|
624
|
+
// v1 smoke 2026-09-26 (#196): the Codex controller exited after the last
|
|
625
|
+
// review, before requesting the cycle-level verifications it had planned.
|
|
626
|
+
// Tasks done + planned-but-never-requested cycle items is pending closure
|
|
627
|
+
// work, not an unsafe recovery claim: relaunch the controller to close.
|
|
628
|
+
function closurePendingState(repoRoot) {
|
|
629
|
+
initUnattendedFixture(repoRoot);
|
|
630
|
+
const state = (0, store_1.readJournal)(repoRoot, 'main').state;
|
|
631
|
+
const verdict = (id, obligationId) => {
|
|
632
|
+
const argv = ['awm-review', obligationId];
|
|
633
|
+
return { id, obligationId, result: 'pass', detail: 'ok', receivedAt: new Date().toISOString(),
|
|
634
|
+
fingerprint: (0, fingerprint_1.computeFingerprint)(repoRoot, argv, [], '.').fingerprint, argv, paths: [], cwd: '.' };
|
|
635
|
+
};
|
|
636
|
+
state.requiredVerifiers = ['test', 'sensors'];
|
|
637
|
+
state.tasks.push({ id: 'S1', title: 'terminada', status: 'done', attempts: 1, verificationPlan: [], reviewObligations: [
|
|
638
|
+
{ id: 'o-spec', taskId: 'S1', kind: 'spec', verdictId: 'v-spec' },
|
|
639
|
+
{ id: 'o-quality', taskId: 'S1', kind: 'quality', verdictId: 'v-quality' },
|
|
640
|
+
] });
|
|
641
|
+
state.verdicts.push(verdict('v-spec', 'o-spec'), verdict('v-quality', 'o-quality'));
|
|
642
|
+
state.cycleVerificationPlan.push({ id: 'cycle-test', kind: 'test' }, { id: 'cycle-sensors', kind: 'sensors' }, { id: 'cycle-qa', kind: 'qa' }, { id: 'cycle-interlock', kind: 'interlock' });
|
|
643
|
+
return state;
|
|
644
|
+
}
|
|
645
|
+
test('cierre pendiente: tareas terminadas + verificaciones de ciclo nunca pedidas => relanza el controller para cerrar, sin custodia', async () => {
|
|
646
|
+
const state = closurePendingState(repo);
|
|
647
|
+
(0, store_1.writeJournal)(repo, 'main', state);
|
|
648
|
+
(0, generations_1.beginGeneration)(repo, 'main'); // el reemplazo que el loop abre tras la salida del anterior
|
|
649
|
+
let spawns = 0;
|
|
650
|
+
const outcome = await new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, () => { spawns++; }, undefined, admitted).tick();
|
|
651
|
+
expect(outcome).not.toBe('custody');
|
|
652
|
+
const after = (0, store_1.readJournal)(repo, 'main').state;
|
|
653
|
+
expect(after.cycle.status).toBe('IN_PROGRESS');
|
|
654
|
+
expect(spawns).toBe(1);
|
|
655
|
+
expect((0, generations_1.activeGeneration)(after).resumeAction?.kind).toBe('resume-cycle');
|
|
656
|
+
expect(after.tasks.map(task => [task.id, task.status, task.attempts])).toEqual([['S1', 'done', 1]]); // nada se redespacha
|
|
657
|
+
});
|
|
658
|
+
test.each([
|
|
659
|
+
['un item de tarea sin satisfacer tras declararla terminada', (s) => {
|
|
660
|
+
s.tasks[0].verificationPlan.push({ id: 'task-test', kind: 'test' });
|
|
661
|
+
}],
|
|
662
|
+
['un item de ciclo que cita un job inexistente', (s) => {
|
|
663
|
+
s.cycleVerificationPlan[0].satisfiedBy = 'job-fantasma';
|
|
664
|
+
}],
|
|
665
|
+
['una revision obligatoria sin verdict', (s) => {
|
|
666
|
+
s.tasks[0].reviewObligations[1].verdictId = undefined;
|
|
667
|
+
}],
|
|
668
|
+
['un verdict de revision adverso sin fix cerrado', (s) => {
|
|
669
|
+
s.verdicts[1].result = 'fail';
|
|
670
|
+
}],
|
|
671
|
+
['un verificador requerido que ningun plan contiene', (s) => {
|
|
672
|
+
s.cycleVerificationPlan.splice(0, 1);
|
|
673
|
+
}],
|
|
674
|
+
])('cierre pendiente sigue fail-closed con %s: custodia sin controller', async (_name, mutate) => {
|
|
675
|
+
const state = closurePendingState(repo);
|
|
676
|
+
mutate(state);
|
|
677
|
+
(0, store_1.writeJournal)(repo, 'main', state);
|
|
678
|
+
(0, generations_1.beginGeneration)(repo, 'main'); // mismo reemplazo: solo cambia la evidencia
|
|
679
|
+
let spawns = 0;
|
|
680
|
+
const outcome = await new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, () => { spawns++; }, undefined, admitted).tick();
|
|
681
|
+
expect(outcome).toBe('custody');
|
|
682
|
+
expect(spawns).toBe(0);
|
|
683
|
+
expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
|
|
684
|
+
});
|
|
685
|
+
// A controller that resumes heartbeating is healthy again; leaving the
|
|
686
|
+
// generation marked as a suspected stall misreports the cycle.
|
|
687
|
+
test('una generacion en sospecha de stall vuelve a active cuando el heartbeat se reanuda', async () => {
|
|
688
|
+
initUnattendedFixture(repo);
|
|
689
|
+
(0, generations_1.beginGeneration)(repo, 'main');
|
|
690
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 20000)'], process.cwd(), 'nBack');
|
|
691
|
+
try {
|
|
692
|
+
const s = (0, store_1.readJournal)(repo, 'main').state;
|
|
693
|
+
const gen = (0, generations_1.activeGeneration)(s);
|
|
694
|
+
gen.processRef = ref;
|
|
695
|
+
gen.state = 'controller-suspected-stall';
|
|
696
|
+
s.controllerHeartbeatAt = new Date().toISOString();
|
|
697
|
+
(0, store_1.writeJournal)(repo, 'main', s);
|
|
698
|
+
const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', heartbeatTimeoutMs: 60000, activityWindowMs: 60000, tickMs: 20 };
|
|
699
|
+
expect(await new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner, undefined, admitted).tick()).not.toBe('custody');
|
|
700
|
+
expect((0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'main').state).state).toBe('active');
|
|
701
|
+
}
|
|
702
|
+
finally {
|
|
703
|
+
child.kill('SIGKILL');
|
|
704
|
+
}
|
|
705
|
+
});
|
|
624
706
|
test('un journal schema-1 legacy no despacha controller ni jobs aunque contenga trabajo pendiente', async () => {
|
|
625
707
|
(0, store_1.initJournal)(repo, 'main');
|
|
626
708
|
const legacy = (0, store_1.readJournal)(repo, 'main').state;
|
|
@@ -672,6 +754,47 @@ describe('supervisor loop', () => {
|
|
|
672
754
|
expect(child.killed).toBe(false); // y NO mato al controlador
|
|
673
755
|
child.kill('SIGKILL');
|
|
674
756
|
});
|
|
757
|
+
// v1 smoke 2026-09-26 (#196): a Codex controller waiting on a native
|
|
758
|
+
// subagent kept an idle leader while the work ran in processes outside its
|
|
759
|
+
// process group, and the supervisor entered a false custody. Work in the
|
|
760
|
+
// descendant tree is activity: observe, never custody, while it moves.
|
|
761
|
+
itPosix('espera sana: lider ocioso + descendiente fuera del grupo trabajando => observa, no custodia (R4.2)', async () => {
|
|
762
|
+
initUnattendedFixture(repo);
|
|
763
|
+
(0, generations_1.beginGeneration)(repo, 'main');
|
|
764
|
+
const worker = "require('child_process').spawn('sh', ['-c', 'while :; do sleep 0.02; done'], { detached: true, stdio: 'ignore' }).unref(); setTimeout(()=>{}, 20000)";
|
|
765
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', worker], process.cwd(), 'nWait');
|
|
766
|
+
try {
|
|
767
|
+
let s = (0, store_1.readJournal)(repo, 'main').state;
|
|
768
|
+
(0, generations_1.activeGeneration)(s).processRef = ref;
|
|
769
|
+
s.controllerHeartbeatAt = new Date(Date.now() - 3600000).toISOString(); // heartbeat vencido: bloqueado en la espera
|
|
770
|
+
(0, store_1.writeJournal)(repo, 'main', s);
|
|
771
|
+
await until(() => ((0, process_1.activitySnapshot)(ref)?.descendants ?? '0:none') !== '0:none');
|
|
772
|
+
const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', heartbeatTimeoutMs: 1, activityWindowMs: 50, tickMs: 20 };
|
|
773
|
+
const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner, undefined, admitted);
|
|
774
|
+
await sup.tick();
|
|
775
|
+
await new Promise((r) => setTimeout(r, 150)); // mas que la ventana, pero el arbol se movio
|
|
776
|
+
expect(await sup.tick()).not.toBe('custody');
|
|
777
|
+
const after = (0, store_1.readJournal)(repo, 'main').state;
|
|
778
|
+
expect(after.cycle.status).toBe('IN_PROGRESS');
|
|
779
|
+
expect((0, generations_1.activeGeneration)(after).state).toBe('controller-suspected-stall'); // observado, jamas matado
|
|
780
|
+
expect(child.killed).toBe(false);
|
|
781
|
+
}
|
|
782
|
+
finally {
|
|
783
|
+
// The detached shell leads its own group: kill that group, never a pattern match.
|
|
784
|
+
let shells = [];
|
|
785
|
+
try {
|
|
786
|
+
shells = (0, child_process_1.execFileSync)('pgrep', ['-P', String(ref.pid)], { encoding: 'utf8' }).split('\n').filter(Boolean).map(Number);
|
|
787
|
+
}
|
|
788
|
+
catch { /* sin hijos */ }
|
|
789
|
+
for (const pid of shells) {
|
|
790
|
+
try {
|
|
791
|
+
process.kill(-pid, 'SIGKILL');
|
|
792
|
+
}
|
|
793
|
+
catch { /* ya ausente */ }
|
|
794
|
+
}
|
|
795
|
+
child.kill('SIGKILL');
|
|
796
|
+
}
|
|
797
|
+
});
|
|
675
798
|
itPosix('runSupervisorLoop: bootstrap gen-1 con stub codex, COMPLETE => libera lock y termina su generacion (R4.1/R4.5/R2.4)', async () => {
|
|
676
799
|
initUnattendedFixture(repo);
|
|
677
800
|
jest.spyOn(fingerprint, 'reconcileUnattendedRecovery').mockReturnValue({
|
|
@@ -167,6 +167,70 @@ describe('process identity', () => {
|
|
|
167
167
|
expect(snap.groupSize).toBeGreaterThanOrEqual(1);
|
|
168
168
|
child.kill('SIGKILL');
|
|
169
169
|
});
|
|
170
|
+
// False-stall root cause (Kodria/agentic-workflow#196, v1 smoke 2026-09-26):
|
|
171
|
+
// a Codex controller waiting on a native subagent barely moves its own CPU
|
|
172
|
+
// time, and the work it waits on can run in processes OUTSIDE its process
|
|
173
|
+
// group (sandboxed tool commands). Activity must see the whole descendant
|
|
174
|
+
// tree, or a healthy wait reads as frozen and the cycle enters custody.
|
|
175
|
+
describe('descendantActivity (descendant tree, not only the process group)', () => {
|
|
176
|
+
const table = [
|
|
177
|
+
' 10 1 00:00:05',
|
|
178
|
+
' 20 10 00:00:01', // child
|
|
179
|
+
' 30 20 00:00:02', // grandchild (e.g. setsid'd sandbox command)
|
|
180
|
+
' 40 1 00:00:09', // unrelated
|
|
181
|
+
' 50 40 00:00:03', // child of the unrelated process
|
|
182
|
+
].join('\n');
|
|
183
|
+
test('counts children and grandchildren, excludes the root and unrelated processes', () => {
|
|
184
|
+
expect((0, process_1.descendantActivity)(table, 10)).toMatch(/^2:[0-9a-f]{64}$/);
|
|
185
|
+
expect((0, process_1.descendantActivity)(table, 20)).toMatch(/^1:/);
|
|
186
|
+
expect((0, process_1.descendantActivity)(table, 30)).toBe('0:none');
|
|
187
|
+
expect((0, process_1.descendantActivity)(table, 999)).toBe('0:none');
|
|
188
|
+
});
|
|
189
|
+
test('changes when a descendant accumulates cpu, appears or exits, and ignores unrelated churn', () => {
|
|
190
|
+
const base = (0, process_1.descendantActivity)(table, 10);
|
|
191
|
+
expect((0, process_1.descendantActivity)(table.replace('30 20 00:00:02', '30 20 00:00:03'), 10)).not.toBe(base);
|
|
192
|
+
expect((0, process_1.descendantActivity)(`${table}\n 60 30 00:00:00`, 10)).not.toBe(base);
|
|
193
|
+
expect((0, process_1.descendantActivity)(table.split('\n').filter((line) => !line.includes('30 20')).join('\n'), 10)).not.toBe(base);
|
|
194
|
+
expect((0, process_1.descendantActivity)(table.replace('50 40 00:00:03', '50 40 00:00:07'), 10)).toBe(base);
|
|
195
|
+
expect((0, process_1.descendantActivity)(`${table}\n 70 40 00:00:00`, 10)).toBe(base);
|
|
196
|
+
});
|
|
197
|
+
test('rejects an invalid root and never follows a ppid cycle forever', () => {
|
|
198
|
+
expect(() => (0, process_1.descendantActivity)(table, 0)).toThrow(/root pid/);
|
|
199
|
+
expect(() => (0, process_1.descendantActivity)(table, Number.NaN)).toThrow(/root pid/);
|
|
200
|
+
expect((0, process_1.descendantActivity)(' 10 1 00:00:00\n 20 10 00:00:00\n 10 20 00:00:00', 10)).toMatch(/^1:/);
|
|
201
|
+
});
|
|
202
|
+
test('ignores malformed ps lines instead of inventing processes', () => {
|
|
203
|
+
expect((0, process_1.descendantActivity)('garbage\n 20 10 00:00:01\n\n x y z', 10)).toMatch(/^1:/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
test('activitySnapshot ve un nieto fuera del process group del controlador', async () => {
|
|
207
|
+
if ((0, paths_1.isWindowsNative)())
|
|
208
|
+
return; // win32 degrada activity sin ps (ver activitySnapshot)
|
|
209
|
+
const script = "setTimeout(() => { require('child_process').spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 4000)'], { detached: true, stdio: 'ignore' }).unref(); }, 400); setTimeout(()=>{}, 4000)";
|
|
210
|
+
const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', script], process.cwd(), 'n-desc');
|
|
211
|
+
try {
|
|
212
|
+
const before = (0, process_1.activitySnapshot)(ref);
|
|
213
|
+
expect(before).not.toBeNull();
|
|
214
|
+
let after = (0, process_1.activitySnapshot)(ref);
|
|
215
|
+
for (let i = 0; i < 40 && after !== null && after.descendants === before.descendants; i++) {
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
217
|
+
after = (0, process_1.activitySnapshot)(ref);
|
|
218
|
+
}
|
|
219
|
+
expect(after).not.toBeNull();
|
|
220
|
+
// The detached grandchild lives in its own process group: the group
|
|
221
|
+
// signal cannot see it, the descendant signal must.
|
|
222
|
+
expect(after.groupSize).toBe(before.groupSize);
|
|
223
|
+
expect(after.descendants).not.toBe(before.descendants);
|
|
224
|
+
expect(after.descendants).toMatch(/^1:/);
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
try {
|
|
228
|
+
process.kill(-ref.processGroup, 'SIGKILL');
|
|
229
|
+
}
|
|
230
|
+
catch { /* ya ausente */ }
|
|
231
|
+
child.kill('SIGKILL');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
170
234
|
test('captureSelfRef captura la identidad del proceso actual (R2.1)', () => {
|
|
171
235
|
const self = (0, process_1.captureSelfRef)('nonce-self');
|
|
172
236
|
expect(self.pid).toBe(process.pid);
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|