agentic-workflow-manager 9.13.1 → 9.14.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.
@@ -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, missing required evidence is an
249
- // unsafe recovery fact and must remain fail-closed.
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 verificationItems = [...before0.state.cycleVerificationPlan, ...before0.state.tasks.flatMap(task => task.verificationPlan)];
487
- const tests = verificationItems.filter(item => item.kind === 'test');
488
- const sensorItems = verificationItems.filter(item => item.kind === 'sensors');
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 unresolvedVerification = terminalTaskClaims && evidenceGate.reasons.some(reason => [
495
- 'dangling-reference', 'unsatisfied-plan', 'adverse-verdict',
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') {
@@ -233,8 +233,14 @@ async function admitPlan(input) {
233
233
  return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED', `Sensor verdict is ${sensors}.`)], { planDigest: plan.planDigest, provider, executionMode: mode, currentness: input.requireCurrent ? 'current' : 'not-checked', cliCurrentness, sensors });
234
234
  }
235
235
  if (mode === 'desatendido') {
236
- const journal = journalStatus(input, plan);
237
- if (journal !== 'current')
236
+ // The durable controller (journal + awm watch + jobs) is opt-in, as it
237
+ // was before 9.8.0: native v1 dispatch with no journal on its branch
238
+ // runs as one provider session. Creating a journal is the opt-in, and
239
+ // from then on it must be current. v2 routing custody lives in the
240
+ // journal, so awm-routed work still requires it.
241
+ const observed = journalStatus(input, plan);
242
+ const journal = observed === 'missing' && plan.schema === 'compact-slices/v1' ? 'not-required' : observed;
243
+ if (journal !== 'current' && journal !== 'not-required')
238
244
  return blocked(input, [diagnostic('ADMISSION_JOURNAL_BINDING_REQUIRED', 'Unattended execution requires a healthy schema-2 journal binding for this exact plan; run watch --init --plan.')], { planDigest: plan.planDigest, provider, executionMode: mode, journal, currentness: input.requireCurrent ? 'current' : 'not-checked', cliCurrentness, sensors: input.verifySensors ? 'pass' : 'not-required' });
239
245
  const capabilities = unattendedCapabilities(provider, input.controllerAutonomy);
240
246
  const resolution = { outcome: capabilities.unattendedController === 'supported' ? 'native' : 'blocked', provider, capabilities, evidenceVersion: 'r1-v1', diagnostics: [] };
@@ -309,7 +309,7 @@ describe('plan admit Commander wiring', () => {
309
309
  process.exitCode = undefined;
310
310
  }
311
311
  });
312
- it('derives desatendido from the canonical plan header and requires a journal without a flag', async () => {
312
+ it('derives desatendido from the canonical plan header; a native v1 plan needs no journal (opt-in durable custody)', async () => {
313
313
  const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-admit-header-'));
314
314
  const output = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
315
315
  try {
@@ -327,7 +327,11 @@ describe('plan admit Commander wiring', () => {
327
327
  readPreferences: () => ({ defaultAgent: 'codex', enabledAgents: ['codex'], installMethod: 'symlink', defaultScope: 'local' }),
328
328
  });
329
329
  await program.parseAsync(['node', 'awm', 'plan', 'admit', 'plan.md', '--provider', 'codex', '--cwd', root, '--json']);
330
- expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({ state: 'blocked', executionMode: 'desatendido', journal: expect.stringMatching(/missing|corrupt/) });
330
+ const withoutPosture = JSON.parse(String(output.mock.calls[0][0]));
331
+ expect(withoutPosture).toMatchObject({ state: 'blocked', executionMode: 'desatendido', journal: 'not-required' });
332
+ expect(withoutPosture.diagnostics[0]).toMatchObject({ code: 'ADMISSION_CONTROLLER_AUTONOMY_REQUIRED' });
333
+ await program.parseAsync(['node', 'awm', 'plan', 'admit', 'plan.md', '--provider', 'codex', '--cwd', root, '--controller-autonomy', 'approval-free', '--json']);
334
+ expect(JSON.parse(String(output.mock.calls[1][0]))).toMatchObject({ state: 'admitted', executionMode: 'desatendido', journal: 'not-required' });
331
335
  }
332
336
  finally {
333
337
  output.mockRestore();
@@ -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;
@@ -143,12 +143,40 @@ describe('admitPlan', () => {
143
143
  expect(report).toMatchObject({ state: 'blocked', executionMode: 'interactivo', provider: 'cursor', journal: 'not-required' });
144
144
  expect(report.diagnostics[0].code).toBe('ADMISSION_CAPABILITY_UNVERIFIED');
145
145
  });
146
- it('reports the schema-2 journal prerequisite for otherwise admissible unattended work', async () => {
147
- const unattended = { ...valid, manifest: { ...valid.manifest, executionMode: 'desatendido' } };
148
- const report = await (0, admission_1.admitPlan)({ plan: unattended, provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'] });
149
- expect(report).toMatchObject({ state: 'blocked', executionMode: 'desatendido', journal: 'missing' });
150
- expect(report.diagnostics[0]).toMatchObject({ code: 'ADMISSION_JOURNAL_BINDING_REQUIRED' });
151
- expect(report.forecast).toBeUndefined();
146
+ // The durable controller (journal + awm watch + jobs) is opt-in again for
147
+ // native v1 dispatch, as it was before 9.8.0: a v1 unattended plan with no
148
+ // journal on its branch runs as one native provider session. Creating a
149
+ // journal (awm watch --init) is the opt-in, after which it must be current.
150
+ // v2 routing custody lives in the journal, so awm-routed still requires it.
151
+ describe('the durable journal is opt-in for native v1 unattended work', () => {
152
+ const unattended = () => ({ ...valid, manifest: { ...valid.manifest, executionMode: 'desatendido' } });
153
+ const base = () => ({ plan: unattended(), provider: 'codex', cwd: process.cwd(), enabledAgents: ['codex'], planPath: 'docs/plan.md', controllerAutonomy: 'approval-free' });
154
+ it('admits a v1 unattended plan with no journal as journal not-required', async () => {
155
+ const report = await (0, admission_1.admitPlan)(base());
156
+ expect(report).toMatchObject({ state: 'admitted', executionMode: 'desatendido', journal: 'not-required' });
157
+ expect(report.diagnostics.map(d => d.code)).not.toContain('ADMISSION_JOURNAL_BINDING_REQUIRED');
158
+ });
159
+ it('keeps a journal that exists (the opt-in) fail-closed when it is corrupt or stale', async () => {
160
+ const corrupt = await (0, admission_1.admitPlan)({ ...base(), journalCorrupt: true });
161
+ expect(corrupt).toMatchObject({ state: 'blocked', journal: 'corrupt' });
162
+ expect(corrupt.diagnostics[0]).toMatchObject({ code: 'ADMISSION_JOURNAL_BINDING_REQUIRED' });
163
+ const stale = await (0, admission_1.admitPlan)({ ...base(), journalState: { ...(0, types_1.emptyState)('main'), schema: 2, planBinding: { path: 'docs/plan.md', digest: 'b'.repeat(64), schema: 'compact-slices/v1', executionMode: 'desatendido', boundAt: '2026-09-15T00:00:00.000Z' } } });
164
+ expect(stale).toMatchObject({ state: 'blocked', journal: 'stale' });
165
+ const legacy = await (0, admission_1.admitPlan)({ ...base(), journalState: (0, types_1.emptyState)('main') });
166
+ expect(legacy).toMatchObject({ state: 'blocked', journal: 'stale' });
167
+ });
168
+ it('still requires the journal for awm-routed v2 unattended work', async () => {
169
+ const v2Unattended = { ...valid, schema: 'compact-slices/v2', manifest: { ...valid.manifest, executionMode: 'desatendido', schema: 'compact-slices/v2', slices: valid.manifest.slices.map(slice => ({ ...slice, implementerProfile: 'mechanical' })) } };
170
+ const report = await (0, admission_1.admitPlan)({ ...base(), plan: v2Unattended });
171
+ expect(report).toMatchObject({ state: 'blocked', executionMode: 'desatendido', journal: 'missing' });
172
+ expect(report.diagnostics[0]).toMatchObject({ code: 'ADMISSION_JOURNAL_BINDING_REQUIRED' });
173
+ expect(report.forecast).toBeUndefined();
174
+ });
175
+ it('keeps the controller posture gate for journal-less unattended work', async () => {
176
+ const report = await (0, admission_1.admitPlan)({ ...base(), controllerAutonomy: undefined });
177
+ expect(report.state).toBe('blocked');
178
+ expect(report.diagnostics[0]).toMatchObject({ code: 'ADMISSION_CONTROLLER_AUTONOMY_REQUIRED' });
179
+ });
152
180
  });
153
181
  it('admits unattended work only with an exact schema-2 plan binding', async () => {
154
182
  const unattended = { ...valid, manifest: { ...valid.manifest, executionMode: 'desatendido' } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.13.1",
3
+ "version": "9.14.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file