agentic-workflow-manager 9.12.3 → 9.12.4

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.
@@ -32,7 +32,12 @@ function sameContext(a, b) {
32
32
  && a.runtime?.accountScopeDigest === b.runtime?.accountScopeDigest;
33
33
  }
34
34
  function admissionCustodyCause(reason) {
35
- if (!reason || (reason.match(/ADMISSION_[A-Z_]+/g) ?? []).length !== 1)
35
+ if (!reason)
36
+ return undefined;
37
+ if (reason === 'runtime local cambió durante generacion admitida'
38
+ || /^runtime local no verificable: (?:NATIVE_QUERY_FAILED|ACCOUNT_UNVERIFIED|MACHINE_KEY_MISSING|HOOK_CAPTURE_FAILED)$/.test(reason))
39
+ return 'runtime';
40
+ if ((reason.match(/ADMISSION_[A-Z_]+/g) ?? []).length !== 1)
36
41
  return undefined;
37
42
  const old = reason.startsWith('admisión compacta desatendida bloqueada antes de dispatch: ');
38
43
  if ((old || reason.startsWith('admisión currentness no verificable tras 3 reintentos: '))
@@ -46,6 +51,12 @@ function admissionCustodyCause(reason) {
46
51
  function assertNoAmbiguousWork(repoRoot, branch, state) {
47
52
  if ((0, types_1.activeRequestProblems)(state).length > 0)
48
53
  throw new Error('recovery bloqueado: request rechazada o corrupta activa');
54
+ if (state.routingAttempts?.some(attempt => attempt.state !== 'reserved'
55
+ || attempt.nativeAgentId !== undefined || attempt.nativeEventDigest !== undefined
56
+ || attempt.observed !== undefined))
57
+ throw new Error('recovery bloqueado: intento nativo activo o ambiguo');
58
+ if (state.dispatches.some(dispatch => dispatch.routingAttemptId !== undefined))
59
+ throw new Error('recovery bloqueado: dispatch nativo ACK-applied sin prueba de child terminado');
49
60
  for (const generation of state.generations) {
50
61
  if ((0, generations_1.controllerGenerationHasUnresolvedClaim)(repoRoot, branch, generation))
51
62
  throw new Error('recovery bloqueado: claim de controller sin identidad demostrable');
@@ -87,7 +98,7 @@ async function recoverAdmissionCustody(repoRoot, branch, input, dispatchAdmissio
87
98
  && sameContext(previous.context, context))
88
99
  return 'already-recovered';
89
100
  if (state.cycle.status !== 'BLOCKED' || !admissionCustodyCause(state.cycle.blockedReason)) {
90
- throw new Error('recovery solo admite custodia causada por admisión currentness o sensores');
101
+ throw new Error('recovery solo admite custodia causada por admisión currentness, sensores o runtime local verificable');
91
102
  }
92
103
  const latest = state.generations.at(-1);
93
104
  if (latest?.token !== input.generationToken || (latest === undefined && input.generationToken !== undefined)) {
@@ -29,6 +29,7 @@ const machine_key_1 = require("../../core/model-policy/machine-key");
29
29
  const paths_2 = require("../../core/paths");
30
30
  const native_routing_proof_1 = require("../../core/model-policy/native-routing-proof");
31
31
  const selection_policy_digest_1 = require("../../core/model-policy/selection-policy-digest");
32
+ const process_1 = require("../../core/journal/process");
32
33
  function now() { return new Date().toISOString(); }
33
34
  function currentNativeAttempt(repoRoot, envelope, eventScope, at) {
34
35
  if (!eventScope || JSON.stringify(eventScope.runtime) !== JSON.stringify(envelope.runtime))
@@ -494,7 +495,73 @@ function applyRequestToState(s, env, digest, repoRoot, eventScope) {
494
495
  }
495
496
  else if (p.routingAttemptId !== undefined)
496
497
  throw new Error('register --entity dispatch: routingAttemptId solo para compact v2');
498
+ const noLiveJobs = () => Object.values(s.jobs).every(job => ['exited', 'cancelled'].includes(job.executionState)
499
+ && [job.processRef, job.wrapperRef].every(ref => !ref || (!(0, process_1.refIsAlive)(ref) && (0, process_1.groupIsGone)(ref.processGroup))));
500
+ const historical = v2 ? s.dispatches.filter(d => d.taskId === taskId && !d.routingAttemptId) : [];
501
+ // A blocked routing verdict does not prove its native child has
502
+ // stopped. This migration permits exactly the first linked handoff;
503
+ // a subsequent retry needs a separately verified ownership path.
504
+ if (historical.some(d => d.id !== dispatchId))
505
+ throw new Error('register --entity dispatch: historical unlinked dispatch ID conflictivo');
497
506
  const existing = s.dispatches.find(d => d.id === dispatchId);
507
+ // A 9.12.2 controller could ACK the unlinked intent before it
508
+ // reserved the native route. Keep that record and ACK as history;
509
+ // only a fresh request can create the first linked handoff.
510
+ if (v2 && existing?.taskId === taskId && !existing.routingAttemptId && !existing.dispatchRequestId && routingAttemptId) {
511
+ const attempt = s.routingAttempts.find(candidate => candidate.id === routingAttemptId);
512
+ const linkedReplay = s.dispatches.find(d => d.dispatchRequestId === env.requestId);
513
+ if (linkedReplay) {
514
+ const replayAck = s.appliedRequests[env.requestId];
515
+ if (linkedReplay.taskId !== taskId || linkedReplay.routingAttemptId !== routingAttemptId
516
+ || replayAck?.outcome !== 'applied' || replayAck.idempotencyKey !== env.idempotencyKey
517
+ || replayAck.payloadDigest !== digest || replayAck.resultRef !== linkedReplay.id)
518
+ throw new Error('register --entity dispatch: routed replay conflictivo');
519
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: linkedReplay.id });
520
+ return;
521
+ }
522
+ const linkedPrior = s.dispatches.find(d => d.routingAttemptId === routingAttemptId);
523
+ if (linkedPrior) {
524
+ const priorAck = linkedPrior.dispatchRequestId ? s.appliedRequests[linkedPrior.dispatchRequestId] : undefined;
525
+ if (linkedPrior.taskId !== taskId || priorAck?.outcome !== 'applied'
526
+ || priorAck.idempotencyKey !== env.idempotencyKey || priorAck.payloadDigest !== digest)
527
+ throw new Error('register --entity dispatch: routed replay conflictivo');
528
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: linkedPrior.id });
529
+ return;
530
+ }
531
+ const oldAt = Date.parse(existing.at);
532
+ const reservedAt = Date.parse(attempt.reservedAt ?? '');
533
+ // 9.12.2 emits {...JSON.parse(--json), entity}; JSON.stringify
534
+ // preserves caller key order. The old record has no request
535
+ // link, so accept only permutations of these exact three
536
+ // fields, not an unrelated resultRef or extra payload fields.
537
+ const legacyDigests = [
538
+ { entity: 'dispatch', dispatchId: existing.id, taskId },
539
+ { entity: 'dispatch', taskId, dispatchId: existing.id },
540
+ { dispatchId: existing.id, entity: 'dispatch', taskId },
541
+ { dispatchId: existing.id, taskId, entity: 'dispatch' },
542
+ { taskId, entity: 'dispatch', dispatchId: existing.id },
543
+ { taskId, dispatchId: existing.id, entity: 'dispatch' },
544
+ ].map(payload => (0, requests_1.digestOf)(payload));
545
+ const oldAckApplied = Object.values(s.appliedRequests).some(ack => ack.outcome === 'applied'
546
+ && ack.resultRef === existing.id && legacyDigests.includes(ack.payloadDigest));
547
+ const safeLegacy = reservedForNewDispatch && Number.isFinite(oldAt) && Number.isFinite(reservedAt)
548
+ && oldAt < reservedAt
549
+ && oldAckApplied
550
+ && s.dispatches.filter(d => d.taskId === taskId).length === 1
551
+ && s.tasks.find(t => t.id === taskId)?.attempts === 1
552
+ && s.tasks.find(t => t.id === taskId)?.status === 'in-progress'
553
+ && noLiveJobs()
554
+ && !s.routingAttempts?.some(candidate => candidate.envelope.sliceId === taskId
555
+ && (candidate.nativeAgentId || candidate.nativeEventDigest || candidate.observed));
556
+ if (!safeLegacy)
557
+ throw new Error('register --entity dispatch: legacy handoff no reconciliable sin evidencia exclusiva');
558
+ const linkedId = `routed-${crypto_1.default.createHash('sha256').update(env.requestId).digest('hex').slice(0, 32)}`;
559
+ if (s.dispatches.some(d => d.id === linkedId) || s.dispatches.some(d => d.routingAttemptId === routingAttemptId))
560
+ throw new Error('register --entity dispatch: routed ID o reservation conflictivo');
561
+ s.dispatches.push({ id: linkedId, taskId, at: now(), routingAttemptId, dispatchRequestId: env.requestId });
562
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: linkedId });
563
+ return;
564
+ }
498
565
  if (existing && (existing.taskId !== taskId || existing.routingAttemptId !== routingAttemptId)) {
499
566
  throw new Error('register --entity dispatch: dispatchId conflictivo');
500
567
  }
@@ -53,7 +53,7 @@ function routingFacts(provider, identity, cwd, deps = {}) {
53
53
  capabilities: capabilities.state === 'current' ? capabilities.receipt : undefined, now: at };
54
54
  }
55
55
  function defaultDispatchAdmission(repoRoot, branch, provider, identity, deps = {}, autonomy) {
56
- return async () => {
56
+ return async (request = {}) => {
57
57
  const observed = (0, store_1.readJournal)(repoRoot, branch);
58
58
  const binding = observed.state?.schema === 2 ? observed.state.planBinding : undefined;
59
59
  if (!binding || binding.executionMode !== 'desatendido') {
@@ -92,6 +92,7 @@ function defaultDispatchAdmission(repoRoot, branch, provider, identity, deps = {
92
92
  routing = routingFacts(provider, identity, repoRoot, deps);
93
93
  return (deps.admit ?? registry_contracts_1.admitRegistryPlan)({ plan, provider, cwd: repoRoot, enabledAgents: preferences.enabledAgents,
94
94
  executionMode: 'desatendido', requireCurrent: true, verifySensors: true,
95
+ freshSensorsRequired: request.freshSensorsRequired === true,
95
96
  journalState: observed.state, journalCorrupt: observed.corrupt, planPath: binding.path,
96
97
  routing,
97
98
  controllerAutonomy: autonomy });
@@ -370,11 +371,17 @@ class Supervisor {
370
371
  try {
371
372
  local = await (0, local_event_scope_1.queryLocalEventScope)(this.repoRoot, expected.target, expected.kind);
372
373
  }
373
- catch (error) {
374
- (0, generations_1.enterCustody)(this.repoRoot, this.branch, `runtime local no verificable: ${error.message}`);
374
+ catch {
375
+ // Stable, non-sensitive custody reason remains recoverable
376
+ // after a transient native query or permission failure.
377
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'runtime local no verificable: NATIVE_QUERY_FAILED');
378
+ return 'custody';
379
+ }
380
+ if (local.state !== 'current') {
381
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, `runtime local no verificable: ${local.reason}`);
375
382
  return 'custody';
376
383
  }
377
- if (local.state !== 'current' || JSON.stringify(local.scope.runtime) !== JSON.stringify(expected)) {
384
+ if (JSON.stringify(local.scope.runtime) !== JSON.stringify(expected)) {
378
385
  (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'runtime local cambió durante generacion admitida');
379
386
  return 'custody';
380
387
  }
@@ -814,7 +821,7 @@ class Supervisor {
814
821
  // after RED edits, runtime drift, or a currentness change.
815
822
  let replacementAdmission;
816
823
  try {
817
- replacementAdmission = await this.dispatchAdmission();
824
+ replacementAdmission = await this.dispatchAdmission({ freshSensorsRequired: true });
818
825
  }
819
826
  catch (error) {
820
827
  (0, generations_1.enterCustody)(this.repoRoot, this.branch, `admisión de reemplazo no verificable: ${error.message}`);
@@ -822,7 +829,7 @@ class Supervisor {
822
829
  }
823
830
  if (replacementAdmission.state !== 'admitted' || replacementAdmission.executionMode !== 'desatendido'
824
831
  || replacementAdmission.currentness !== 'current' || replacementAdmission.sensors !== 'pass'
825
- || replacementAdmission.journal !== 'current') {
832
+ || replacementAdmission.journal !== 'current' || replacementAdmission.sensorEvidence === 'generation-bound') {
826
833
  (0, generations_1.enterCustody)(this.repoRoot, this.branch, `admisión de reemplazo bloqueada antes de dispatch: ${replacementAdmission.diagnostics.map(d => `${d.code}: ${d.message}`).join('; ')}`);
827
834
  return 'custody';
828
835
  }
@@ -166,6 +166,8 @@ function sanitizeAdmissionReport(report) {
166
166
  invalidAdmission();
167
167
  if (value.cliCurrentness !== undefined && !CLI_CURRENTNESS.has(value.cliCurrentness))
168
168
  invalidAdmission();
169
+ if (value.sensorEvidence !== undefined && value.sensorEvidence !== 'generation-bound')
170
+ invalidAdmission();
169
171
  if (value.planDigest !== undefined && (typeof value.planDigest !== 'string' || !/^[a-f0-9]{64}$/.test(value.planDigest)))
170
172
  invalidAdmission();
171
173
  if (value.executionMode !== undefined && value.executionMode !== 'interactivo' && value.executionMode !== 'desatendido')
@@ -19,6 +19,10 @@ const versioning_1 = require("../versioning");
19
19
  const check_1 = require("../currentness/check");
20
20
  const run_1 = require("../../commands/sensors/run");
21
21
  const index_1 = require("./index");
22
+ const store_1 = require("../journal/store");
23
+ const paths_1 = require("../journal/paths");
24
+ const generations_1 = require("../../commands/watch/generations");
25
+ const process_1 = require("../journal/process");
22
26
  const FRAMEWORK_CONTRACTS = ['using-awm', 'writing-plans', 'development-process', 'subagent-driven-development', 'executing-plans', 'post-implementation-qa', 'post-implementation-docs', 'harness-retro', 'finishing-a-development-branch'];
23
27
  const MAX_CONTRACT_BYTES = 1024 * 1024;
24
28
  const MAX_MANIFEST_BYTES = 256 * 1024;
@@ -217,6 +221,56 @@ function registryCompatibility(registries, consumed) {
217
221
  }
218
222
  return diagnostics;
219
223
  }
224
+ /** A controller may ask for admission while its supervisor is writing journal
225
+ * events. The already-launched generation passed sensors before launch; its
226
+ * durable intent plus verified live process identity is the evidence, not a
227
+ * second tree snapshot. A claim alone never certifies process ownership. */
228
+ function hasOwnedGenerationAdmission(input) {
229
+ if (!input.verifySensors || input.freshSensorsRequired || input.executionMode !== 'desatendido' || input.plan.state !== 'valid'
230
+ || input.journalCorrupt || !input.journalState || input.journalState.schema !== 2
231
+ || input.journalState.cycle.status !== 'IN_PROGRESS'
232
+ || input.journalState.admissionContext?.provider !== input.provider
233
+ || input.journalState.admissionContext.controllerAutonomy !== input.controllerAutonomy)
234
+ return false;
235
+ const observed = input.journalState;
236
+ const binding = observed.planBinding;
237
+ if (!binding)
238
+ return false;
239
+ if (input.plan.schema === 'compact-slices/v2' && !observed.admissionContext?.runtime)
240
+ return false;
241
+ let expectedPath;
242
+ try {
243
+ expectedPath = (0, paths_1.bindingPlanPath)(input.planPath ?? '');
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ if (binding.path !== expectedPath || binding.digest !== input.plan.planDigest || binding.schema !== input.plan.schema
249
+ || (binding.executionDigest !== undefined && binding.executionDigest !== input.plan.executionDigest))
250
+ return false;
251
+ let fresh;
252
+ try {
253
+ fresh = (0, store_1.readJournal)(input.cwd, observed.branch);
254
+ }
255
+ catch {
256
+ return false;
257
+ }
258
+ if (fresh.corrupt || !fresh.state || fresh.state.cycle.status !== 'IN_PROGRESS'
259
+ || fresh.state.revision < observed.revision || JSON.stringify(fresh.state.planBinding) !== JSON.stringify(binding)
260
+ || JSON.stringify(fresh.state.admissionContext) !== JSON.stringify(observed.admissionContext))
261
+ return false;
262
+ const generation = (0, generations_1.activeGeneration)(fresh.state);
263
+ if (!generation || generation !== fresh.state.generations.at(-1)
264
+ || generation.provider !== input.provider || !generation.controllerJobId || !generation.launchArgvDigest)
265
+ return false;
266
+ const prior = observed.generations.find(item => item.token === generation.token);
267
+ if (!prior || prior.controllerJobId !== generation.controllerJobId || prior.launchArgvDigest !== generation.launchArgvDigest)
268
+ return false;
269
+ return (generation.processRef !== undefined && generation.processRef.spawnNonce === generation.spawnNonce
270
+ && generation.processRef.argvDigest === generation.launchArgvDigest && (0, process_1.refIsAlive)(generation.processRef))
271
+ || (generation.wrapperRef !== undefined && generation.wrapperRef.spawnNonce === generation.spawnNonce
272
+ && (0, process_1.refIsAlive)(generation.wrapperRef));
273
+ }
220
274
  /** One admission authority for the public command AND actual watch dispatch.
221
275
  * No empirical execution occurs before provenance/currentness/CLI-floor gates. */
222
276
  async function admitRegistryPlan(input, dependencies = {}) {
@@ -253,12 +307,14 @@ async function admitRegistryPlan(input, dependencies = {}) {
253
307
  }
254
308
  return scope.provenance === 'proven';
255
309
  };
310
+ let generationBound = false;
256
311
  const compose = async (fields) => {
257
312
  const report = await admission({ ...input, provenance: scope.provenance, consumedRegistryComponents: scope.consumedRegistryComponents, ...fields });
313
+ const withEvidence = generationBound && report.sensors === 'pass' ? { ...report, sensorEvidence: 'generation-bound' } : report;
258
314
  if (scope.provenanceDiagnostic && report.diagnostics.some(diagnostic => diagnostic.code === 'ADMISSION_CURRENTNESS_PROVENANCE_REQUIRED')) {
259
- return { ...report, diagnostics: report.diagnostics.map(diagnostic => diagnostic.code === 'ADMISSION_CURRENTNESS_PROVENANCE_REQUIRED' ? scope.provenanceDiagnostic : diagnostic) };
315
+ return { ...withEvidence, diagnostics: report.diagnostics.map(diagnostic => diagnostic.code === 'ADMISSION_CURRENTNESS_PROVENANCE_REQUIRED' ? scope.provenanceDiagnostic : diagnostic) };
260
316
  }
261
- return report;
317
+ return withEvidence;
262
318
  };
263
319
  let currentness = input.currentness;
264
320
  let compatibilityDiagnostics;
@@ -277,7 +333,10 @@ async function admitRegistryPlan(input, dependencies = {}) {
277
333
  if (compatibilityGate.currentness !== 'current')
278
334
  return compatibilityGate;
279
335
  }
280
- const sensors = input.verifySensors ? input.sensors ?? await (dependencies.runSensors ?? run_1.runSensors)({ cwd: input.cwd, all: true, readOnly: true }) : undefined;
336
+ generationBound = input.sensors === undefined && hasOwnedGenerationAdmission(input);
337
+ const sensors = input.verifySensors ? input.sensors ?? (generationBound
338
+ ? { sensors: [], overall: 'pass', reason: 'generation-bound-admission' }
339
+ : await (dependencies.runSensors ?? run_1.runSensors)({ cwd: input.cwd, all: true, readOnly: true })) : undefined;
281
340
  if (input.requireCurrent && input.verifySensors) {
282
341
  if (!refresh())
283
342
  return compose({ currentness, sensors });
@@ -286,5 +345,17 @@ async function admitRegistryPlan(input, dependencies = {}) {
286
345
  return currentnessGate;
287
346
  compatibilityDiagnostics = registryCompatibility(registries, scope.consumedRegistryComponents);
288
347
  }
348
+ if (generationBound && input.plan.schema === 'compact-slices/v2') {
349
+ const routing = input.routing ?? await dependencies.readRouting?.();
350
+ const expected = input.journalState?.admissionContext?.runtime;
351
+ if (!expected || !routing?.runtime || routing.runtime.target !== input.provider
352
+ || routing.runtime.kind !== expected.kind || routing.runtime.version !== expected.version
353
+ || routing.runtime.accountScopeDigest !== expected.accountScopeDigest) {
354
+ return compose({ currentness, compatibilityDiagnostics, sensors,
355
+ routing: { ...routing, eventIssue: { code: 'ADMISSION_GENERATION_RUNTIME_MISMATCH',
356
+ message: 'Live runtime identity differs from the admitted controller generation; stop and recover with a fresh admission.' } } });
357
+ }
358
+ return compose({ currentness, compatibilityDiagnostics, sensors, routing });
359
+ }
289
360
  return compose({ currentness, compatibilityDiagnostics, sensors, ...(dependencies.readRouting ? { routingReader: dependencies.readRouting } : {}) });
290
361
  }
@@ -51,6 +51,7 @@ const supervisor_1 = require("../../../src/commands/watch/supervisor");
51
51
  const admission_recovery_1 = require("../../../src/commands/watch/admission-recovery");
52
52
  const watch_1 = require("../../../src/commands/watch");
53
53
  const supervisorModule = __importStar(require("../../../src/commands/watch/supervisor"));
54
+ const routing_approval_1 = require("../../helpers/routing-approval");
54
55
  const DIGEST = 'a'.repeat(64);
55
56
  const identity = { kind: 'native', version: '1.0.0', accountScopeDigest: DIGEST };
56
57
  const input = { provider: 'codex', routingIdentity: identity, controllerAutonomy: 'approval-free',
@@ -157,6 +158,29 @@ describe('issue #196: admission custody recovery', () => {
157
158
  expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
158
159
  expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).toBe('recovered');
159
160
  });
161
+ test.each([
162
+ 'runtime local no verificable: NATIVE_QUERY_FAILED',
163
+ 'runtime local cambió durante generacion admitida',
164
+ ])('runtime custody %s recovers only after strict offline revalidation', async (reason) => {
165
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'plans', 'cycle.md'), fs_1.default.readFileSync(path_1.default.join(__dirname, '../../core/plan/fixtures/compact-slices-v2/valid.md'), 'utf8'));
166
+ const v2Plan = (0, validate_1.validatePlanFile)('plans/cycle.md', repo);
167
+ if (v2Plan.state !== 'valid' || !v2Plan.executionDigest)
168
+ throw new Error('invalid v2 recovery fixture');
169
+ const state = (0, store_1.readJournal)(repo, 'main').state;
170
+ state.planBinding = { ...state.planBinding, schema: v2Plan.schema, digest: v2Plan.planDigest,
171
+ executionDigest: v2Plan.executionDigest, executionIdentitySchema: 'awm-plan-execution/v1' };
172
+ state.cycle.blockedReason = reason;
173
+ (0, store_1.writeJournal)(repo, 'main', state);
174
+ const revision = (0, store_1.readJournal)(repo, 'main').state.revision;
175
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, blocked('ADMISSION_SENSORS_BLOCKED', 'current', 'not-certified'))).rejects.toThrow();
176
+ expect((0, store_1.readJournal)(repo, 'main').state.revision).toBe(revision);
177
+ expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).toBe('recovered');
178
+ const recovered = (0, store_1.readJournal)(repo, 'main').state;
179
+ expect(recovered.cycle.status).toBe('IN_PROGRESS');
180
+ expect(recovered.planBinding?.schema).toBe('compact-slices/v2');
181
+ expect(recovered.admissionRecoveries?.at(-1)?.blockedReason).toBe(reason);
182
+ expect(recovered.generations).toEqual(state.generations);
183
+ });
160
184
  test('changed binding, active request problem, pending job and identity mismatch all fail closed', async () => {
161
185
  const check = async (mutate, pattern) => {
162
186
  const state = (0, store_1.readJournal)(repo, 'main').state;
@@ -181,6 +205,26 @@ describe('issue #196: admission custody recovery', () => {
181
205
  const sJob = (0, store_1.readJournal)(repo, 'main').state;
182
206
  sJob.jobs = {};
183
207
  (0, store_1.writeJournal)(repo, 'main', sJob);
208
+ await check(state => {
209
+ state.routingAttempts = [{ schema: 'routing-attempt/v1', id: 'route-1',
210
+ obligationId: 'S1-implementer', lineageId: 'S1', attempt: 1, envelope: routing_approval_1.routingEnvelope,
211
+ envelopeDigest: 'e'.repeat(64), fingerprint: 'f'.repeat(64), state: 'active',
212
+ nativeAgentId: 'native-child' }];
213
+ }, /nativ|routing|attempt|intento/i);
214
+ const sRouting = (0, store_1.readJournal)(repo, 'main').state;
215
+ sRouting.routingAttempts = [];
216
+ (0, store_1.writeJournal)(repo, 'main', sRouting);
217
+ await check(state => {
218
+ state.routingAttempts = [{ schema: 'routing-attempt/v1', id: 'route-1',
219
+ obligationId: 'S1-implementer', lineageId: 'S1', attempt: 1, envelope: routing_approval_1.routingEnvelope,
220
+ envelopeDigest: 'e'.repeat(64), fingerprint: 'f'.repeat(64), state: 'reserved' }];
221
+ state.dispatches.push({ id: 'linked', taskId: 'S1', at: new Date().toISOString(),
222
+ routingAttemptId: 'route-1', dispatchRequestId: 'linked-ack' });
223
+ }, /nativ|routing|attempt|intento|dispatch/i);
224
+ const sPendingChild = (0, store_1.readJournal)(repo, 'main').state;
225
+ sPendingChild.routingAttempts = [];
226
+ sPendingChild.dispatches = [];
227
+ (0, store_1.writeJournal)(repo, 'main', sPendingChild);
184
228
  await check(state => { state.generations[0].processRef = (0, process_1.captureSelfRef)('recovery-test'); }, /controller/i);
185
229
  const sController = (0, store_1.readJournal)(repo, 'main').state;
186
230
  sController.generations[0].processRef = undefined;
@@ -55,6 +55,7 @@ const supervisor_1 = require("../../../src/commands/watch/supervisor");
55
55
  const supervisorModule = __importStar(require("../../../src/commands/watch/supervisor"));
56
56
  const generationModule = __importStar(require("../../../src/commands/watch/generations"));
57
57
  const adapterModule = __importStar(require("../../../src/core/journal/adapter"));
58
+ const registry_contracts_1 = require("../../../src/core/admission/registry-contracts");
58
59
  const routing_approval_1 = require("../../helpers/routing-approval");
59
60
  const routing_approval_2 = require("../../helpers/routing-approval");
60
61
  const canonical_1 = require("../../../src/core/model-policy/canonical");
@@ -67,6 +68,7 @@ const localScope = __importStar(require("../../../src/core/model-policy/local-ev
67
68
  const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
68
69
  const paths_1 = require("../../../src/core/journal/paths");
69
70
  const paths_2 = require("../../../src/core/journal/paths");
71
+ const process_1 = require("../../../src/core/journal/process");
70
72
  function v2State() {
71
73
  const state = (0, types_1.emptyState)('main');
72
74
  state.schema = 2;
@@ -90,6 +92,11 @@ function dispatch(state, payload) {
90
92
  generationToken: 'g1', idempotencyKey: 'dispatch-key', payload: { entity: 'dispatch',
91
93
  dispatchId: 'dispatch-S1', taskId: 'S1', ...payload } }, 'd'.repeat(64), process.cwd());
92
94
  }
95
+ function legacyDispatchDigest(rawJson = '{"dispatchId":"dispatch-S1","taskId":"S1"}') {
96
+ // 9.12.2 job register emits { ...JSON.parse(--json), entity: --entity }.
97
+ // Preserve the caller's key order: digestOf uses JSON.stringify bytes.
98
+ return (0, requests_1.digestOf)({ ...JSON.parse(rawJson), entity: 'dispatch' });
99
+ }
93
100
  function boundRepo(version = 'v1') {
94
101
  const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-196-bound-'));
95
102
  (0, child_process_1.execFileSync)('git', ['init', '-q', '-b', 'main'], { cwd: repo });
@@ -181,6 +188,138 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
181
188
  expect(state.dispatches).toHaveLength(1);
182
189
  expect(state.tasks[0].attempts).toBe(1);
183
190
  });
191
+ test('9.12.2 unlinked S1 dispatch is preserved while a fresh routed dispatch gets its own real ACK', () => {
192
+ expect(legacyDispatchDigest()).toBe('01edb739d12771ff6ffea2ea4296bf477b745f76afb66b4b20cb90cd470f5666');
193
+ const state = v2State();
194
+ const oldAt = new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString();
195
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1', at: oldAt });
196
+ state.tasks[0].attempts = 1;
197
+ state.tasks[0].status = 'in-progress';
198
+ state.jobs['completed-test'] = { id: 'completed-test', fingerprint: 'fixture', commandDigest: 'fixture',
199
+ argv: ['true'], cwd: '.', paths: [], expandedPaths: [], executionState: 'exited',
200
+ observationState: 'progressing', phaseTimestamps: { exited: new Date().toISOString() } };
201
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
202
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
203
+ state.appliedRequests['old-dispatch-retry'] = { ...state.appliedRequests['old-dispatch-request'], requestId: 'old-dispatch-retry' };
204
+ const oldRecord = structuredClone(state.dispatches[0]);
205
+ const oldAck = structuredClone(state.appliedRequests['old-dispatch-request']);
206
+ const retryAck = structuredClone(state.appliedRequests['old-dispatch-retry']);
207
+ dispatch(state, { routingAttemptId: 'route-1' });
208
+ expect(state.dispatches[0]).toEqual(oldRecord);
209
+ expect(state.appliedRequests['old-dispatch-request']).toEqual(oldAck);
210
+ expect(state.appliedRequests['old-dispatch-retry']).toEqual(retryAck);
211
+ expect(state.dispatches).toHaveLength(2);
212
+ const linked = state.dispatches[1];
213
+ expect(linked).toMatchObject({ taskId: 'S1', routingAttemptId: 'route-1', dispatchRequestId: 'dispatch-request' });
214
+ expect(linked.id).not.toBe('dispatch-S1');
215
+ expect(state.appliedRequests['dispatch-request']).toMatchObject({ outcome: 'applied', resultRef: linked.id });
216
+ expect(state.tasks[0].attempts).toBe(1);
217
+ expect((0, types_1.isWellFormedState)(state)).toBe(true);
218
+ dispatch(state, { routingAttemptId: 'route-1' });
219
+ expect(state.dispatches).toHaveLength(2);
220
+ expect(state.tasks[0].attempts).toBe(1);
221
+ expect(() => (0, apply_1.applyRequestToState)(state, { kind: 'register-entity', requestId: 'unrelated-dispatch-request',
222
+ generationToken: 'g1', idempotencyKey: 'different-dispatch-key', payload: { entity: 'dispatch',
223
+ dispatchId: 'dispatch-S1', taskId: 'S1', routingAttemptId: 'route-1' } }, 'd'.repeat(64), process.cwd()))
224
+ .toThrow(/conflictivo/);
225
+ expect(state.dispatches).toHaveLength(2);
226
+ });
227
+ test('historical ACK matching is independent of JSON key order but not payload identity', () => {
228
+ for (const rawJson of [
229
+ '{"taskId":"S1","dispatchId":"dispatch-S1"}',
230
+ '{"entity":"dispatch","taskId":"S1","dispatchId":"dispatch-S1"}',
231
+ ]) {
232
+ const state = v2State();
233
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
234
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
235
+ state.tasks[0].status = 'in-progress';
236
+ state.tasks[0].attempts = 1;
237
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
238
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(rawJson),
239
+ outcome: 'applied', resultRef: 'dispatch-S1' };
240
+ dispatch(state, { routingAttemptId: 'route-1' });
241
+ expect(state.dispatches).toHaveLength(2);
242
+ expect(state.appliedRequests['dispatch-request']).toMatchObject({ outcome: 'applied', resultRef: state.dispatches[1].id });
243
+ }
244
+ });
245
+ test('historical unlinked S1 cannot be bypassed with a different requested dispatch ID', () => {
246
+ const state = v2State();
247
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
248
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
249
+ state.tasks[0].status = 'in-progress';
250
+ state.tasks[0].attempts = 1;
251
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
252
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
253
+ expect(() => dispatch(state, { dispatchId: 'new-S1', routingAttemptId: 'route-1' })).toThrow(/legacy|historical|conflict/i);
254
+ expect(state.dispatches).toHaveLength(1);
255
+ expect(state.tasks[0].attempts).toBe(1);
256
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
257
+ });
258
+ test('historical handoff requires a matching applied legacy dispatch ACK', () => {
259
+ for (const ack of ['missing', 'unrelated', 'rejected']) {
260
+ const state = v2State();
261
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
262
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
263
+ state.tasks[0].status = 'in-progress';
264
+ state.tasks[0].attempts = 1;
265
+ if (ack !== 'missing')
266
+ state.appliedRequests['old-dispatch-request'] = {
267
+ requestId: 'old-dispatch-request', idempotencyKey: 'old-dispatch-key',
268
+ payloadDigest: ack === 'unrelated' ? (0, requests_1.digestOf)({ entity: 'task', taskId: 'S1' }) : legacyDispatchDigest(),
269
+ outcome: ack === 'rejected' ? 'rejected-stale-generation' : 'applied', resultRef: 'dispatch-S1',
270
+ };
271
+ expect(() => dispatch(state, { routingAttemptId: 'route-1' })).toThrow(/legacy|historical|ACK|evidencia/i);
272
+ expect(state.dispatches).toHaveLength(1);
273
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
274
+ }
275
+ });
276
+ test('a failed linked handoff does not authorize another child without native ownership proof', () => {
277
+ const state = v2State();
278
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
279
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
280
+ state.tasks[0].status = 'in-progress';
281
+ state.tasks[0].attempts = 1;
282
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
283
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
284
+ dispatch(state, { routingAttemptId: 'route-1' });
285
+ state.routingAttempts[0].state = 'blocked';
286
+ state.routingAttempts[0].verdict = 'fail';
287
+ state.routingAttempts.push({ ...state.routingAttempts[0], id: 'route-2', state: 'reserved',
288
+ verdict: undefined, reservationRequestId: 'reserve-2' });
289
+ state.appliedRequests['reserve-2'] = { requestId: 'reserve-2', idempotencyKey: 'reserve-2',
290
+ payloadDigest: 'd'.repeat(64), outcome: 'applied', resultRef: 'route-2' };
291
+ expect(() => (0, apply_1.applyRequestToState)(state, { kind: 'register-entity', requestId: 'dispatch-request-2',
292
+ generationToken: 'g1', idempotencyKey: 'dispatch-key-2', payload: { entity: 'dispatch',
293
+ dispatchId: 'dispatch-S1-retry', taskId: 'S1', routingAttemptId: 'route-2' } }, 'd'.repeat(64), process.cwd()))
294
+ .toThrow(/historical|conflict/i);
295
+ expect(state.dispatches).toHaveLength(2);
296
+ expect(state.tasks[0].attempts).toBe(1);
297
+ expect(state.appliedRequests['dispatch-request-2']).toBeUndefined();
298
+ expect((0, types_1.isWellFormedState)(state)).toBe(true);
299
+ });
300
+ test('legacy dispatch reconciliation refuses ambiguous or already-observed work', () => {
301
+ for (const scenario of ['two-old-dispatches', 'observed', 'wrong-order', 'existing-job']) {
302
+ const state = v2State();
303
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
304
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
305
+ state.tasks[0].attempts = 1;
306
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
307
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
308
+ if (scenario === 'two-old-dispatches')
309
+ state.dispatches.push({ id: 'other-old', taskId: 'S1', at: state.dispatches[0].at });
310
+ if (scenario === 'observed')
311
+ state.routingAttempts[0].nativeAgentId = 'native-child';
312
+ if (scenario === 'wrong-order')
313
+ state.dispatches[0].at = new Date(Date.parse(state.routingAttempts[0].reservedAt) + 60_000).toISOString();
314
+ if (scenario === 'existing-job')
315
+ state.jobs['unreconciled'] = { id: 'unreconciled',
316
+ fingerprint: 'fixture', commandDigest: 'fixture', argv: ['true'], cwd: '.', paths: [], expandedPaths: [],
317
+ executionState: 'received', observationState: 'progressing', phaseTimestamps: {} };
318
+ expect(() => dispatch(state, { routingAttemptId: 'route-1' })).toThrow();
319
+ expect(state.dispatches.some(item => item.routingAttemptId === 'route-1')).toBe(false);
320
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
321
+ }
322
+ });
184
323
  test('an exact dispatch replay after native observation does not duplicate S1', () => {
185
324
  const state = v2State();
186
325
  dispatch(state, { routingAttemptId: 'route-1' });
@@ -336,6 +475,118 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
336
475
  fs_1.default.rmSync(repo, { recursive: true, force: true });
337
476
  }
338
477
  });
478
+ test('controller admission reuses only an owned generation sensor pass, never a new generation', async () => {
479
+ const repo = boundRepo();
480
+ try {
481
+ (0, generations_1.beginGeneration)(repo, 'main');
482
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
483
+ const state = (0, store_1.readJournal)(repo, 'main').state;
484
+ const generation = state.generations[0];
485
+ const claim = (0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), generation.controllerJobId, generation.spawnNonce);
486
+ fs_1.default.writeFileSync(claim, '{}');
487
+ generation.wrapperRef = (0, process_1.captureSelfRef)(generation.spawnNonce);
488
+ state.admissionContext = { provider: 'codex', controllerAutonomy: 'approval-free' };
489
+ (0, store_1.writeJournal)(repo, 'main', state);
490
+ const sensorRun = jest.fn(async () => ({ sensors: [], overall: 'not_certified',
491
+ projectRoot: repo, manifestPath: path_1.default.join(repo, '.awm', 'sensors.json'), mode: 'project-sensors' }));
492
+ const admit = async (freshSensorsRequired = false) => (0, registry_contracts_1.admitRegistryPlan)({
493
+ plan: (0, validate_1.validatePlanFile)('plans/cycle.md', repo), provider: 'codex', cwd: repo,
494
+ executionMode: 'desatendido', planPath: 'plans/cycle.md', journalState: (0, store_1.readJournal)(repo, 'main').state,
495
+ verifySensors: true, controllerAutonomy: 'approval-free',
496
+ ...(freshSensorsRequired ? { freshSensorsRequired: true } : {}),
497
+ }, { runSensors: sensorRun });
498
+ const active = await admit();
499
+ expect(active.state).toBe('admitted');
500
+ expect(active).toHaveProperty('sensorEvidence', 'generation-bound');
501
+ expect(sensorRun).not.toHaveBeenCalled();
502
+ const replacement = await admit(true);
503
+ expect(replacement).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
504
+ expect(sensorRun).toHaveBeenCalledTimes(1);
505
+ const claimOnly = (0, store_1.readJournal)(repo, 'main').state;
506
+ claimOnly.generations[0].wrapperRef = undefined;
507
+ (0, store_1.writeJournal)(repo, 'main', claimOnly);
508
+ expect(await admit()).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
509
+ expect(sensorRun).toHaveBeenCalledTimes(2);
510
+ const mismatched = (0, store_1.readJournal)(repo, 'main').state;
511
+ mismatched.generations[0].processRef = (0, process_1.captureSelfRef)('different-nonce');
512
+ (0, store_1.writeJournal)(repo, 'main', mismatched);
513
+ expect(await admit()).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
514
+ expect(sensorRun).toHaveBeenCalledTimes(3);
515
+ fs_1.default.unlinkSync(claim);
516
+ const unowned = await admit();
517
+ expect(unowned).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
518
+ expect(sensorRun).toHaveBeenCalledTimes(4);
519
+ const stale = (0, store_1.readJournal)(repo, 'main').state;
520
+ stale.generations[0].processRef = { pid: 99999997, processGroup: 99999997,
521
+ startTime: 'Thu Sep 24 20:00:00 2026', spawnNonce: generation.spawnNonce,
522
+ argvDigest: generation.launchArgvDigest, psArgsDigest: 'b'.repeat(64) };
523
+ (0, store_1.writeJournal)(repo, 'main', stale);
524
+ expect(await admit()).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
525
+ expect(sensorRun).toHaveBeenCalledTimes(5);
526
+ (0, generations_1.beginGeneration)(repo, 'main');
527
+ const next = await admit();
528
+ expect(next).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
529
+ expect(sensorRun).toHaveBeenCalledTimes(6);
530
+ }
531
+ finally {
532
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
533
+ }
534
+ });
535
+ test('generation-bound sensor reuse rejects a changed live v2 runtime', async () => {
536
+ const repo = boundRepo('v2');
537
+ try {
538
+ (0, generations_1.beginGeneration)(repo, 'main');
539
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
540
+ const state = (0, store_1.readJournal)(repo, 'main').state;
541
+ const generation = state.generations[0];
542
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), generation.controllerJobId, generation.spawnNonce), '{}');
543
+ generation.wrapperRef = (0, process_1.captureSelfRef)(generation.spawnNonce);
544
+ state.admissionContext = { provider: 'codex', controllerAutonomy: 'approval-free',
545
+ runtime: { kind: 'native', version: '0.156.0', accountScopeDigest: 'a'.repeat(64) } };
546
+ (0, store_1.writeJournal)(repo, 'main', state);
547
+ const sensorRun = jest.fn(async () => ({ sensors: [], overall: 'not_certified' }));
548
+ const report = await (0, registry_contracts_1.admitRegistryPlan)({ plan: (0, validate_1.validatePlanFile)('plans/cycle.md', repo),
549
+ provider: 'codex', cwd: repo, executionMode: 'desatendido', planPath: 'plans/cycle.md',
550
+ journalState: (0, store_1.readJournal)(repo, 'main').state, verifySensors: true, controllerAutonomy: 'approval-free',
551
+ }, { runSensors: sensorRun, readRouting: async () => ({ runtime: { target: 'codex', kind: 'native',
552
+ version: '0.157.0', accountScopeDigest: 'a'.repeat(64) } }) });
553
+ expect(report.state).toBe('blocked');
554
+ expect(report.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'ADMISSION_GENERATION_RUNTIME_MISMATCH' })]));
555
+ expect(sensorRun).not.toHaveBeenCalled();
556
+ }
557
+ finally {
558
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
559
+ }
560
+ });
561
+ test('active v2 controller admission reuses owned sensors while still checking routing evidence', async () => {
562
+ const repo = boundRepo('v2');
563
+ try {
564
+ (0, generations_1.beginGeneration)(repo, 'main');
565
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
566
+ const state = (0, store_1.readJournal)(repo, 'main').state;
567
+ const generation = state.generations[0];
568
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), generation.controllerJobId, generation.spawnNonce), '{}');
569
+ generation.wrapperRef = (0, process_1.captureSelfRef)(generation.spawnNonce);
570
+ state.admissionContext = { provider: 'codex', controllerAutonomy: 'approval-free',
571
+ runtime: { kind: routing_approval_1.routingEnvelope.runtime.kind, version: routing_approval_1.routingEnvelope.runtime.version,
572
+ accountScopeDigest: routing_approval_1.routingEnvelope.runtime.accountScopeDigest } };
573
+ (0, store_1.writeJournal)(repo, 'main', state);
574
+ const evidence = (0, routing_approval_2.routingApproval)();
575
+ const sensorRun = jest.fn(async () => ({ sensors: [], overall: 'not_certified' }));
576
+ const readRouting = jest.fn(async () => ({ runtime: routing_approval_1.routingEnvelope.runtime,
577
+ policy: evidence.policy, capabilities: evidence.capabilities, now: evidence.checkedAt }));
578
+ const report = await (0, registry_contracts_1.admitRegistryPlan)({ plan: (0, validate_1.validatePlanFile)('plans/cycle.md', repo),
579
+ provider: 'codex', cwd: repo, executionMode: 'desatendido', planPath: 'plans/cycle.md',
580
+ journalState: (0, store_1.readJournal)(repo, 'main').state, verifySensors: true, controllerAutonomy: 'approval-free',
581
+ }, { runSensors: sensorRun, readRouting });
582
+ expect(report).toMatchObject({ state: 'admitted', sensors: 'pass', sensorEvidence: 'generation-bound' });
583
+ expect(sensorRun).not.toHaveBeenCalled();
584
+ expect(readRouting).toHaveBeenCalledTimes(1);
585
+ }
586
+ finally {
587
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
588
+ }
589
+ });
339
590
  test('the same RED continuation applies to compact v2 with unchanged native scope', async () => {
340
591
  const repo = boundRepo('v2');
341
592
  try {
@@ -381,6 +632,33 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
381
632
  fs_1.default.rmSync(repo, { recursive: true, force: true });
382
633
  }
383
634
  });
635
+ test.each(['unverified', 'throws'])('an active v2 generation records an inconclusive local runtime query (%s) without claiming drift', async (scenario) => {
636
+ const repo = boundRepo('v2');
637
+ try {
638
+ (0, generations_1.beginGeneration)(repo, 'main');
639
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
640
+ const gen = (0, store_1.readJournal)(repo, 'main').state.generations[0];
641
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), gen.controllerJobId, gen.spawnNonce), '{}');
642
+ const query = jest.spyOn(localScope, 'queryLocalEventScope');
643
+ if (scenario === 'throws')
644
+ query.mockRejectedValue(new Error('permission denied while reading a private path'));
645
+ else
646
+ query.mockResolvedValue({ state: 'unverified', reason: 'NATIVE_QUERY_FAILED' });
647
+ const expected = routing_approval_1.routingEnvelope.runtime;
648
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, routingIdentity: {
649
+ kind: expected.kind, version: expected.version, accountScopeDigest: expected.accountScopeDigest,
650
+ }, controllerAutonomy: 'approval-free' };
651
+ let spawns = 0;
652
+ expect(await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawns++; }, undefined, async () => admitted, true).tick()).toBe('custody');
653
+ const final = (0, store_1.readJournal)(repo, 'main').state;
654
+ expect(final.cycle.blockedReason).toBe('runtime local no verificable: NATIVE_QUERY_FAILED');
655
+ expect(spawns).toBe(0);
656
+ }
657
+ finally {
658
+ jest.restoreAllMocks();
659
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
660
+ }
661
+ });
384
662
  test('an active v2 generation refuses dispatch when local runtime identity drifts', async () => {
385
663
  const repo = boundRepo('v2');
386
664
  try {
@@ -433,15 +711,14 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
433
711
  (0, request_1.requestJob)(repo, 'main', state.generations[0].token, [process.execPath, '-e', 'process.exit(0)'], [], '.');
434
712
  const adapter = adapterModule.adapterFor('codex');
435
713
  jest.spyOn(adapterModule, 'adapterFor').mockReturnValue({ ...adapter, activity: () => null, safeToReplace: () => 'safe' });
436
- jest.spyOn(generationModule, 'resolveGeneration').mockImplementation(async () => {
437
- const current = (0, store_1.readJournal)(repo, 'main').state;
438
- current.generations[0].state = 'terminated';
439
- (0, store_1.writeJournal)(repo, 'main', current);
440
- return 'proven-dead';
441
- });
714
+ jest.spyOn(generationModule, 'resolveGeneration').mockResolvedValue('proven-dead');
442
715
  let admissions = 0;
443
716
  let jobSpawns = 0;
444
- const sup = new supervisor_1.Supervisor(repo, 'main', { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, heartbeatTimeoutMs: 0, activityWindowMs: 0 }, () => { jobSpawns += 1; }, undefined, async () => { admissions += 1; return redSensor; }, true);
717
+ const sup = new supervisor_1.Supervisor(repo, 'main', { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, heartbeatTimeoutMs: 0, activityWindowMs: 0 }, () => { jobSpawns += 1; }, undefined, async (request) => {
718
+ admissions += 1;
719
+ expect(request).toMatchObject({ freshSensorsRequired: true });
720
+ return { ...admitted, sensorEvidence: 'generation-bound' };
721
+ }, true);
445
722
  expect(await sup.tick()).toBe('custody');
446
723
  expect((0, store_1.readJournal)(repo, 'main').state.generations).toHaveLength(1);
447
724
  expect(admissions).toBe(1);
@@ -625,7 +902,7 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
625
902
  fs_1.default.rmSync(repo, { recursive: true, force: true });
626
903
  }
627
904
  }, 10000);
628
- test('recovered v2 S1 uses real CLI reservation, dispatch ack, observation and one continuation', async () => {
905
+ test('recovered historical v2 S1 uses real CLI reservation, new dispatch ack, observation and one continuation', async () => {
629
906
  const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-196-integral-'));
630
907
  const previousHome = process.env.AWM_HOME;
631
908
  const operator = path_1.default.join(repo, 'operator');
@@ -695,6 +972,16 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
695
972
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
696
973
  expect((0, store_1.readJournal)(repo, 'main').state.requestProblems).toEqual([]);
697
974
  expect(cli('ack', task.requestId).state).toBe('applied');
975
+ // Durable 9.12.2 intent predates the route reservation. Neither
976
+ // the record nor its old ACK may be rewritten to imply a child.
977
+ const historical = (0, store_1.readJournal)(repo, 'main').state;
978
+ const historicalAt = new Date(Date.now() - 60_000).toISOString();
979
+ historical.dispatches.push({ id: 'dispatch-S1', taskId: 'S1', at: historicalAt });
980
+ historical.tasks[0].attempts = 1;
981
+ historical.tasks[0].status = 'in-progress';
982
+ historical.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
983
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
984
+ (0, store_1.writeJournal)(repo, 'main', historical);
698
985
  const reserve = cli('routing-reserve', '--generation', generation.token, '--obligation', 'S1-implementer', '--lineage', 'lineage-S1', '--envelope-file', envelopeFile, '--fingerprint', 'f'.repeat(64));
699
986
  expect(cli('ack', reserve.requestId).state).toBe('pending');
700
987
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
@@ -707,7 +994,9 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
707
994
  expect(cli('ack', queuedDispatch.requestId).state).toBe('pending');
708
995
  expect(fs_1.default.existsSync(path_1.default.join(repo, 's1-red.test.ts'))).toBe(false);
709
996
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
710
- expect(cli('ack', queuedDispatch.requestId)).toMatchObject({ state: 'applied', resultRef: 'dispatch-S1' });
997
+ const routedAck = cli('ack', queuedDispatch.requestId);
998
+ expect(routedAck.state).toBe('applied');
999
+ expect(routedAck.resultRef).not.toBe('dispatch-S1');
711
1000
  const worker = (0, child_process_1.spawnSync)(process.execPath, ['-e', `require('fs').writeFileSync(${JSON.stringify(path_1.default.join(repo, 's1-red.test.ts'))}, 'test("RED", () => { throw new Error("RED"); });\\n')`], {
712
1001
  cwd: repo, encoding: 'utf8', env: { ...process.env, AWM_NO_UPDATE_CHECK: '1' },
713
1002
  });
@@ -722,7 +1011,7 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
722
1011
  expect(cli('ack', observation.requestId).state).toBe('applied');
723
1012
  const replay = cli('register', '--generation', generation.token, '--entity', 'dispatch', '--json', dispatchPayload);
724
1013
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
725
- expect(cli('ack', replay.requestId).state).toBe('applied');
1014
+ expect(cli('ack', replay.requestId)).toMatchObject({ state: 'applied', resultRef: routedAck.resultRef });
726
1015
  expect(cli('reconcile').nextAction).toBeTruthy();
727
1016
  const unsupported = (0, child_process_1.spawnSync)(process.execPath, [compiled, 'job', 'reconcile', '--generation', generation.token], {
728
1017
  cwd: repo, encoding: 'utf8', env: { ...process.env, AWM_NO_UPDATE_CHECK: '1' },
@@ -731,7 +1020,11 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
731
1020
  expect(unsupported.stderr).toMatch(/generation|unknown option/i);
732
1021
  const final = (0, store_1.readJournal)(repo, 'main').state;
733
1022
  expect(final.tasks).toHaveLength(1);
734
- expect(final.dispatches).toHaveLength(1);
1023
+ expect(final.dispatches).toHaveLength(2);
1024
+ expect(final.dispatches[0]).toEqual({ id: 'dispatch-S1', taskId: 'S1', at: historicalAt });
1025
+ expect(final.appliedRequests['old-dispatch-request']).toEqual(historical.appliedRequests['old-dispatch-request']);
1026
+ expect(final.dispatches[1]).toMatchObject({ id: routedAck.resultRef,
1027
+ taskId: 'S1', routingAttemptId: attemptId, dispatchRequestId: queuedDispatch.requestId });
735
1028
  expect(final.routingAttempts).toHaveLength(1);
736
1029
  expect(final.routingAttempts[0].state).toBe('active');
737
1030
  expect(final.tasks[0].attempts).toBe(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.12.3",
3
+ "version": "9.12.4",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file