agentic-workflow-manager 9.12.2 → 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.
@@ -251,6 +251,67 @@ function registerJobCommand(program) {
251
251
  assertAuthenticatedCwd(repo, branch);
252
252
  process.stdout.write(JSON.stringify((0, query_1.queryPs)(repo, branch), null, 2) + '\n');
253
253
  });
254
+ job.command('ack')
255
+ .description('consulta read-only el acuse durable de una request por ID exacto')
256
+ .argument('<requestId>')
257
+ .action((requestId) => {
258
+ if (!/^req-[A-Za-z0-9-]{1,124}$/.test(requestId))
259
+ throw new Error('requestId invalido');
260
+ const repo = process.cwd();
261
+ const branch = branchOf(repo);
262
+ assertAuthenticatedCwd(repo, branch);
263
+ const read = (0, store_1.readJournal)(repo, branch);
264
+ if (read.corrupt || !read.state) {
265
+ process.stdout.write(JSON.stringify({ requestId, state: 'unverifiable', journal: read.corrupt ? 'corrupt' : 'absent' }) + '\n');
266
+ process.exitCode = 1;
267
+ return;
268
+ }
269
+ (0, lock_1.verifyBranchInvariant)(repo, read.state.branch);
270
+ const ack = (0, requests_1.ackFor)(read.state, requestId);
271
+ if (ack) {
272
+ process.stdout.write(JSON.stringify({ requestId, state: ack.outcome === 'applied' ? 'applied' : 'rejected',
273
+ outcome: ack.outcome, ...(ack.resultRef ? { resultRef: ack.resultRef } : {}) }) + '\n');
274
+ if (ack.outcome !== 'applied')
275
+ process.exitCode = 1;
276
+ return;
277
+ }
278
+ const problem = read.state.requestProblems.find(item => item.requestId === requestId
279
+ || (item.kind === 'corrupt' && path_1.default.basename(item.file) === `${requestId}.json`));
280
+ if (problem) {
281
+ process.stdout.write(JSON.stringify({ requestId, state: problem.kind === 'corrupt' ? 'unverifiable' : 'rejected', reasonCode: problem.kind }) + '\n');
282
+ process.exitCode = 1;
283
+ return;
284
+ }
285
+ let pending;
286
+ try {
287
+ pending = (0, requests_1.listPendingRequests)(repo, branch).find(item => item.requestId === requestId
288
+ || (item.corrupt && path_1.default.basename(item.file) === `${requestId}.json`));
289
+ }
290
+ catch {
291
+ process.stdout.write(JSON.stringify({ requestId, state: 'unverifiable', reasonCode: 'request-directory' }) + '\n');
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ // The supervisor may have consumed the request between the first
296
+ // journal snapshot and the queue read. Recheck before saying unknown.
297
+ const latest = (0, store_1.readJournal)(repo, branch);
298
+ if (latest.corrupt || !latest.state) {
299
+ process.stdout.write(JSON.stringify({ requestId, state: 'unverifiable', journal: latest.corrupt ? 'corrupt' : 'absent' }) + '\n');
300
+ process.exitCode = 1;
301
+ return;
302
+ }
303
+ const latestAck = (0, requests_1.ackFor)(latest.state, requestId);
304
+ if (latestAck) {
305
+ process.stdout.write(JSON.stringify({ requestId, state: latestAck.outcome === 'applied' ? 'applied' : 'rejected',
306
+ outcome: latestAck.outcome, ...(latestAck.resultRef ? { resultRef: latestAck.resultRef } : {}) }) + '\n');
307
+ if (latestAck.outcome !== 'applied')
308
+ process.exitCode = 1;
309
+ return;
310
+ }
311
+ process.stdout.write(JSON.stringify({ requestId, state: pending?.corrupt ? 'unverifiable' : pending ? 'pending' : 'unknown' }) + '\n');
312
+ if (!pending || pending.corrupt)
313
+ process.exitCode = 1;
314
+ });
254
315
  job.command('list').action(() => {
255
316
  const repo = process.cwd();
256
317
  const branch = branchOf(repo);
@@ -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))
@@ -155,6 +156,14 @@ function applyRequestToState(s, env, digest, repoRoot, eventScope) {
155
156
  const capabilities = (0, capabilities_1.readCapabilities)((0, capabilities_1.validateRuntimeKey)(envelope.runtime), checkedAt);
156
157
  return { checkedAt, policy: policy.state === 'approved' ? policy.policy : undefined, capabilities: capabilities.state === 'current' ? capabilities.receipt : undefined };
157
158
  } }, p.at ?? now());
159
+ const routingAttempt = reserved.state.routingAttempts?.find(item => item.id === reserved.attemptId);
160
+ if (!routingAttempt)
161
+ throw new Error('routing reservation produced no attempt');
162
+ const priorReservationAck = routingAttempt.reservationRequestId === undefined ? undefined
163
+ : reserved.state.appliedRequests[routingAttempt.reservationRequestId];
164
+ if (!priorReservationAck || priorReservationAck.outcome !== 'applied' || priorReservationAck.resultRef !== routingAttempt.id) {
165
+ routingAttempt.reservationRequestId = env.requestId;
166
+ }
158
167
  Object.assign(s, reserved.state);
159
168
  (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: reserved.attemptId });
160
169
  return;
@@ -166,6 +175,15 @@ function applyRequestToState(s, env, digest, repoRoot, eventScope) {
166
175
  const attempt = s.routingAttempts?.find(item => item.id === p.attemptId);
167
176
  if (!attempt)
168
177
  throw new Error('routing-observe attempt is unknown');
178
+ if (s.planBinding?.schema === 'compact-slices/v2' && attempt.envelope.role === 'implementer') {
179
+ if (attempt.nativeEvidenceRequired !== true)
180
+ throw new Error('routing-observe v2 requires a native event receipt');
181
+ const dispatch = s.dispatches.find(item => item.routingAttemptId === attempt.id);
182
+ const ack = dispatch?.dispatchRequestId ? s.appliedRequests[dispatch.dispatchRequestId] : undefined;
183
+ if (!dispatch || ack?.outcome !== 'applied' || ack.requestId !== dispatch.dispatchRequestId
184
+ || ack.resultRef !== dispatch.id)
185
+ throw new Error('routing-observe requires applied dispatch ack');
186
+ }
169
187
  let key = null;
170
188
  if (attempt.nativeEvidenceRequired) {
171
189
  try {
@@ -454,8 +472,106 @@ function applyRequestToState(s, env, digest, repoRoot, eventScope) {
454
472
  const taskId = p.taskId;
455
473
  if (!s.tasks.some((task) => task.id === taskId))
456
474
  throw new Error('register --entity dispatch: taskId desconocido');
457
- if (!s.dispatches.some((d) => d.id === dispatchId)) {
458
- s.dispatches.push({ id: dispatchId, taskId, at: now() });
475
+ const v2 = s.planBinding?.schema === 'compact-slices/v2';
476
+ let routingAttemptId;
477
+ let reservedForNewDispatch = true;
478
+ if (v2) {
479
+ if (typeof p.routingAttemptId !== 'string' || p.routingAttemptId.length === 0)
480
+ throw new Error('register --entity dispatch v2 requiere routingAttemptId');
481
+ const attempt = s.routingAttempts?.find(candidate => candidate.id === p.routingAttemptId);
482
+ if (!attempt || attempt.envelope.role !== 'implementer'
483
+ || attempt.envelope.sliceId !== taskId || attempt.envelope.planDigest !== s.planBinding.digest
484
+ || attempt.envelope.executionDigest !== s.planBinding.executionDigest
485
+ || !attempt.reservationRequestId
486
+ || s.appliedRequests[attempt.reservationRequestId]?.outcome !== 'applied'
487
+ || s.appliedRequests[attempt.reservationRequestId]?.requestId !== attempt.reservationRequestId
488
+ || s.appliedRequests[attempt.reservationRequestId]?.resultRef !== attempt.id) {
489
+ throw new Error('register --entity dispatch: routing reservation for this slice and plan lacks applied ack');
490
+ }
491
+ routingAttemptId = attempt.id;
492
+ if (attempt.nativeEvidenceRequired !== true)
493
+ throw new Error('register --entity dispatch v2 requires native event receipt');
494
+ reservedForNewDispatch = attempt.state === 'reserved';
495
+ }
496
+ else if (p.routingAttemptId !== undefined)
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');
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
+ }
565
+ if (existing && (existing.taskId !== taskId || existing.routingAttemptId !== routingAttemptId)) {
566
+ throw new Error('register --entity dispatch: dispatchId conflictivo');
567
+ }
568
+ if (!existing) {
569
+ if (routingAttemptId && s.dispatches.some(d => d.routingAttemptId === routingAttemptId)) {
570
+ throw new Error('register --entity dispatch: routing reservation ya tiene un dispatch');
571
+ }
572
+ if (!reservedForNewDispatch)
573
+ throw new Error('register --entity dispatch: routing attempt ya no esta reservado');
574
+ s.dispatches.push({ id: dispatchId, taskId, at: now(), ...(routingAttemptId ? { routingAttemptId, dispatchRequestId: env.requestId } : {}) });
459
575
  const task = s.tasks.find((t) => t.id === taskId);
460
576
  if (task !== undefined)
461
577
  task.attempts += 1;
@@ -95,7 +95,7 @@ function promptForGeneration(gen) {
95
95
  'resume-next-action': 'Retoma el next_action vigente del journal.',
96
96
  'reconcile-active-jobs': 'Reconcilia los jobs activos existentes del journal sin duplicarlos.',
97
97
  };
98
- return `${instructions[gen.resumeAction.kind]}\nGeneracion activa: ${gen.token}. Incluye --generation ${gen.token} en cada comando awm job.`;
98
+ return `${instructions[gen.resumeAction.kind]}\nGeneracion activa: ${gen.token}. Incluye --generation ${gen.token} solo en awm job request, register, verdict, controller-heartbeat, routing-reserve y routing-observe. Ejecuta awm job reconcile sin --generation; awm job ack <requestId> tambien es de solo lectura y no acepta ese flag. En compact v2: espera el ack applied de routing-reserve, toma su resultRef como routingAttemptId, registra dispatch con ese routingAttemptId y espera su ack applied antes de lanzar al implementador nativo. Ningun implementador escribe antes de ese ack. Luego emite routing-observe y espera su ack; un recibo de emision no es un ack aplicado.`;
99
99
  }
100
100
  function recoveryAction(action) {
101
101
  if (!(0, types_1.isControllerRecoveryAction)(action))
@@ -143,8 +143,18 @@ function registerWatchCommand(program) {
143
143
  ...(opts.controllerAutonomy === undefined ? {} : { controllerAutonomy: opts.controllerAutonomy }),
144
144
  };
145
145
  process.stdout.write(`awm watch: supervisor activo (${cfg.provider}) — Ctrl-C para terminar\n`);
146
- await (0, supervisor_1.runSupervisorLoop)(repo, branch, cfg);
147
- process.stdout.write('gate verde: ciclo COMPLETE — drenado, lock liberado, apagando\n');
146
+ const outcome = await (0, supervisor_1.runSupervisorLoop)(repo, branch, cfg);
147
+ const final = (0, archive_unused_1.watchJournalStatus)(repo, branch);
148
+ if (outcome === 'complete' && final.state === 'present' && final.cycleState === 'COMPLETE') {
149
+ process.stdout.write('gate verde: ciclo COMPLETE — drenado, lock liberado, apagando\n');
150
+ }
151
+ else {
152
+ process.stdout.write(`awm watch: ${outcome === 'stopped' ? 'detenido' : 'track congelado'}; ciclo ${final.cycleState ?? final.state} — sin certificacion de cierre\n`);
153
+ if (outcome === 'stopped')
154
+ process.exitCode = 130;
155
+ else if (outcome === 'complete' || final.state !== 'present')
156
+ process.exitCode = 1;
157
+ }
148
158
  });
149
159
  watch.command('recover-request')
150
160
  .description('reconcilia una task rechazada con una request correctiva aplicada bajo lock exclusivo')
@@ -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 });
@@ -341,12 +342,67 @@ class Supervisor {
341
342
  if (before0.corrupt || !before0.state)
342
343
  throw new Error('journal no verificable tras vincular identidad de admisión');
343
344
  }
344
- if (before0.state.admissionRetry && Date.now() < Date.parse(before0.state.admissionRetry.nextRetryAt))
345
+ // Full sensor admission belongs to the launch boundary. Once that
346
+ // generation has a durable controller launch intent, RED edits are
347
+ // normal work, not a new pre-dispatch baseline. Preserve binding
348
+ // checks while it runs; the next generation gets a fresh admission.
349
+ const binding = before0.state.planBinding;
350
+ if (!binding)
351
+ throw new Error('binding compacto ausente tras lectura del journal');
352
+ const launched = (0, generations_1.activeGeneration)(before0.state);
353
+ const ownedLaunch = launched !== undefined && (launched.processRef !== undefined || launched.wrapperRef !== undefined
354
+ || (0, generations_1.controllerGenerationHasUnresolvedClaim)(this.repoRoot, this.branch, launched));
355
+ const generationAdmitted = launched?.controllerJobId !== undefined && launched.launchArgvDigest !== undefined && ownedLaunch;
356
+ if (generationAdmitted) {
357
+ const plan = (0, validate_1.validatePlanFile)(binding.path, this.repoRoot);
358
+ if (plan.state !== 'valid' || plan.planDigest !== binding.digest || plan.schema !== binding.schema
359
+ || (binding.executionDigest !== undefined && plan.executionDigest !== binding.executionDigest)) {
360
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'binding del plan alterado durante generacion admitida');
361
+ return 'custody';
362
+ }
363
+ if (binding.schema === 'compact-slices/v2') {
364
+ const asserted = assertedContext.runtime;
365
+ if (!asserted) {
366
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'runtime de routing ausente durante generacion v2');
367
+ return 'custody';
368
+ }
369
+ const expected = (0, capabilities_1.validateRuntimeKey)({ target: assertedContext.provider, ...asserted });
370
+ let local;
371
+ try {
372
+ local = await (0, local_event_scope_1.queryLocalEventScope)(this.repoRoot, expected.target, expected.kind);
373
+ }
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}`);
382
+ return 'custody';
383
+ }
384
+ if (JSON.stringify(local.scope.runtime) !== JSON.stringify(expected)) {
385
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'runtime local cambió durante generacion admitida');
386
+ return 'custody';
387
+ }
388
+ const stored = (0, event_store_1.readStoredEventReceipt)(expected);
389
+ if (stored.state === 'invalid' || (stored.state === 'present' &&
390
+ (stored.receipt.binaryDigest !== local.scope.binaryDigest || stored.receipt.configDigest !== local.scope.configDigest))) {
391
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'recibo nativo cambió durante generacion admitida');
392
+ return 'custody';
393
+ }
394
+ if (stored.state === 'absent' && (0, capabilities_1.readCapabilities)(expected, new Date()).state !== 'current') {
395
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'capacidades de routing no verificables durante generacion admitida');
396
+ return 'custody';
397
+ }
398
+ }
399
+ }
400
+ if (!generationAdmitted && before0.state.admissionRetry && Date.now() < Date.parse(before0.state.admissionRetry.nextRetryAt))
345
401
  return 'continue';
346
402
  // A controller can create jobs and runnerTick can start them. Both are
347
403
  // downstream of the same full compact unattended admission, so it must
348
404
  // complete before any reconciliation path that could dispatch either.
349
- if (before0.state.schema === 2 && before0.state.planBinding) {
405
+ if (!generationAdmitted) {
350
406
  let admission;
351
407
  try {
352
408
  admission = await this.dispatchAdmission();
@@ -359,7 +415,6 @@ class Supervisor {
359
415
  || admission.currentness !== 'current' || admission.sensors !== 'pass' || admission.journal !== 'current') {
360
416
  const kind = transientAdmission(admission);
361
417
  if (kind) {
362
- const binding = before0.state.planBinding;
363
418
  const plan = (0, validate_1.validatePlanFile)(binding.path, this.repoRoot);
364
419
  if (plan.state !== 'valid' || plan.planDigest !== binding.digest || plan.schema !== binding.schema
365
420
  || (binding.executionDigest !== undefined && plan.executionDigest !== binding.executionDigest)
@@ -565,6 +620,10 @@ class Supervisor {
565
620
  const finalizing = afterRequests.state?.cohortPhase === 'FINAL_INTEGRATION' || afterRequests.state?.cohortPhase === 'FINAL_INTERLOCK';
566
621
  if (!finalizing && afterRequests.state !== null && (0, generations_1.activeGeneration)(afterRequests.state) === undefined
567
622
  && afterRequests.state.generations.length > 0 && afterRequests.state.cycle.status === 'IN_PROGRESS') {
623
+ // The current tick used its launched generation's admission. A
624
+ // replacement must wait until the next tick's full admission.
625
+ if (generationAdmitted)
626
+ return 'continue';
568
627
  (0, generations_1.beginGeneration)(this.repoRoot, this.branch);
569
628
  if (this.ensureController(resumeAction) === 'custody')
570
629
  return 'custody';
@@ -577,12 +636,15 @@ class Supervisor {
577
636
  const freezeCheck = (0, store_1.readJournal)(this.repoRoot, this.branch);
578
637
  const freezing = !freezeCheck.corrupt && freezeCheck.state !== null
579
638
  && freezeCheck.state.freezeRequested === true && freezeCheck.state.frozen === undefined;
639
+ // Reconcile controller ownership and, if needed, freshly admit its
640
+ // replacement before runnerTick can launch received jobs.
641
+ const supervision = await this.superviseController();
642
+ if (supervision === 'custody')
643
+ return 'custody';
580
644
  (0, runner_1.runnerTick)(this.repoRoot, this.branch, this.spawner, {
581
- reconcileGraceMs: this.cfg.reconcileGraceMs, stallObservationMs: this.cfg.jobStallObservationMs, dispatch: !freezing,
645
+ reconcileGraceMs: this.cfg.reconcileGraceMs, stallObservationMs: this.cfg.jobStallObservationMs,
646
+ dispatch: !freezing && supervision === 'ok',
582
647
  });
583
- const custody = await this.superviseController();
584
- if (custody)
585
- return 'custody';
586
648
  if (freezing)
587
649
  return this.attemptFreeze();
588
650
  const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
@@ -695,15 +757,23 @@ class Supervisor {
695
757
  (0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'track-frozen', headSha: s.frozen.headSha });
696
758
  return 'frozen'; // paso 6 ("libera el lock y sale"): runSupervisorLoop trata 'frozen' igual que 'complete'
697
759
  }
698
- /** true => custodia (el caller NO libera lock ni sale). */
760
+ /** Deferred ownership drains existing jobs but never starts new work. */
699
761
  async superviseController() {
700
762
  const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
701
763
  if (r.corrupt || r.state === null)
702
764
  throw new Error('journal corrupto (R1.6)');
703
765
  const s = r.state;
704
766
  const gen = (0, generations_1.activeGeneration)(s);
705
- if (gen?.processRef === undefined)
706
- return false; // sin controlador propio: nada que supervisar
767
+ // Track worktrees may be driven by the plan controller without a
768
+ // local generation. The canonical final-integration job also runs
769
+ // after the plan controller is deliberately paused. An existing but
770
+ // unadopted launch is different: claim recovery/backoff must never
771
+ // dispatch received jobs under that generation.
772
+ if (gen === undefined)
773
+ return s.trackContext || s.cohortPhase === 'FINAL_INTEGRATION'
774
+ || s.cohortPhase === 'FINAL_INTERLOCK' ? 'ok' : 'deferred';
775
+ if (gen.processRef === undefined)
776
+ return 'deferred';
707
777
  if (this.lastGenerationToken !== gen.token) {
708
778
  this.lastGenerationToken = gen.token;
709
779
  this.lastActivity = null;
@@ -716,10 +786,13 @@ class Supervisor {
716
786
  this.lastActivity = { key, changedAt: Date.now() };
717
787
  }
718
788
  const activityFrozenMs = Date.now() - this.lastActivity.changedAt;
719
- const decision = (0, generations_1.decideStall)({ heartbeatAgeMs, activityFrozenMs, safeToReplace: adapter.safeToReplace(gen.processRef) }, { heartbeatTimeoutMs: this.cfg.heartbeatTimeoutMs, activityWindowMs: this.cfg.activityWindowMs });
789
+ const safeToReplace = adapter.safeToReplace(gen.processRef);
790
+ // A dead process with proven identity is not healthy merely because
791
+ // its last heartbeat was recent. Resolve it before any new job starts.
792
+ const decision = snap === null && safeToReplace === 'safe' ? 'resolve-generation' : (0, generations_1.decideStall)({ heartbeatAgeMs, activityFrozenMs, safeToReplace }, { heartbeatTimeoutMs: this.cfg.heartbeatTimeoutMs, activityWindowMs: this.cfg.activityWindowMs });
720
793
  if (decision === 'healthy') {
721
794
  this.backoff.reset();
722
- return false;
795
+ return 'ok';
723
796
  }
724
797
  if (decision === 'suspected-stall-observe') {
725
798
  if (gen.state !== 'controller-suspected-stall') {
@@ -727,39 +800,53 @@ class Supervisor {
727
800
  (0, store_1.writeJournal)(this.repoRoot, this.branch, s);
728
801
  (0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'controller-suspected-stall', n: gen.n });
729
802
  }
730
- return false;
803
+ return 'ok';
731
804
  }
732
805
  if (decision === 'custody-blocked') {
733
806
  (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'doble senial de stall sin safeToReplace positivo del adapter (R4.2b)');
734
- return true;
807
+ return 'custody';
735
808
  }
736
809
  // resolve-generation
737
810
  const resolved = await (0, generations_1.resolveGeneration)(this.repoRoot, this.branch, adapter, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
738
811
  if (resolved === 'custody-blocked')
739
- return true;
812
+ return 'custody';
740
813
  if (this.backoff.exhausted()) {
741
814
  (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de relanzamientos por hora alcanzado (R4.3)');
742
- return true;
815
+ return 'custody';
743
816
  }
744
817
  if (Date.now() < this.relaunchNotBefore)
745
- return false; // esperando backoff, auditando
818
+ return 'deferred'; // esperando backoff, auditando
819
+ // This is a different controller generation, even though the tick
820
+ // began under the old one's admission. Do not reuse that admission
821
+ // after RED edits, runtime drift, or a currentness change.
822
+ let replacementAdmission;
823
+ try {
824
+ replacementAdmission = await this.dispatchAdmission({ freshSensorsRequired: true });
825
+ }
826
+ catch (error) {
827
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, `admisión de reemplazo no verificable: ${error.message}`);
828
+ return 'custody';
829
+ }
830
+ if (replacementAdmission.state !== 'admitted' || replacementAdmission.executionMode !== 'desatendido'
831
+ || replacementAdmission.currentness !== 'current' || replacementAdmission.sensors !== 'pass'
832
+ || replacementAdmission.journal !== 'current' || replacementAdmission.sensorEvidence === 'generation-bound') {
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('; ')}`);
834
+ return 'custody';
835
+ }
746
836
  (0, generations_1.beginGeneration)(this.repoRoot, this.branch);
747
837
  const nextAction = (0, store_1.readJournal)(this.repoRoot, this.branch).state.cycle.nextAction;
748
838
  const action = { schema: 'controller-recovery/v1', kind: nextAction !== undefined ? 'resume-next-action' : 'resume-cycle' };
749
839
  const launched = this.ensureController(action);
750
840
  if (launched === 'custody')
751
- return true;
841
+ return 'custody';
752
842
  if (launched === 'ok') {
753
843
  this.backoff.recordRelaunch();
754
844
  this.relaunchNotBefore = Date.now() + this.backoff.nextMs();
755
845
  }
756
- return false;
846
+ return launched === 'deferred' ? 'deferred' : 'ok';
757
847
  }
758
848
  }
759
849
  exports.Supervisor = Supervisor;
760
- /** Foreground, visible, terminable (R2.4): sin daemons. SIGINT/SIGTERM libera
761
- * el lock y sale; COMPLETE => auto-exit liberando lock y terminando la
762
- * generacion propia (cero huerfanos). */
763
850
  async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.defaultWrapperSpawner)(), trackRuntime, dispatchAdmission) {
764
851
  const r = (0, store_1.readJournal)(repoRoot, branch);
765
852
  if (r.corrupt || r.state === null) {
@@ -772,11 +859,12 @@ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.d
772
859
  }
773
860
  (0, lock_1.verifyBranchInvariant)(repoRoot, r.state.branch);
774
861
  if (r.state.cycle.status === 'COMPLETE')
775
- return;
862
+ return 'complete';
776
863
  const handle = (0, lock_1.acquireLock)(repoRoot);
777
864
  let shutdownRequested = false;
778
865
  let wakeSleep = null;
779
866
  let safeToRelease = false;
867
+ let terminal = 'stopped';
780
868
  const onSignal = () => { shutdownRequested = true; wakeSleep?.(); };
781
869
  process.on('SIGINT', onSignal);
782
870
  process.on('SIGTERM', onSignal);
@@ -790,6 +878,7 @@ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.d
790
878
  // drenar ownership y liberar el lock, el track ya cumplió su
791
879
  // freeze y no debe seguir despachando ni corriendo su loop.
792
880
  if (out === 'complete' || out === 'frozen') {
881
+ terminal = out;
793
882
  const finalState = (0, store_1.readJournal)(repoRoot, branch);
794
883
  if (!finalState.corrupt && finalState.state?.routingIncidents?.length)
795
884
  process.stderr.write(`AWM final routing summary ${JSON.stringify((0, journal_1.routingReport)(finalState.state))}\n`);
@@ -831,6 +920,7 @@ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.d
831
920
  if (shutdownRequested)
832
921
  (0, store_1.writeJournal)(repoRoot, branch, sEnd);
833
922
  safeToRelease = true;
923
+ return terminal;
834
924
  }
835
925
  finally {
836
926
  process.removeListener('SIGINT', onSignal);
@@ -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')