agentic-workflow-manager 9.13.0 → 9.13.1

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.
@@ -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) {
@@ -672,6 +672,47 @@ describe('supervisor loop', () => {
672
672
  expect(child.killed).toBe(false); // y NO mato al controlador
673
673
  child.kill('SIGKILL');
674
674
  });
675
+ // v1 smoke 2026-09-26 (#196): a Codex controller waiting on a native
676
+ // subagent kept an idle leader while the work ran in processes outside its
677
+ // process group, and the supervisor entered a false custody. Work in the
678
+ // descendant tree is activity: observe, never custody, while it moves.
679
+ itPosix('espera sana: lider ocioso + descendiente fuera del grupo trabajando => observa, no custodia (R4.2)', async () => {
680
+ initUnattendedFixture(repo);
681
+ (0, generations_1.beginGeneration)(repo, 'main');
682
+ const worker = "require('child_process').spawn('sh', ['-c', 'while :; do sleep 0.02; done'], { detached: true, stdio: 'ignore' }).unref(); setTimeout(()=>{}, 20000)";
683
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', worker], process.cwd(), 'nWait');
684
+ try {
685
+ let s = (0, store_1.readJournal)(repo, 'main').state;
686
+ (0, generations_1.activeGeneration)(s).processRef = ref;
687
+ s.controllerHeartbeatAt = new Date(Date.now() - 3600000).toISOString(); // heartbeat vencido: bloqueado en la espera
688
+ (0, store_1.writeJournal)(repo, 'main', s);
689
+ await until(() => ((0, process_1.activitySnapshot)(ref)?.descendants ?? '0:none') !== '0:none');
690
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', heartbeatTimeoutMs: 1, activityWindowMs: 50, tickMs: 20 };
691
+ const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner, undefined, admitted);
692
+ await sup.tick();
693
+ await new Promise((r) => setTimeout(r, 150)); // mas que la ventana, pero el arbol se movio
694
+ expect(await sup.tick()).not.toBe('custody');
695
+ const after = (0, store_1.readJournal)(repo, 'main').state;
696
+ expect(after.cycle.status).toBe('IN_PROGRESS');
697
+ expect((0, generations_1.activeGeneration)(after).state).toBe('controller-suspected-stall'); // observado, jamas matado
698
+ expect(child.killed).toBe(false);
699
+ }
700
+ finally {
701
+ // The detached shell leads its own group: kill that group, never a pattern match.
702
+ let shells = [];
703
+ try {
704
+ shells = (0, child_process_1.execFileSync)('pgrep', ['-P', String(ref.pid)], { encoding: 'utf8' }).split('\n').filter(Boolean).map(Number);
705
+ }
706
+ catch { /* sin hijos */ }
707
+ for (const pid of shells) {
708
+ try {
709
+ process.kill(-pid, 'SIGKILL');
710
+ }
711
+ catch { /* ya ausente */ }
712
+ }
713
+ child.kill('SIGKILL');
714
+ }
715
+ });
675
716
  itPosix('runSupervisorLoop: bootstrap gen-1 con stub codex, COMPLETE => libera lock y termina su generacion (R4.1/R4.5/R2.4)', async () => {
676
717
  initUnattendedFixture(repo);
677
718
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.13.0",
3
+ "version": "9.13.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file