agentic-workflow-manager 9.12.3 → 9.12.5

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,80 @@ 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 appliedAcks = Object.values(s.appliedRequests);
546
+ const oldAckIndex = appliedAcks.findIndex(ack => ack.outcome === 'applied'
547
+ && ack.resultRef === existing.id && legacyDigests.includes(ack.payloadDigest));
548
+ const reservationAckIndex = appliedAcks.findIndex(ack => ack.requestId === attempt.reservationRequestId);
549
+ // The single writer appends ACKs to appliedRequests and persists
550
+ // their insertion order. Equal millisecond timestamps need this
551
+ // additional durable ordering evidence, not an inferred child.
552
+ const legacyPrecedesReservation = oldAt < reservedAt
553
+ || (oldAt === reservedAt && oldAckIndex >= 0 && reservationAckIndex > oldAckIndex);
554
+ const safeLegacy = reservedForNewDispatch && Number.isFinite(oldAt) && Number.isFinite(reservedAt)
555
+ && legacyPrecedesReservation
556
+ && oldAckIndex >= 0
557
+ && s.dispatches.filter(d => d.taskId === taskId).length === 1
558
+ && s.tasks.find(t => t.id === taskId)?.attempts === 1
559
+ && s.tasks.find(t => t.id === taskId)?.status === 'in-progress'
560
+ && noLiveJobs()
561
+ && !s.routingAttempts?.some(candidate => candidate.envelope.sliceId === taskId
562
+ && (candidate.nativeAgentId || candidate.nativeEventDigest || candidate.observed));
563
+ if (!safeLegacy)
564
+ throw new Error('register --entity dispatch: legacy handoff no reconciliable sin evidencia exclusiva');
565
+ const linkedId = `routed-${crypto_1.default.createHash('sha256').update(env.requestId).digest('hex').slice(0, 32)}`;
566
+ if (s.dispatches.some(d => d.id === linkedId) || s.dispatches.some(d => d.routingAttemptId === routingAttemptId))
567
+ throw new Error('register --entity dispatch: routed ID o reservation conflictivo');
568
+ s.dispatches.push({ id: linkedId, taskId, at: now(), routingAttemptId, dispatchRequestId: env.requestId });
569
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: linkedId });
570
+ return;
571
+ }
498
572
  if (existing && (existing.taskId !== taskId || existing.routingAttemptId !== routingAttemptId)) {
499
573
  throw new Error('register --entity dispatch: dispatchId conflictivo');
500
574
  }
@@ -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,169 @@ 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('equal-millisecond legacy timestamps reconcile only when the durable dispatch ACK precedes the reservation ACK', () => {
228
+ const state = v2State();
229
+ const at = state.routingAttempts[0].reservedAt;
230
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1', at });
231
+ state.tasks[0].attempts = 1;
232
+ state.tasks[0].status = 'in-progress';
233
+ const reservationAck = state.appliedRequests['reserve-1'];
234
+ const historicalAck = { requestId: 'old-dispatch-request', idempotencyKey: 'old-dispatch-key',
235
+ payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
236
+ state.appliedRequests = { 'old-dispatch-request': historicalAck, 'reserve-1': reservationAck };
237
+ const durable = JSON.parse(JSON.stringify(state));
238
+ expect(Object.keys(durable.appliedRequests)).toEqual(['old-dispatch-request', 'reserve-1']);
239
+ dispatch(durable, { routingAttemptId: 'route-1' });
240
+ expect(durable.dispatches).toHaveLength(2);
241
+ expect(durable.dispatches[0]).toEqual({ id: 'dispatch-S1', taskId: 'S1', at });
242
+ expect(durable.appliedRequests['old-dispatch-request']).toEqual(historicalAck);
243
+ expect(durable.tasks[0].attempts).toBe(1);
244
+ expect((0, types_1.isWellFormedState)(durable)).toBe(true);
245
+ });
246
+ test('equal-millisecond legacy timestamps do not infer order from a later dispatch ACK', () => {
247
+ const state = v2State();
248
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1', at: state.routingAttempts[0].reservedAt });
249
+ state.tasks[0].attempts = 1;
250
+ state.tasks[0].status = 'in-progress';
251
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
252
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(),
253
+ outcome: 'applied', resultRef: 'dispatch-S1' };
254
+ expect(() => dispatch(state, { routingAttemptId: 'route-1' })).toThrow(/legacy handoff/);
255
+ expect(state.dispatches).toHaveLength(1);
256
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
257
+ });
258
+ test('historical ACK matching is independent of JSON key order but not payload identity', () => {
259
+ for (const rawJson of [
260
+ '{"taskId":"S1","dispatchId":"dispatch-S1"}',
261
+ '{"entity":"dispatch","taskId":"S1","dispatchId":"dispatch-S1"}',
262
+ ]) {
263
+ const state = v2State();
264
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
265
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
266
+ state.tasks[0].status = 'in-progress';
267
+ state.tasks[0].attempts = 1;
268
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
269
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(rawJson),
270
+ outcome: 'applied', resultRef: 'dispatch-S1' };
271
+ dispatch(state, { routingAttemptId: 'route-1' });
272
+ expect(state.dispatches).toHaveLength(2);
273
+ expect(state.appliedRequests['dispatch-request']).toMatchObject({ outcome: 'applied', resultRef: state.dispatches[1].id });
274
+ }
275
+ });
276
+ test('historical unlinked S1 cannot be bypassed with a different requested dispatch ID', () => {
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
+ expect(() => dispatch(state, { dispatchId: 'new-S1', routingAttemptId: 'route-1' })).toThrow(/legacy|historical|conflict/i);
285
+ expect(state.dispatches).toHaveLength(1);
286
+ expect(state.tasks[0].attempts).toBe(1);
287
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
288
+ });
289
+ test('historical handoff requires a matching applied legacy dispatch ACK', () => {
290
+ for (const ack of ['missing', 'unrelated', 'rejected']) {
291
+ const state = v2State();
292
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
293
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
294
+ state.tasks[0].status = 'in-progress';
295
+ state.tasks[0].attempts = 1;
296
+ if (ack !== 'missing')
297
+ state.appliedRequests['old-dispatch-request'] = {
298
+ requestId: 'old-dispatch-request', idempotencyKey: 'old-dispatch-key',
299
+ payloadDigest: ack === 'unrelated' ? (0, requests_1.digestOf)({ entity: 'task', taskId: 'S1' }) : legacyDispatchDigest(),
300
+ outcome: ack === 'rejected' ? 'rejected-stale-generation' : 'applied', resultRef: 'dispatch-S1',
301
+ };
302
+ expect(() => dispatch(state, { routingAttemptId: 'route-1' })).toThrow(/legacy|historical|ACK|evidencia/i);
303
+ expect(state.dispatches).toHaveLength(1);
304
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
305
+ }
306
+ });
307
+ test('a failed linked handoff does not authorize another child without native ownership proof', () => {
308
+ const state = v2State();
309
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
310
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
311
+ state.tasks[0].status = 'in-progress';
312
+ state.tasks[0].attempts = 1;
313
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
314
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
315
+ dispatch(state, { routingAttemptId: 'route-1' });
316
+ state.routingAttempts[0].state = 'blocked';
317
+ state.routingAttempts[0].verdict = 'fail';
318
+ state.routingAttempts.push({ ...state.routingAttempts[0], id: 'route-2', state: 'reserved',
319
+ verdict: undefined, reservationRequestId: 'reserve-2' });
320
+ state.appliedRequests['reserve-2'] = { requestId: 'reserve-2', idempotencyKey: 'reserve-2',
321
+ payloadDigest: 'd'.repeat(64), outcome: 'applied', resultRef: 'route-2' };
322
+ expect(() => (0, apply_1.applyRequestToState)(state, { kind: 'register-entity', requestId: 'dispatch-request-2',
323
+ generationToken: 'g1', idempotencyKey: 'dispatch-key-2', payload: { entity: 'dispatch',
324
+ dispatchId: 'dispatch-S1-retry', taskId: 'S1', routingAttemptId: 'route-2' } }, 'd'.repeat(64), process.cwd()))
325
+ .toThrow(/historical|conflict/i);
326
+ expect(state.dispatches).toHaveLength(2);
327
+ expect(state.tasks[0].attempts).toBe(1);
328
+ expect(state.appliedRequests['dispatch-request-2']).toBeUndefined();
329
+ expect((0, types_1.isWellFormedState)(state)).toBe(true);
330
+ });
331
+ test('legacy dispatch reconciliation refuses ambiguous or already-observed work', () => {
332
+ for (const scenario of ['two-old-dispatches', 'observed', 'wrong-order', 'existing-job']) {
333
+ const state = v2State();
334
+ state.dispatches.push({ id: 'dispatch-S1', taskId: 'S1',
335
+ at: new Date(Date.parse(state.routingAttempts[0].reservedAt) - 60_000).toISOString() });
336
+ state.tasks[0].attempts = 1;
337
+ state.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
338
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
339
+ if (scenario === 'two-old-dispatches')
340
+ state.dispatches.push({ id: 'other-old', taskId: 'S1', at: state.dispatches[0].at });
341
+ if (scenario === 'observed')
342
+ state.routingAttempts[0].nativeAgentId = 'native-child';
343
+ if (scenario === 'wrong-order')
344
+ state.dispatches[0].at = new Date(Date.parse(state.routingAttempts[0].reservedAt) + 60_000).toISOString();
345
+ if (scenario === 'existing-job')
346
+ state.jobs['unreconciled'] = { id: 'unreconciled',
347
+ fingerprint: 'fixture', commandDigest: 'fixture', argv: ['true'], cwd: '.', paths: [], expandedPaths: [],
348
+ executionState: 'received', observationState: 'progressing', phaseTimestamps: {} };
349
+ expect(() => dispatch(state, { routingAttemptId: 'route-1' })).toThrow();
350
+ expect(state.dispatches.some(item => item.routingAttemptId === 'route-1')).toBe(false);
351
+ expect(state.appliedRequests['dispatch-request']).toBeUndefined();
352
+ }
353
+ });
184
354
  test('an exact dispatch replay after native observation does not duplicate S1', () => {
185
355
  const state = v2State();
186
356
  dispatch(state, { routingAttemptId: 'route-1' });
@@ -336,6 +506,118 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
336
506
  fs_1.default.rmSync(repo, { recursive: true, force: true });
337
507
  }
338
508
  });
509
+ test('controller admission reuses only an owned generation sensor pass, never a new generation', async () => {
510
+ const repo = boundRepo();
511
+ try {
512
+ (0, generations_1.beginGeneration)(repo, 'main');
513
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
514
+ const state = (0, store_1.readJournal)(repo, 'main').state;
515
+ const generation = state.generations[0];
516
+ const claim = (0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), generation.controllerJobId, generation.spawnNonce);
517
+ fs_1.default.writeFileSync(claim, '{}');
518
+ generation.wrapperRef = (0, process_1.captureSelfRef)(generation.spawnNonce);
519
+ state.admissionContext = { provider: 'codex', controllerAutonomy: 'approval-free' };
520
+ (0, store_1.writeJournal)(repo, 'main', state);
521
+ const sensorRun = jest.fn(async () => ({ sensors: [], overall: 'not_certified',
522
+ projectRoot: repo, manifestPath: path_1.default.join(repo, '.awm', 'sensors.json'), mode: 'project-sensors' }));
523
+ const admit = async (freshSensorsRequired = false) => (0, registry_contracts_1.admitRegistryPlan)({
524
+ plan: (0, validate_1.validatePlanFile)('plans/cycle.md', repo), provider: 'codex', cwd: repo,
525
+ executionMode: 'desatendido', planPath: 'plans/cycle.md', journalState: (0, store_1.readJournal)(repo, 'main').state,
526
+ verifySensors: true, controllerAutonomy: 'approval-free',
527
+ ...(freshSensorsRequired ? { freshSensorsRequired: true } : {}),
528
+ }, { runSensors: sensorRun });
529
+ const active = await admit();
530
+ expect(active.state).toBe('admitted');
531
+ expect(active).toHaveProperty('sensorEvidence', 'generation-bound');
532
+ expect(sensorRun).not.toHaveBeenCalled();
533
+ const replacement = await admit(true);
534
+ expect(replacement).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
535
+ expect(sensorRun).toHaveBeenCalledTimes(1);
536
+ const claimOnly = (0, store_1.readJournal)(repo, 'main').state;
537
+ claimOnly.generations[0].wrapperRef = undefined;
538
+ (0, store_1.writeJournal)(repo, 'main', claimOnly);
539
+ expect(await admit()).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
540
+ expect(sensorRun).toHaveBeenCalledTimes(2);
541
+ const mismatched = (0, store_1.readJournal)(repo, 'main').state;
542
+ mismatched.generations[0].processRef = (0, process_1.captureSelfRef)('different-nonce');
543
+ (0, store_1.writeJournal)(repo, 'main', mismatched);
544
+ expect(await admit()).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
545
+ expect(sensorRun).toHaveBeenCalledTimes(3);
546
+ fs_1.default.unlinkSync(claim);
547
+ const unowned = await admit();
548
+ expect(unowned).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
549
+ expect(sensorRun).toHaveBeenCalledTimes(4);
550
+ const stale = (0, store_1.readJournal)(repo, 'main').state;
551
+ stale.generations[0].processRef = { pid: 99999997, processGroup: 99999997,
552
+ startTime: 'Thu Sep 24 20:00:00 2026', spawnNonce: generation.spawnNonce,
553
+ argvDigest: generation.launchArgvDigest, psArgsDigest: 'b'.repeat(64) };
554
+ (0, store_1.writeJournal)(repo, 'main', stale);
555
+ expect(await admit()).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
556
+ expect(sensorRun).toHaveBeenCalledTimes(5);
557
+ (0, generations_1.beginGeneration)(repo, 'main');
558
+ const next = await admit();
559
+ expect(next).toMatchObject({ state: 'blocked', sensors: 'not-certified' });
560
+ expect(sensorRun).toHaveBeenCalledTimes(6);
561
+ }
562
+ finally {
563
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
564
+ }
565
+ });
566
+ test('generation-bound sensor reuse rejects a changed live v2 runtime', async () => {
567
+ const repo = boundRepo('v2');
568
+ try {
569
+ (0, generations_1.beginGeneration)(repo, 'main');
570
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
571
+ const state = (0, store_1.readJournal)(repo, 'main').state;
572
+ const generation = state.generations[0];
573
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), generation.controllerJobId, generation.spawnNonce), '{}');
574
+ generation.wrapperRef = (0, process_1.captureSelfRef)(generation.spawnNonce);
575
+ state.admissionContext = { provider: 'codex', controllerAutonomy: 'approval-free',
576
+ runtime: { kind: 'native', version: '0.156.0', accountScopeDigest: 'a'.repeat(64) } };
577
+ (0, store_1.writeJournal)(repo, 'main', state);
578
+ const sensorRun = jest.fn(async () => ({ sensors: [], overall: 'not_certified' }));
579
+ const report = await (0, registry_contracts_1.admitRegistryPlan)({ plan: (0, validate_1.validatePlanFile)('plans/cycle.md', repo),
580
+ provider: 'codex', cwd: repo, executionMode: 'desatendido', planPath: 'plans/cycle.md',
581
+ journalState: (0, store_1.readJournal)(repo, 'main').state, verifySensors: true, controllerAutonomy: 'approval-free',
582
+ }, { runSensors: sensorRun, readRouting: async () => ({ runtime: { target: 'codex', kind: 'native',
583
+ version: '0.157.0', accountScopeDigest: 'a'.repeat(64) } }) });
584
+ expect(report.state).toBe('blocked');
585
+ expect(report.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'ADMISSION_GENERATION_RUNTIME_MISMATCH' })]));
586
+ expect(sensorRun).not.toHaveBeenCalled();
587
+ }
588
+ finally {
589
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
590
+ }
591
+ });
592
+ test('active v2 controller admission reuses owned sensors while still checking routing evidence', async () => {
593
+ const repo = boundRepo('v2');
594
+ try {
595
+ (0, generations_1.beginGeneration)(repo, 'main');
596
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
597
+ const state = (0, store_1.readJournal)(repo, 'main').state;
598
+ const generation = state.generations[0];
599
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), generation.controllerJobId, generation.spawnNonce), '{}');
600
+ generation.wrapperRef = (0, process_1.captureSelfRef)(generation.spawnNonce);
601
+ state.admissionContext = { provider: 'codex', controllerAutonomy: 'approval-free',
602
+ runtime: { kind: routing_approval_1.routingEnvelope.runtime.kind, version: routing_approval_1.routingEnvelope.runtime.version,
603
+ accountScopeDigest: routing_approval_1.routingEnvelope.runtime.accountScopeDigest } };
604
+ (0, store_1.writeJournal)(repo, 'main', state);
605
+ const evidence = (0, routing_approval_2.routingApproval)();
606
+ const sensorRun = jest.fn(async () => ({ sensors: [], overall: 'not_certified' }));
607
+ const readRouting = jest.fn(async () => ({ runtime: routing_approval_1.routingEnvelope.runtime,
608
+ policy: evidence.policy, capabilities: evidence.capabilities, now: evidence.checkedAt }));
609
+ const report = await (0, registry_contracts_1.admitRegistryPlan)({ plan: (0, validate_1.validatePlanFile)('plans/cycle.md', repo),
610
+ provider: 'codex', cwd: repo, executionMode: 'desatendido', planPath: 'plans/cycle.md',
611
+ journalState: (0, store_1.readJournal)(repo, 'main').state, verifySensors: true, controllerAutonomy: 'approval-free',
612
+ }, { runSensors: sensorRun, readRouting });
613
+ expect(report).toMatchObject({ state: 'admitted', sensors: 'pass', sensorEvidence: 'generation-bound' });
614
+ expect(sensorRun).not.toHaveBeenCalled();
615
+ expect(readRouting).toHaveBeenCalledTimes(1);
616
+ }
617
+ finally {
618
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
619
+ }
620
+ });
339
621
  test('the same RED continuation applies to compact v2 with unchanged native scope', async () => {
340
622
  const repo = boundRepo('v2');
341
623
  try {
@@ -381,6 +663,33 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
381
663
  fs_1.default.rmSync(repo, { recursive: true, force: true });
382
664
  }
383
665
  });
666
+ test.each(['unverified', 'throws'])('an active v2 generation records an inconclusive local runtime query (%s) without claiming drift', async (scenario) => {
667
+ const repo = boundRepo('v2');
668
+ try {
669
+ (0, generations_1.beginGeneration)(repo, 'main');
670
+ (0, generations_1.launchControllerGeneration)(repo, 'main', 'codex', { schema: 'controller-recovery/v1', kind: 'resume-cycle' }, () => { });
671
+ const gen = (0, store_1.readJournal)(repo, 'main').state.generations[0];
672
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'main'), gen.controllerJobId, gen.spawnNonce), '{}');
673
+ const query = jest.spyOn(localScope, 'queryLocalEventScope');
674
+ if (scenario === 'throws')
675
+ query.mockRejectedValue(new Error('permission denied while reading a private path'));
676
+ else
677
+ query.mockResolvedValue({ state: 'unverified', reason: 'NATIVE_QUERY_FAILED' });
678
+ const expected = routing_approval_1.routingEnvelope.runtime;
679
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, routingIdentity: {
680
+ kind: expected.kind, version: expected.version, accountScopeDigest: expected.accountScopeDigest,
681
+ }, controllerAutonomy: 'approval-free' };
682
+ let spawns = 0;
683
+ expect(await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawns++; }, undefined, async () => admitted, true).tick()).toBe('custody');
684
+ const final = (0, store_1.readJournal)(repo, 'main').state;
685
+ expect(final.cycle.blockedReason).toBe('runtime local no verificable: NATIVE_QUERY_FAILED');
686
+ expect(spawns).toBe(0);
687
+ }
688
+ finally {
689
+ jest.restoreAllMocks();
690
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
691
+ }
692
+ });
384
693
  test('an active v2 generation refuses dispatch when local runtime identity drifts', async () => {
385
694
  const repo = boundRepo('v2');
386
695
  try {
@@ -433,15 +742,14 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
433
742
  (0, request_1.requestJob)(repo, 'main', state.generations[0].token, [process.execPath, '-e', 'process.exit(0)'], [], '.');
434
743
  const adapter = adapterModule.adapterFor('codex');
435
744
  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
- });
745
+ jest.spyOn(generationModule, 'resolveGeneration').mockResolvedValue('proven-dead');
442
746
  let admissions = 0;
443
747
  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);
748
+ const sup = new supervisor_1.Supervisor(repo, 'main', { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, heartbeatTimeoutMs: 0, activityWindowMs: 0 }, () => { jobSpawns += 1; }, undefined, async (request) => {
749
+ admissions += 1;
750
+ expect(request).toMatchObject({ freshSensorsRequired: true });
751
+ return { ...admitted, sensorEvidence: 'generation-bound' };
752
+ }, true);
445
753
  expect(await sup.tick()).toBe('custody');
446
754
  expect((0, store_1.readJournal)(repo, 'main').state.generations).toHaveLength(1);
447
755
  expect(admissions).toBe(1);
@@ -625,7 +933,7 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
625
933
  fs_1.default.rmSync(repo, { recursive: true, force: true });
626
934
  }
627
935
  }, 10000);
628
- test('recovered v2 S1 uses real CLI reservation, dispatch ack, observation and one continuation', async () => {
936
+ test('recovered historical v2 S1 uses real CLI reservation, new dispatch ack, observation and one continuation', async () => {
629
937
  const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-196-integral-'));
630
938
  const previousHome = process.env.AWM_HOME;
631
939
  const operator = path_1.default.join(repo, 'operator');
@@ -695,6 +1003,16 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
695
1003
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
696
1004
  expect((0, store_1.readJournal)(repo, 'main').state.requestProblems).toEqual([]);
697
1005
  expect(cli('ack', task.requestId).state).toBe('applied');
1006
+ // Durable 9.12.2 intent and ACK precede the route reservation.
1007
+ // Equal timestamp granularity alone must not imply a child.
1008
+ const historical = (0, store_1.readJournal)(repo, 'main').state;
1009
+ let historicalAt = new Date(Date.now() - 60_000).toISOString();
1010
+ historical.dispatches.push({ id: 'dispatch-S1', taskId: 'S1', at: historicalAt });
1011
+ historical.tasks[0].attempts = 1;
1012
+ historical.tasks[0].status = 'in-progress';
1013
+ historical.appliedRequests['old-dispatch-request'] = { requestId: 'old-dispatch-request',
1014
+ idempotencyKey: 'old-dispatch-key', payloadDigest: legacyDispatchDigest(), outcome: 'applied', resultRef: 'dispatch-S1' };
1015
+ (0, store_1.writeJournal)(repo, 'main', historical);
698
1016
  const reserve = cli('routing-reserve', '--generation', generation.token, '--obligation', 'S1-implementer', '--lineage', 'lineage-S1', '--envelope-file', envelopeFile, '--fingerprint', 'f'.repeat(64));
699
1017
  expect(cli('ack', reserve.requestId).state).toBe('pending');
700
1018
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
@@ -702,12 +1020,22 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
702
1020
  const reservation = cli('ack', reserve.requestId);
703
1021
  expect(reservation.state).toBe('applied');
704
1022
  const attemptId = reservation.resultRef;
1023
+ // Reproduce the real journal's millisecond tie without changing
1024
+ // the durable ACK order or adding any native evidence.
1025
+ const tied = (0, store_1.readJournal)(repo, 'main').state;
1026
+ historicalAt = tied.routingAttempts.find(attempt => attempt.id === attemptId).reservedAt;
1027
+ tied.dispatches[0].at = historicalAt;
1028
+ expect(Object.keys(tied.appliedRequests).indexOf('old-dispatch-request'))
1029
+ .toBeLessThan(Object.keys(tied.appliedRequests).indexOf(reserve.requestId));
1030
+ (0, store_1.writeJournal)(repo, 'main', tied);
705
1031
  const dispatchPayload = JSON.stringify({ dispatchId: 'dispatch-S1', taskId: 'S1', routingAttemptId: attemptId });
706
1032
  const queuedDispatch = cli('register', '--generation', generation.token, '--entity', 'dispatch', '--json', dispatchPayload);
707
1033
  expect(cli('ack', queuedDispatch.requestId).state).toBe('pending');
708
1034
  expect(fs_1.default.existsSync(path_1.default.join(repo, 's1-red.test.ts'))).toBe(false);
709
1035
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
710
- expect(cli('ack', queuedDispatch.requestId)).toMatchObject({ state: 'applied', resultRef: 'dispatch-S1' });
1036
+ const routedAck = cli('ack', queuedDispatch.requestId);
1037
+ expect(routedAck.state).toBe('applied');
1038
+ expect(routedAck.resultRef).not.toBe('dispatch-S1');
711
1039
  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
1040
  cwd: repo, encoding: 'utf8', env: { ...process.env, AWM_NO_UPDATE_CHECK: '1' },
713
1041
  });
@@ -722,7 +1050,7 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
722
1050
  expect(cli('ack', observation.requestId).state).toBe('applied');
723
1051
  const replay = cli('register', '--generation', generation.token, '--entity', 'dispatch', '--json', dispatchPayload);
724
1052
  (0, apply_1.consumePendingRequests)(repo, 'main', generation.token, scope);
725
- expect(cli('ack', replay.requestId).state).toBe('applied');
1053
+ expect(cli('ack', replay.requestId)).toMatchObject({ state: 'applied', resultRef: routedAck.resultRef });
726
1054
  expect(cli('reconcile').nextAction).toBeTruthy();
727
1055
  const unsupported = (0, child_process_1.spawnSync)(process.execPath, [compiled, 'job', 'reconcile', '--generation', generation.token], {
728
1056
  cwd: repo, encoding: 'utf8', env: { ...process.env, AWM_NO_UPDATE_CHECK: '1' },
@@ -731,7 +1059,11 @@ describe('issue #196 reopened: supervised S1 handoff', () => {
731
1059
  expect(unsupported.stderr).toMatch(/generation|unknown option/i);
732
1060
  const final = (0, store_1.readJournal)(repo, 'main').state;
733
1061
  expect(final.tasks).toHaveLength(1);
734
- expect(final.dispatches).toHaveLength(1);
1062
+ expect(final.dispatches).toHaveLength(2);
1063
+ expect(final.dispatches[0]).toEqual({ id: 'dispatch-S1', taskId: 'S1', at: historicalAt });
1064
+ expect(final.appliedRequests['old-dispatch-request']).toEqual(historical.appliedRequests['old-dispatch-request']);
1065
+ expect(final.dispatches[1]).toMatchObject({ id: routedAck.resultRef,
1066
+ taskId: 'S1', routingAttemptId: attemptId, dispatchRequestId: queuedDispatch.requestId });
735
1067
  expect(final.routingAttempts).toHaveLength(1);
736
1068
  expect(final.routingAttempts[0].state).toBe('active');
737
1069
  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.5",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file