agentic-workflow-manager 9.12.1 → 9.12.2

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.
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.recoverAdmissionCustody = recoverAdmissionCustody;
7
+ const path_1 = __importDefault(require("path"));
8
+ const validate_1 = require("../../core/plan/validate");
9
+ const capabilities_1 = require("../../core/model-policy/capabilities");
10
+ const event_store_1 = require("../../core/model-policy/event-store");
11
+ const local_event_scope_1 = require("../../core/model-policy/local-event-scope");
12
+ const store_1 = require("../../core/journal/store");
13
+ const types_1 = require("../../core/journal/types");
14
+ const process_1 = require("../../core/journal/process");
15
+ const adapter_1 = require("../../core/journal/adapter");
16
+ const generations_1 = require("./generations");
17
+ const lock_1 = require("./lock");
18
+ const supervisor_1 = require("./supervisor");
19
+ function contextFor(input) {
20
+ if (!(0, adapter_1.isWatchProvider)(input.provider))
21
+ throw new Error('provider de recovery invalido');
22
+ if (!(0, adapter_1.isControllerAutonomy)(input.controllerAutonomy))
23
+ throw new Error('postura del controller invalida');
24
+ if (!input.routingIdentity)
25
+ throw new Error('runtime de recovery requerido');
26
+ const runtime = (0, capabilities_1.validateRuntimeKey)({ target: input.provider, ...input.routingIdentity });
27
+ return { provider: input.provider, runtime: { kind: runtime.kind, version: runtime.version, accountScopeDigest: runtime.accountScopeDigest }, controllerAutonomy: input.controllerAutonomy };
28
+ }
29
+ function sameContext(a, b) {
30
+ return a.provider === b.provider && a.controllerAutonomy === b.controllerAutonomy
31
+ && a.runtime?.kind === b.runtime?.kind && a.runtime?.version === b.runtime?.version
32
+ && a.runtime?.accountScopeDigest === b.runtime?.accountScopeDigest;
33
+ }
34
+ function admissionCustodyCause(reason) {
35
+ if (!reason || (reason.match(/ADMISSION_[A-Z_]+/g) ?? []).length !== 1)
36
+ return undefined;
37
+ const old = reason.startsWith('admisión compacta desatendida bloqueada antes de dispatch: ');
38
+ if ((old || reason.startsWith('admisión currentness no verificable tras 3 reintentos: '))
39
+ && reason.includes('ADMISSION_CURRENTNESS_BLOCKED:'))
40
+ return 'currentness';
41
+ if ((old || reason.startsWith('admisión sensors no verificable tras 3 reintentos: '))
42
+ && reason.includes('ADMISSION_SENSORS_BLOCKED:'))
43
+ return 'sensors';
44
+ return undefined;
45
+ }
46
+ function assertNoAmbiguousWork(repoRoot, branch, state) {
47
+ if ((0, types_1.activeRequestProblems)(state).length > 0)
48
+ throw new Error('recovery bloqueado: request rechazada o corrupta activa');
49
+ for (const generation of state.generations) {
50
+ if ((0, generations_1.controllerGenerationHasUnresolvedClaim)(repoRoot, branch, generation))
51
+ throw new Error('recovery bloqueado: claim de controller sin identidad demostrable');
52
+ for (const ref of [generation.processRef, generation.wrapperRef]) {
53
+ if (ref && ((0, process_1.refIsAlive)(ref) || !(0, process_1.groupIsGone)(ref.processGroup)))
54
+ throw new Error('recovery bloqueado: controller vivo o ambiguo');
55
+ }
56
+ }
57
+ for (const job of Object.values(state.jobs)) {
58
+ if (!['exited', 'cancelled'].includes(job.executionState))
59
+ throw new Error(`recovery bloqueado: job ${job.id} activo o ambiguo`);
60
+ for (const ref of [job.processRef, job.wrapperRef]) {
61
+ if (ref && ((0, process_1.refIsAlive)(ref) || !(0, process_1.groupIsGone)(ref.processGroup)))
62
+ throw new Error(`recovery bloqueado: job ${job.id} vivo o ambiguo`);
63
+ }
64
+ }
65
+ }
66
+ /** Explicit offline transition. The journal record is the authoritative audit;
67
+ * the event is supplementary, so retry after a crash cannot duplicate the
68
+ * state transition or silently discard the original custody reason. */
69
+ async function recoverAdmissionCustody(repoRoot, branch, input, dispatchAdmission) {
70
+ if (typeof repoRoot !== 'string' || !path_1.default.isAbsolute(repoRoot) || typeof branch !== 'string' || branch.length === 0
71
+ || !input || typeof input !== 'object' || typeof input.reason !== 'string'
72
+ || input.reason.trim().length === 0 || input.reason.length > 1024
73
+ || (input.generationToken !== undefined && (typeof input.generationToken !== 'string' || input.generationToken.length === 0))) {
74
+ throw new Error('parametros de recovery invalidos');
75
+ }
76
+ const context = contextFor(input);
77
+ const lock = (0, lock_1.acquireLock)(repoRoot);
78
+ try {
79
+ const read = (0, store_1.readJournal)(repoRoot, branch);
80
+ if (read.corrupt || !read.state || read.state.schema !== 2 || !read.state.planBinding)
81
+ throw new Error('recovery requiere journal schema-2 vigente');
82
+ const state = read.state;
83
+ (0, lock_1.verifyBranchInvariant)(repoRoot, state.branch);
84
+ const previous = state.admissionRecoveries?.at(-1);
85
+ if (state.cycle.status === 'IN_PROGRESS' && previous
86
+ && previous.reason === input.reason && previous.generationToken === input.generationToken
87
+ && sameContext(previous.context, context))
88
+ return 'already-recovered';
89
+ if (state.cycle.status !== 'BLOCKED' || !admissionCustodyCause(state.cycle.blockedReason)) {
90
+ throw new Error('recovery solo admite custodia causada por admisión currentness o sensores');
91
+ }
92
+ const latest = state.generations.at(-1);
93
+ if (latest?.token !== input.generationToken || (latest === undefined && input.generationToken !== undefined)) {
94
+ throw new Error('generation de recovery no es la vigente');
95
+ }
96
+ if (latest?.provider && latest.provider !== context.provider)
97
+ throw new Error('provider de generation no coincide con identidad de recovery');
98
+ if (state.admissionContext && !sameContext(state.admissionContext, context))
99
+ throw new Error('identidad de runtime o postura no coincide con la admisión original');
100
+ assertNoAmbiguousWork(repoRoot, branch, state);
101
+ const prospective = structuredClone(state);
102
+ prospective.cycle.status = 'IN_PROGRESS';
103
+ prospective.cycle.blockedReason = undefined;
104
+ const independentBlocker = (0, supervisor_1.recoveryWhitelistBlocker)(prospective);
105
+ if (independentBlocker)
106
+ throw new Error(`recovery bloqueado por causa independiente: ${independentBlocker}`);
107
+ const binding = state.planBinding;
108
+ if (!binding)
109
+ throw new Error('binding del plan ausente');
110
+ const plan = (0, validate_1.validatePlanFile)(binding.path, repoRoot);
111
+ if (plan.state !== 'valid' || plan.planDigest !== binding.digest || plan.schema !== binding.schema
112
+ || (binding.executionDigest !== undefined && plan.executionDigest !== binding.executionDigest)) {
113
+ throw new Error('binding del plan alterado: recovery denegado');
114
+ }
115
+ // A v1 plan does not ask the admission engine to evaluate routing.
116
+ // Recovery still promises a current native receipt for the asserted
117
+ // provider/runtime, so check its machine seal and live local scope
118
+ // explicitly; an operator assertion alone is not evidence.
119
+ const runtime = (0, capabilities_1.validateRuntimeKey)({ target: context.provider, ...context.runtime });
120
+ const stored = (0, event_store_1.readStoredEventReceipt)(runtime);
121
+ if (stored.state !== 'present')
122
+ throw new Error(`recibo nativo ausente o invalido para ${context.provider}: ejecuta awm model-policy setup --provider ${context.provider}`);
123
+ const local = await (0, local_event_scope_1.queryLocalEventScope)(repoRoot, context.provider, runtime.kind);
124
+ if (local.state !== 'current' || local.scope.runtime.target !== runtime.target
125
+ || local.scope.runtime.kind !== runtime.kind || local.scope.runtime.version !== runtime.version
126
+ || local.scope.runtime.accountScopeDigest !== runtime.accountScopeDigest
127
+ || stored.receipt.runtime.target !== runtime.target || stored.receipt.runtime.kind !== runtime.kind
128
+ || stored.receipt.runtime.version !== runtime.version || stored.receipt.runtime.accountScopeDigest !== runtime.accountScopeDigest
129
+ || stored.receipt.binaryDigest !== local.scope.binaryDigest || stored.receipt.configDigest !== local.scope.configDigest
130
+ || Date.parse(stored.receipt.recordedAt) > Date.now()) {
131
+ throw new Error(`recibo nativo no coincide con runtime/cuenta local de ${context.provider}`);
132
+ }
133
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: context.provider,
134
+ routingIdentity: context.runtime, controllerAutonomy: context.controllerAutonomy };
135
+ const report = await (dispatchAdmission ?? (0, supervisor_1.admissionForConfig)(repoRoot, branch, cfg))();
136
+ if (report.state !== 'admitted' || report.planState !== 'valid' || report.executionMode !== 'desatendido'
137
+ || report.journal !== 'current' || report.currentness !== 'current' || report.sensors !== 'pass'
138
+ || (report.planDigest !== undefined && report.planDigest !== binding.digest)) {
139
+ throw new Error(`admisión estricta todavía bloqueada: ${report.diagnostics.map(item => `${item.code}: ${item.message}`).join('; ')}`);
140
+ }
141
+ const candidate = structuredClone(state);
142
+ candidate.admissionRecoveries ??= [];
143
+ candidate.admissionRecoveries.push({ at: new Date().toISOString(), reason: input.reason,
144
+ blockedReason: state.cycle.blockedReason, generationToken: input.generationToken,
145
+ context, planDigest: binding.digest,
146
+ assertion: state.admissionContext ? 'recorded' : 'legacy-operator-asserted' });
147
+ candidate.admissionContext = context;
148
+ candidate.admissionRetry = undefined;
149
+ candidate.cycle.status = 'IN_PROGRESS';
150
+ candidate.cycle.blockedReason = undefined;
151
+ // Existing generations and nextAction remain untouched. The next watch
152
+ // tick performs strict admission again before reconciling either.
153
+ (0, store_1.writeJournal)(repoRoot, branch, candidate);
154
+ (0, store_1.appendEvent)(repoRoot, branch, { kind: 'admission-recovered', generationToken: input.generationToken,
155
+ reason: input.reason, blockedReason: state.cycle.blockedReason, planDigest: binding.digest });
156
+ return 'recovered';
157
+ }
158
+ finally {
159
+ (0, lock_1.releaseLock)(repoRoot, lock);
160
+ }
161
+ }
@@ -7,6 +7,7 @@ exports.registerWatchCommand = registerWatchCommand;
7
7
  const child_process_1 = require("child_process");
8
8
  const init_1 = require("./init");
9
9
  const request_recovery_1 = require("./request-recovery");
10
+ const admission_recovery_1 = require("./admission-recovery");
10
11
  const supervisor_1 = require("./supervisor");
11
12
  const capabilities_1 = require("../../core/model-policy/capabilities");
12
13
  const adapter_1 = require("../../core/journal/adapter");
@@ -168,6 +169,36 @@ function registerWatchCommand(program) {
168
169
  process.exitCode = 1;
169
170
  }
170
171
  });
172
+ watch.command('recover-admission')
173
+ .description('reanuda offline una custodia por admisión tras una nueva comprobación estricta')
174
+ .option('--generation <token>', 'token de la ultima generacion; omitir solo si no existe')
175
+ .requiredOption('--reason <text>', 'motivo explícito de la decisión del operador')
176
+ .action(async (opts, command) => {
177
+ const repo = process.cwd();
178
+ try {
179
+ const branch = currentBranch(repo);
180
+ (0, context_1.resolveCommandContext)(repo, branch);
181
+ const parent = command.parent?.opts() ?? {};
182
+ if (command.parent?.getOptionValueSource('provider') !== 'cli')
183
+ throw new Error('recover-admission requiere --provider explícito');
184
+ if (!(0, adapter_1.isControllerAutonomy)(parent.controllerAutonomy))
185
+ throw new Error('recover-admission requiere --controller-autonomy valido');
186
+ if (parent.runtimeKind === undefined || parent.runtimeVersion === undefined || parent.accountScopeDigest === undefined) {
187
+ throw new Error('recover-admission requiere --runtime-kind, --runtime-version y --account-scope-digest');
188
+ }
189
+ const outcome = await (0, admission_recovery_1.recoverAdmissionCustody)(repo, branch, {
190
+ provider: parent.provider,
191
+ routingIdentity: { kind: parent.runtimeKind, version: parent.runtimeVersion, accountScopeDigest: parent.accountScopeDigest },
192
+ controllerAutonomy: parent.controllerAutonomy,
193
+ generationToken: opts.generation, reason: opts.reason,
194
+ });
195
+ process.stdout.write(JSON.stringify({ recovered: true, outcome, generationToken: opts.generation ?? null }) + '\n');
196
+ }
197
+ catch (error) {
198
+ process.stderr.write(`${error.message}\n`);
199
+ process.exitCode = 1;
200
+ }
201
+ });
171
202
  watch
172
203
  .command('rebind')
173
204
  .description('reconcilia intencionalmente el binding desatendido tras un cambio válido del ciclo del plan')
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Supervisor = exports.MAX_SENSOR_DEFERRALS = exports.DEFAULT_SUPERVISOR_CONFIG = void 0;
3
+ exports.Supervisor = exports.ADMISSION_RETRY_DELAY_MS = exports.MAX_SENSOR_DEFERRALS = exports.DEFAULT_SUPERVISOR_CONFIG = void 0;
4
4
  exports.routingFacts = routingFacts;
5
5
  exports.defaultDispatchAdmission = defaultDispatchAdmission;
6
6
  exports.admissionForConfig = admissionForConfig;
@@ -192,6 +192,21 @@ exports.DEFAULT_SUPERVISOR_CONFIG = {
192
192
  function admissionForConfig(repoRoot, branch, cfg, deps = {}) {
193
193
  return defaultDispatchAdmission(repoRoot, branch, cfg.provider, cfg.routingIdentity, deps, cfg.controllerAutonomy);
194
194
  }
195
+ function admissionContextForConfig(cfg) {
196
+ if (!(0, adapter_1.isWatchProvider)(cfg.provider))
197
+ throw new Error('provider de supervisor invalido');
198
+ if (cfg.controllerAutonomy !== undefined && !(0, adapter_1.isControllerAutonomy)(cfg.controllerAutonomy))
199
+ throw new Error('postura del controller invalida');
200
+ const runtime = cfg.routingIdentity === undefined ? undefined : (0, capabilities_1.validateRuntimeKey)({ target: cfg.provider, ...cfg.routingIdentity });
201
+ return { provider: cfg.provider,
202
+ ...(runtime ? { runtime: { kind: runtime.kind, version: runtime.version, accountScopeDigest: runtime.accountScopeDigest } } : {}),
203
+ ...(cfg.controllerAutonomy ? { controllerAutonomy: cfg.controllerAutonomy } : {}) };
204
+ }
205
+ function sameAdmissionContext(left, right) {
206
+ return left.provider === right.provider && left.controllerAutonomy === right.controllerAutonomy
207
+ && left.runtime?.kind === right.runtime?.kind && left.runtime?.version === right.runtime?.version
208
+ && left.runtime?.accountScopeDigest === right.runtime?.accountScopeDigest;
209
+ }
195
210
  // 'frozen' (R5.2/R6.3, Task 10): SOLO puede ocurrir en el journal de un
196
211
  // TRACK individual (nunca en el del plan) — el track terminó, con las 6
197
212
  // observaciones demostrables (cero jobs vivos, gate local verde, worktree
@@ -200,7 +215,24 @@ function admissionForConfig(repoRoot, branch, cfg, deps = {}) {
200
215
  /** How many consecutive ticks an inconclusive sensor verdict may defer dispatch
201
216
  * before the supervisor says so out loud. Small: the deferral covers a verdict
202
217
  * still settling, not one that never will. */
203
- exports.MAX_SENSOR_DEFERRALS = 3;
218
+ exports.MAX_SENSOR_DEFERRALS = types_1.MAX_ADMISSION_DEFERRALS;
219
+ exports.ADMISSION_RETRY_DELAY_MS = 5000;
220
+ function transientAdmission(report) {
221
+ // Currentness and sensors are evaluated before journalStatus, so their
222
+ // blocked reports legitimately carry journal=not-required. The caller
223
+ // verifies the bound journal identity independently before deferring.
224
+ if (report.state !== 'blocked' || report.executionMode !== 'desatendido'
225
+ || report.planState !== 'valid' || !['not-required', 'current'].includes(report.journal)
226
+ || report.diagnostics.length !== 1)
227
+ return undefined;
228
+ if (report.currentness === 'unverifiable' && report.sensors === 'not-required'
229
+ && report.diagnostics[0].code === 'ADMISSION_CURRENTNESS_BLOCKED')
230
+ return 'currentness';
231
+ if (report.currentness === 'current' && report.sensors === 'not-certified'
232
+ && report.diagnostics[0].code === 'ADMISSION_SENSORS_BLOCKED')
233
+ return 'sensors';
234
+ return undefined;
235
+ }
204
236
  const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
205
237
  function recoveryWhitelistBlocker(state) {
206
238
  if (state.cycle.status === 'BLOCKED') {
@@ -230,21 +262,19 @@ class Supervisor {
230
262
  cfg;
231
263
  spawner;
232
264
  dispatchAdmission;
265
+ autoBeginGeneration;
233
266
  backoff = new generations_1.Backoff();
234
267
  relaunchNotBefore = 0;
235
- /** Consecutive ticks deferred on an inconclusive sensor verdict. Bounded:
236
- * the deferral exists for a verdict that is still settling, and a verdict
237
- * that never settles must become visible rather than loop forever. */
238
- sensorDeferrals = 0;
239
268
  lastActivity = null;
240
269
  lastGenerationToken = null;
241
270
  trackRuntime;
242
- constructor(repoRoot, branch, cfg, spawner, trackRuntime, dispatchAdmission = admissionForConfig(repoRoot, branch, cfg)) {
271
+ constructor(repoRoot, branch, cfg, spawner, trackRuntime, dispatchAdmission = admissionForConfig(repoRoot, branch, cfg), autoBeginGeneration = false) {
243
272
  this.repoRoot = repoRoot;
244
273
  this.branch = branch;
245
274
  this.cfg = cfg;
246
275
  this.spawner = spawner;
247
276
  this.dispatchAdmission = dispatchAdmission;
277
+ this.autoBeginGeneration = autoBeginGeneration;
248
278
  this.trackRuntime = trackRuntime ?? (0, tracks_1.defaultTrackRuntime)(repoRoot, branch, { termGraceMs: cfg.termGraceMs, killGraceMs: cfg.killGraceMs });
249
279
  }
250
280
  fingerprintNow = (argv, paths, cwd) => {
@@ -281,7 +311,7 @@ class Supervisor {
281
311
  }
282
312
  }
283
313
  async tick() {
284
- const before0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
314
+ let before0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
285
315
  if (before0.corrupt || before0.state === null)
286
316
  throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
287
317
  // Historical/schema-1 journals are intentionally readable by migration
@@ -295,6 +325,24 @@ class Supervisor {
295
325
  // longer reconstructible.
296
326
  if (before0.state.cycle.status === 'COMPLETE')
297
327
  return 'complete';
328
+ // A historical custody cannot be silently converted into another
329
+ // cause on a later tick. Only the locked offline recovery may resume.
330
+ if (before0.state.cycle.status === 'BLOCKED')
331
+ return 'custody';
332
+ const assertedContext = admissionContextForConfig(this.cfg);
333
+ if (before0.state.admissionContext && !sameAdmissionContext(before0.state.admissionContext, assertedContext)) {
334
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'identidad de admisión cambió al reiniciar: provider/runtime/postura');
335
+ return 'custody';
336
+ }
337
+ if (!before0.state.admissionContext && assertedContext.runtime && assertedContext.controllerAutonomy) {
338
+ before0.state.admissionContext = assertedContext;
339
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, before0.state);
340
+ before0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
341
+ if (before0.corrupt || !before0.state)
342
+ throw new Error('journal no verificable tras vincular identidad de admisión');
343
+ }
344
+ if (before0.state.admissionRetry && Date.now() < Date.parse(before0.state.admissionRetry.nextRetryAt))
345
+ return 'continue';
298
346
  // A controller can create jobs and runnerTick can start them. Both are
299
347
  // downstream of the same full compact unattended admission, so it must
300
348
  // complete before any reconciliation path that could dispatch either.
@@ -309,40 +357,49 @@ class Supervisor {
309
357
  }
310
358
  if (admission.state !== 'admitted' || admission.executionMode !== 'desatendido'
311
359
  || admission.currentness !== 'current' || admission.sensors !== 'pass' || admission.journal !== 'current') {
312
- // A first empirical sensor observation can be inconclusive while
313
- // the already-started controller is still settling. It never
314
- // authorizes dispatch; retry the read-only admission next tick
315
- // instead of permanently custodying a healthy cycle.
316
- //
317
- // Bounded, because the verdict is not always transient: a
318
- // registry whose sensors are all disabled reports
319
- // `not-certified` forever. Unbounded, this returned before
320
- // `consumePendingRequests` below, so the supervisor ticked
321
- // indefinitely applying nothing and writing nothing — no event,
322
- // no state change, no exit. Refusing to progress is correct
323
- // here; refusing invisibly is not. Consuming requests cannot be
324
- // hoisted above this gate to compensate: applying a job-request
325
- // creates a Job that runnerTick can start, which is the dispatch
326
- // this admission exists to authorize.
327
- if (admission.planState === 'valid' && admission.currentness === 'current'
328
- && admission.sensors === 'not-certified' && admission.journal === 'not-required') {
329
- this.sensorDeferrals += 1;
330
- if (this.sensorDeferrals <= exports.MAX_SENSOR_DEFERRALS)
360
+ const kind = transientAdmission(admission);
361
+ if (kind) {
362
+ const binding = before0.state.planBinding;
363
+ const plan = (0, validate_1.validatePlanFile)(binding.path, this.repoRoot);
364
+ if (plan.state !== 'valid' || plan.planDigest !== binding.digest || plan.schema !== binding.schema
365
+ || (binding.executionDigest !== undefined && plan.executionDigest !== binding.executionDigest)
366
+ || (admission.planDigest !== undefined && admission.planDigest !== binding.digest)) {
367
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'binding del plan alterado durante admisión transitoria');
368
+ return 'custody';
369
+ }
370
+ const prior = before0.state.admissionRetry;
371
+ const attempts = (prior?.attempts ?? 0) + 1;
372
+ if (attempts <= types_1.MAX_ADMISSION_DEFERRALS) {
373
+ const now = Date.now();
374
+ before0.state.admissionRetry = { kind, attempts, firstAt: prior?.firstAt ?? new Date(now).toISOString(), nextRetryAt: new Date(now + exports.ADMISSION_RETRY_DELAY_MS).toISOString() };
375
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, before0.state);
376
+ (0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'admission-retry', cause: kind, attempts, retryAfterMs: exports.ADMISSION_RETRY_DELAY_MS });
331
377
  return 'continue';
332
- // The reason must not carry the running counter. enterCustody is
333
- // idempotent on (status, reason), so a message that changes every
334
- // tick defeats that guard and rewrites the journal plus appends a
335
- // custody-blocked event every tickMs, without bound. Naming the
336
- // bound instead of the count keeps the reason stable, so custody
337
- // is entered once and stays entered.
338
- (0, generations_1.enterCustody)(this.repoRoot, this.branch, `veredicto de sensores no concluyente tras ${exports.MAX_SENSOR_DEFERRALS} ticks: ${admission.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('; ')}`);
378
+ }
379
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, `admisión ${kind} no verificable tras ${types_1.MAX_ADMISSION_DEFERRALS} reintentos: ${admission.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('; ')}`);
339
380
  return 'custody';
340
381
  }
341
382
  (0, generations_1.enterCustody)(this.repoRoot, this.branch, `admisión compacta desatendida bloqueada antes de dispatch: ${admission.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('; ')}`);
342
383
  return 'custody';
343
384
  }
344
- // The verdict settled, which is exactly what the deferral waited for.
345
- this.sensorDeferrals = 0;
385
+ // An incomplete assertion can be repaired while the cycle is
386
+ // still waiting. Bind it only after an admitted observation, never
387
+ // on an early currentness/sensor response that has not reached the
388
+ // provider, runtime and autonomy gates yet.
389
+ if (!before0.state.admissionContext) {
390
+ before0.state.admissionContext = assertedContext;
391
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, before0.state);
392
+ before0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
393
+ if (before0.corrupt || !before0.state)
394
+ throw new Error('journal no verificable tras vincular admisión completa');
395
+ }
396
+ if (before0.state.admissionRetry) {
397
+ before0.state.admissionRetry = undefined;
398
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, before0.state);
399
+ before0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
400
+ if (before0.corrupt || !before0.state)
401
+ throw new Error('journal no verificable tras resolver reintento de admisión');
402
+ }
346
403
  }
347
404
  let recoveryResumeAction;
348
405
  // Schema-2 custody is reconciled before any controller launch. This is
@@ -396,14 +453,14 @@ class Supervisor {
396
453
  // verdicts, and an adverse verdict without a closed fix all stop
397
454
  // recovery before a controller can create more work.
398
455
  const verdictById = new Map(before0.state.verdicts.map(verdict => [verdict.id, verdict]));
399
- const openReviewOrFix = before0.state.tasks.some(task => {
456
+ const openReviewOrFix = (terminalTaskClaims && before0.state.tasks.some(task => {
400
457
  const obligations = task.reviewObligations;
401
458
  return !['spec', 'quality'].every(kind => obligations.some(obligation => obligation.kind === kind))
402
459
  || obligations.some(obligation => {
403
460
  const verdict = obligation.verdictId === undefined ? undefined : verdictById.get(obligation.verdictId);
404
461
  return verdict === undefined || verdict.result !== 'pass';
405
462
  });
406
- }) || before0.state.verdicts.some(verdict => verdict.result !== 'pass'
463
+ })) || before0.state.verdicts.some(verdict => verdict.result !== 'pass'
407
464
  && !before0.state.fixes.some(fix => fix.verdictId === verdict.id && fix.closed));
408
465
  const recovery = (0, fingerprint_1.reconcileUnattendedRecovery)({
409
466
  journal: before0.state, journalCorrupt: false, plan: before0.state.planBinding,
@@ -442,6 +499,8 @@ class Supervisor {
442
499
  // mismo resultado terminal.
443
500
  if (before.frozen !== undefined)
444
501
  return 'frozen';
502
+ if (this.autoBeginGeneration && (0, generations_1.activeGeneration)(before) === undefined)
503
+ (0, generations_1.beginGeneration)(this.repoRoot, this.branch);
445
504
  const pending = before.cycle.nextAction;
446
505
  const resumeAction = recoveryResumeAction ?? { schema: 'controller-recovery/v1', kind: pending !== undefined ? 'resume-next-action' : 'resume-cycle' };
447
506
  if (this.ensureController(resumeAction) === 'custody')
@@ -721,12 +780,8 @@ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.d
721
780
  const onSignal = () => { shutdownRequested = true; wakeSleep?.(); };
722
781
  process.on('SIGINT', onSignal);
723
782
  process.on('SIGTERM', onSignal);
724
- const sup = new Supervisor(repoRoot, branch, cfg, spawner, trackRuntime, dispatchAdmission);
783
+ const sup = new Supervisor(repoRoot, branch, cfg, spawner, trackRuntime, dispatchAdmission, true);
725
784
  try {
726
- const s0 = (0, store_1.readJournal)(repoRoot, branch).state;
727
- if ((0, generations_1.activeGeneration)(s0) === undefined) {
728
- (0, generations_1.beginGeneration)(repoRoot, branch);
729
- }
730
785
  for (;;) {
731
786
  if (shutdownRequested)
732
787
  break;
@@ -5,7 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  return (mod && mod.__esModule) ? mod : { "default": mod };
6
6
  };
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.GENERATION_STATES = exports.EXECUTION_STATES = void 0;
8
+ exports.MAX_ADMISSION_DEFERRALS = exports.GENERATION_STATES = exports.EXECUTION_STATES = void 0;
9
9
  exports.activeRequestProblems = activeRequestProblems;
10
10
  exports.isControllerRecoveryAction = isControllerRecoveryAction;
11
11
  exports.emptyState = emptyState;
@@ -50,6 +50,7 @@ function activeRequestProblems(state) {
50
50
  || !state.tasks.some(task => task.id === problem.taskId);
51
51
  });
52
52
  }
53
+ exports.MAX_ADMISSION_DEFERRALS = 3;
53
54
  function isControllerRecoveryAction(x) {
54
55
  return isObj(x) && Object.keys(x).length === 2
55
56
  && x.schema === 'controller-recovery/v1'
@@ -117,6 +118,12 @@ function isWellFormedState(x) {
117
118
  return false;
118
119
  if (x.custodyDecisions !== undefined && (!Array.isArray(x.custodyDecisions) || !x.custodyDecisions.every(isWellFormedCustodyDecision)))
119
120
  return false;
121
+ if (x.admissionRetry !== undefined && !isWellFormedAdmissionRetry(x.admissionRetry))
122
+ return false;
123
+ if (x.admissionContext !== undefined && !isWellFormedAdmissionContext(x.admissionContext))
124
+ return false;
125
+ if (x.admissionRecoveries !== undefined && (!Array.isArray(x.admissionRecoveries) || !x.admissionRecoveries.every(isWellFormedAdmissionRecovery)))
126
+ return false;
120
127
  if (!x.generations.every(isWellFormedGeneration) || !x.tasks.every(isWellFormedTask))
121
128
  return false;
122
129
  if (!x.cycleVerificationPlan.every(isWellFormedVerificationItem))
@@ -274,6 +281,36 @@ function isWellFormedCustodyDecision(x) {
274
281
  return isObj(x) && typeof x.at === 'string' && x.decision === 'resume'
275
282
  && typeof x.reason === 'string' && typeof x.generationToken === 'string';
276
283
  }
284
+ function isWellFormedAdmissionRetry(x) {
285
+ return isObj(x) && Object.keys(x).length === 4
286
+ && (x.kind === 'currentness' || x.kind === 'sensors')
287
+ && Number.isInteger(x.attempts) && Number(x.attempts) >= 1 && Number(x.attempts) <= exports.MAX_ADMISSION_DEFERRALS
288
+ && typeof x.firstAt === 'string' && Number.isFinite(Date.parse(x.firstAt))
289
+ && typeof x.nextRetryAt === 'string' && Number.isFinite(Date.parse(x.nextRetryAt))
290
+ && Date.parse(x.nextRetryAt) >= Date.parse(x.firstAt);
291
+ }
292
+ function isWellFormedAdmissionContext(x) {
293
+ if (!isObj(x) || !Object.keys(x).every(key => ['provider', 'runtime', 'controllerAutonomy'].includes(key))
294
+ || (x.provider !== 'codex' && x.provider !== 'claude-code')
295
+ || (x.controllerAutonomy !== undefined && x.controllerAutonomy !== 'approval-free'))
296
+ return false;
297
+ if (x.runtime === undefined)
298
+ return true;
299
+ return isObj(x.runtime) && Object.keys(x.runtime).length === 3
300
+ && typeof x.runtime.kind === 'string' && x.runtime.kind.length > 0
301
+ && typeof x.runtime.version === 'string' && x.runtime.version.length > 0
302
+ && typeof x.runtime.accountScopeDigest === 'string' && /^[a-f0-9]{64}$/.test(x.runtime.accountScopeDigest);
303
+ }
304
+ function isWellFormedAdmissionRecovery(x) {
305
+ return isObj(x) && Object.keys(x).every(key => ['at', 'reason', 'blockedReason', 'generationToken', 'context', 'planDigest', 'assertion'].includes(key))
306
+ && typeof x.at === 'string' && Number.isFinite(Date.parse(x.at))
307
+ && typeof x.reason === 'string' && x.reason.trim().length > 0 && x.reason.length <= 1024
308
+ && typeof x.blockedReason === 'string' && x.blockedReason.length > 0
309
+ && (x.generationToken === undefined || (typeof x.generationToken === 'string' && x.generationToken.length > 0))
310
+ && isWellFormedAdmissionContext(x.context)
311
+ && typeof x.planDigest === 'string' && /^[a-f0-9]{64}$/.test(x.planDigest)
312
+ && (x.assertion === 'recorded' || x.assertion === 'legacy-operator-asserted');
313
+ }
277
314
  function isWellFormedProcessRef(x) {
278
315
  if (!isObj(x))
279
316
  return false;
@@ -0,0 +1,278 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const fs_1 = __importDefault(require("fs"));
40
+ const os_1 = __importDefault(require("os"));
41
+ const path_1 = __importDefault(require("path"));
42
+ const child_process_1 = require("child_process");
43
+ const commander_1 = require("commander");
44
+ const init_1 = require("../../../src/commands/watch/init");
45
+ const store_1 = require("../../../src/core/journal/store");
46
+ const validate_1 = require("../../../src/core/plan/validate");
47
+ const process_1 = require("../../../src/core/journal/process");
48
+ const eventStore = __importStar(require("../../../src/core/model-policy/event-store"));
49
+ const localScope = __importStar(require("../../../src/core/model-policy/local-event-scope"));
50
+ const supervisor_1 = require("../../../src/commands/watch/supervisor");
51
+ const admission_recovery_1 = require("../../../src/commands/watch/admission-recovery");
52
+ const watch_1 = require("../../../src/commands/watch");
53
+ const supervisorModule = __importStar(require("../../../src/commands/watch/supervisor"));
54
+ const DIGEST = 'a'.repeat(64);
55
+ const identity = { kind: 'native', version: '1.0.0', accountScopeDigest: DIGEST };
56
+ const input = { provider: 'codex', routingIdentity: identity, controllerAutonomy: 'approval-free',
57
+ generationToken: 'legacy-1', reason: 'Operador verificó currentness y recibos vigentes' };
58
+ const admitted = async () => ({ state: 'admitted', planState: 'valid', executionMode: 'desatendido', journal: 'current', currentness: 'current', sensors: 'pass', diagnostics: [] });
59
+ const blocked = (code, currentness, sensors) => async () => ({ state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'not-required', currentness, sensors, diagnostics: [{ code, message: 'still blocked' }] });
60
+ describe('issue #196: admission custody recovery', () => {
61
+ let repo;
62
+ beforeEach(() => {
63
+ repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-admission-recovery-'));
64
+ (0, child_process_1.execFileSync)('git', ['init', '-q', '-b', 'main'], { cwd: repo });
65
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', 'commit', '--allow-empty', '-qm', 'seed'], { cwd: repo });
66
+ fs_1.default.mkdirSync(path_1.default.join(repo, 'plans'), { recursive: true });
67
+ 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-v1/valid.md'), 'utf8'));
68
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'source.md'), '## Canonical source\nfixture source\n');
69
+ (0, init_1.initWatch)(repo, 'main');
70
+ const plan = (0, validate_1.validatePlanFile)('plans/cycle.md', repo);
71
+ if (plan.state !== 'valid')
72
+ throw new Error('invalid fixture');
73
+ const state = (0, store_1.readJournal)(repo, 'main').state;
74
+ state.schema = 2;
75
+ state.planBinding = { path: 'plans/cycle.md', digest: plan.planDigest, schema: plan.schema, executionMode: 'desatendido', boundAt: new Date().toISOString() };
76
+ state.generations.push({ n: 1, token: input.generationToken, state: 'terminated', launchedAt: new Date().toISOString(), provider: 'codex' });
77
+ state.cycle.status = 'BLOCKED';
78
+ state.cycle.blockedReason = 'admisión compacta desatendida bloqueada antes de dispatch: ADMISSION_CURRENTNESS_BLOCKED: Consumed contract currentness is unverifiable: registry:baseline.';
79
+ (0, store_1.writeJournal)(repo, 'main', state);
80
+ jest.spyOn(eventStore, 'readStoredEventReceipt').mockImplementation(runtime => ({ state: 'present', receipt: {
81
+ schema: 'routing-capabilities/v2', runtime, binaryDigest: DIGEST, configDigest: DIGEST,
82
+ recordedAt: new Date().toISOString(), claims: [],
83
+ } }));
84
+ jest.spyOn(localScope, 'queryLocalEventScope').mockImplementation(async (_cwd, target, kind) => ({ state: 'current', scope: {
85
+ runtime: { target, kind, version: identity.version, accountScopeDigest: identity.accountScopeDigest },
86
+ binaryDigest: DIGEST, configDigest: DIGEST,
87
+ } }));
88
+ });
89
+ afterEach(() => { jest.restoreAllMocks(); fs_1.default.rmSync(repo, { recursive: true, force: true }); });
90
+ test('legacy currentness custody resumes once with original reason, generation and nextAction retained', async () => {
91
+ const original = (0, store_1.readJournal)(repo, 'main').state;
92
+ expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).toBe('recovered');
93
+ const result = (0, store_1.readJournal)(repo, 'main').state;
94
+ expect(result.cycle.status).toBe('IN_PROGRESS');
95
+ expect(result.cycle.nextAction).toEqual(original.cycle.nextAction);
96
+ expect(result.generations).toEqual(original.generations);
97
+ expect(result.tasks).toEqual(original.tasks);
98
+ expect(result.dispatches).toEqual(original.dispatches);
99
+ expect(result.jobs).toEqual(original.jobs);
100
+ expect(result).toHaveProperty('admissionRecoveries.0.blockedReason', original.cycle.blockedReason);
101
+ expect(result).toHaveProperty('admissionRecoveries.0.generationToken', input.generationToken);
102
+ const revision = result.revision;
103
+ expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).toBe('already-recovered');
104
+ expect((0, store_1.readJournal)(repo, 'main').state.revision).toBe(revision);
105
+ });
106
+ test('persistent unverifiable currentness → custody without generation → explicit recovery → one launch', async () => {
107
+ const initial = (0, store_1.readJournal)(repo, 'main').state;
108
+ initial.generations = [];
109
+ initial.cycle.status = 'IN_PROGRESS';
110
+ initial.cycle.blockedReason = undefined;
111
+ (0, store_1.writeJournal)(repo, 'main', initial);
112
+ let now = Date.now();
113
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
114
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, routingIdentity: identity, controllerAutonomy: 'approval-free' };
115
+ const transient = blocked('ADMISSION_CURRENTNESS_BLOCKED', 'unverifiable', 'not-required');
116
+ let spawns = 0;
117
+ for (let i = 0; i < supervisor_1.MAX_SENSOR_DEFERRALS + 1; i++) {
118
+ const outcome = await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawns++; }, undefined, transient, true).tick();
119
+ expect(outcome).toBe(i < supervisor_1.MAX_SENSOR_DEFERRALS ? 'continue' : 'custody');
120
+ now += 5000;
121
+ }
122
+ const held = (0, store_1.readJournal)(repo, 'main').state;
123
+ expect(held.generations).toHaveLength(0);
124
+ expect(held.tasks).toHaveLength(0);
125
+ expect(spawns).toBe(0);
126
+ expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', { ...input, generationToken: undefined }, admitted)).toBe('recovered');
127
+ expect(await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawns++; }, undefined, admitted, true).tick()).toBe('continue');
128
+ const resumed = (0, store_1.readJournal)(repo, 'main').state;
129
+ expect(resumed.generations).toHaveLength(1);
130
+ expect(resumed.cycle.nextAction).toEqual(held.cycle.nextAction);
131
+ expect(resumed.jobs).toEqual({});
132
+ expect(resumed.dispatches).toEqual([]);
133
+ expect(spawns).toBeGreaterThan(0);
134
+ });
135
+ test('persistent currentness and sensor failures leave custody untouched', async () => {
136
+ for (const [code, currentness, sensors] of [
137
+ ['ADMISSION_CURRENTNESS_BLOCKED', 'unverifiable', 'not-required'],
138
+ ['ADMISSION_SENSORS_BLOCKED', 'current', 'not-certified'],
139
+ ]) {
140
+ const revision = (0, store_1.readJournal)(repo, 'main').state.revision;
141
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, blocked(code, currentness, sensors))).rejects.toThrow(/admisión|currentness|sensores/i);
142
+ expect((0, store_1.readJournal)(repo, 'main').state.revision).toBe(revision);
143
+ }
144
+ });
145
+ test('legacy recovery refuses a missing native receipt even when v1 plan admission says admitted', async () => {
146
+ jest.spyOn(eventStore, 'readStoredEventReceipt').mockReturnValue({ state: 'absent' });
147
+ const revision = (0, store_1.readJournal)(repo, 'main').state.revision;
148
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).rejects.toThrow(/recibo|receipt/i);
149
+ expect((0, store_1.readJournal)(repo, 'main').state.revision).toBe(revision);
150
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
151
+ });
152
+ test('sensor custody can recover only after sensors pass', async () => {
153
+ const state = (0, store_1.readJournal)(repo, 'main').state;
154
+ state.cycle.blockedReason = 'admisión sensors no verificable tras 3 reintentos: ADMISSION_SENSORS_BLOCKED: not-certified';
155
+ (0, store_1.writeJournal)(repo, 'main', state);
156
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, blocked('ADMISSION_SENSORS_BLOCKED', 'current', 'not-certified'))).rejects.toThrow();
157
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
158
+ expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).toBe('recovered');
159
+ });
160
+ test('changed binding, active request problem, pending job and identity mismatch all fail closed', async () => {
161
+ const check = async (mutate, pattern) => {
162
+ const state = (0, store_1.readJournal)(repo, 'main').state;
163
+ mutate(state);
164
+ (0, store_1.writeJournal)(repo, 'main', state);
165
+ const revision = (0, store_1.readJournal)(repo, 'main').state.revision;
166
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).rejects.toThrow(pattern);
167
+ expect((0, store_1.readJournal)(repo, 'main').state.revision).toBe(revision);
168
+ };
169
+ await check(state => { state.planBinding.digest = DIGEST; }, /binding|plan/i);
170
+ const validPlan = (0, validate_1.validatePlanFile)('plans/cycle.md', repo);
171
+ if (validPlan.state !== 'valid')
172
+ throw new Error('fixture changed');
173
+ const s1 = (0, store_1.readJournal)(repo, 'main').state;
174
+ s1.planBinding.digest = validPlan.planDigest;
175
+ (0, store_1.writeJournal)(repo, 'main', s1);
176
+ await check(state => { state.requestProblems.push({ file: 'rejected', kind: 'rejected', detail: 'x', at: new Date().toISOString() }); }, /request/i);
177
+ const s2 = (0, store_1.readJournal)(repo, 'main').state;
178
+ s2.requestProblems = [];
179
+ (0, store_1.writeJournal)(repo, 'main', s2);
180
+ await check(state => { state.jobs['J1'] = { id: 'J1', fingerprint: 'x', commandDigest: 'x', argv: ['true'], cwd: '.', paths: [], expandedPaths: [], executionState: 'received', observationState: 'progressing', phaseTimestamps: {} }; }, /job/i);
181
+ const sJob = (0, store_1.readJournal)(repo, 'main').state;
182
+ sJob.jobs = {};
183
+ (0, store_1.writeJournal)(repo, 'main', sJob);
184
+ await check(state => { state.generations[0].processRef = (0, process_1.captureSelfRef)('recovery-test'); }, /controller/i);
185
+ const sController = (0, store_1.readJournal)(repo, 'main').state;
186
+ sController.generations[0].processRef = undefined;
187
+ (0, store_1.writeJournal)(repo, 'main', sController);
188
+ await check(state => { state.generations[0].provider = 'claude-code'; }, /provider|identidad/i);
189
+ const s3 = (0, store_1.readJournal)(repo, 'main').state;
190
+ s3.generations[0].provider = 'codex';
191
+ (0, store_1.writeJournal)(repo, 'main', s3);
192
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', { ...input, routingIdentity: { ...identity, accountScopeDigest: 'bad' } }, admitted)).rejects.toThrow(/runtime|digest/i);
193
+ });
194
+ test('an independent missing required verifier keeps the cycle BLOCKED', async () => {
195
+ const state = (0, store_1.readJournal)(repo, 'main').state;
196
+ state.requiredVerifiers = ['test'];
197
+ state.tasks.push({ id: 'S1', title: 'complete claim', status: 'done', attempts: 1, verificationPlan: [], reviewObligations: [] });
198
+ (0, store_1.writeJournal)(repo, 'main', state);
199
+ const revision = (0, store_1.readJournal)(repo, 'main').state.revision;
200
+ await expect((0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).rejects.toThrow(/verificador|required/i);
201
+ expect((0, store_1.readJournal)(repo, 'main').state.revision).toBe(revision);
202
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
203
+ });
204
+ test('a long-lived cycle can recover again after 128 prior audited decisions', async () => {
205
+ const state = (0, store_1.readJournal)(repo, 'main').state;
206
+ state.admissionRecoveries = Array.from({ length: 128 }, (_, index) => ({
207
+ at: new Date().toISOString(), reason: `prior-${index}`, blockedReason: state.cycle.blockedReason,
208
+ generationToken: input.generationToken, context: { provider: 'codex', runtime: identity, controllerAutonomy: 'approval-free' },
209
+ planDigest: state.planBinding.digest, assertion: 'legacy-operator-asserted',
210
+ }));
211
+ (0, store_1.writeJournal)(repo, 'main', state);
212
+ expect(await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted)).toBe('recovered');
213
+ expect((0, store_1.readJournal)(repo, 'main').state.admissionRecoveries).toHaveLength(129);
214
+ });
215
+ test('after audited recovery supervisor still rechecks admission before any dispatch', async () => {
216
+ await (0, admission_recovery_1.recoverAdmissionCustody)(repo, 'main', input, admitted);
217
+ let spawns = 0;
218
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', routingIdentity: identity, controllerAutonomy: 'approval-free' };
219
+ const sup = new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawns++; }, undefined, blocked('ADMISSION_CURRENTNESS_BLOCKED', 'unverifiable', 'not-required'));
220
+ expect(await sup.tick()).toBe('continue');
221
+ expect(spawns).toBe(0);
222
+ expect((0, store_1.readJournal)(repo, 'main').state.tasks).toHaveLength(0);
223
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.nextAction?.actionId).toBe('bootstrap-cycle');
224
+ });
225
+ test('public watch recover-admission CLI records one offline transition', async () => {
226
+ const before = (0, store_1.readJournal)(repo, 'main').state;
227
+ before.tasks.push({ id: 'S1', title: 'pending work', status: 'pending', attempts: 0, verificationPlan: [], reviewObligations: [] });
228
+ (0, store_1.writeJournal)(repo, 'main', before);
229
+ jest.spyOn(supervisorModule, 'admissionForConfig').mockReturnValue(admitted);
230
+ const cwd = jest.spyOn(process, 'cwd').mockReturnValue(repo);
231
+ const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
232
+ const program = new commander_1.Command();
233
+ program.exitOverride();
234
+ (0, watch_1.registerWatchCommand)(program);
235
+ try {
236
+ await program.parseAsync(['node', 'awm', 'watch', 'recover-admission',
237
+ '--provider', 'codex', '--runtime-kind', identity.kind, '--runtime-version', identity.version,
238
+ '--account-scope-digest', identity.accountScopeDigest, '--controller-autonomy', 'approval-free',
239
+ '--generation', input.generationToken, '--reason', input.reason]);
240
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('IN_PROGRESS');
241
+ expect(stdout.mock.calls.map(call => String(call[0])).join('')).toContain('"recovered":true');
242
+ let spawned = 0;
243
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', routingIdentity: identity, controllerAutonomy: 'approval-free' };
244
+ const outcome = await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawned++; }, undefined, admitted, true).tick();
245
+ expect({ outcome, blockedReason: (0, store_1.readJournal)(repo, 'main').state.cycle.blockedReason }).toEqual({ outcome: 'continue', blockedReason: undefined });
246
+ const resumed = (0, store_1.readJournal)(repo, 'main').state;
247
+ expect(resumed.tasks.map(task => task.id)).toEqual(['S1']);
248
+ expect(resumed.jobs).toEqual({});
249
+ expect(resumed.dispatches).toEqual([]);
250
+ expect(resumed.cycle.nextAction?.actionId).toBe('bootstrap-cycle');
251
+ expect(resumed.generations).toHaveLength(2);
252
+ expect(spawned).toBeGreaterThan(0);
253
+ }
254
+ finally {
255
+ cwd.mockRestore();
256
+ stdout.mockRestore();
257
+ }
258
+ });
259
+ test('CLI requires provider to be asserted, not inherited from the watch default', async () => {
260
+ const cwd = jest.spyOn(process, 'cwd').mockReturnValue(repo);
261
+ const stderr = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
262
+ const program = new commander_1.Command();
263
+ (0, watch_1.registerWatchCommand)(program);
264
+ try {
265
+ await program.parseAsync(['node', 'awm', 'watch', 'recover-admission',
266
+ '--runtime-kind', identity.kind, '--runtime-version', identity.version,
267
+ '--account-scope-digest', identity.accountScopeDigest, '--controller-autonomy', 'approval-free',
268
+ '--generation', input.generationToken, '--reason', input.reason]);
269
+ expect(stderr.mock.calls.map(call => String(call[0])).join('')).toMatch(/--provider/);
270
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
271
+ }
272
+ finally {
273
+ cwd.mockRestore();
274
+ stderr.mockRestore();
275
+ process.exitCode = 0;
276
+ }
277
+ });
278
+ });
@@ -416,9 +416,12 @@ describe('supervisor loop', () => {
416
416
  });
417
417
  let spawned = 0;
418
418
  const sup = new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, () => { spawned += 1; }, undefined, notCertified);
419
+ let now = Date.now();
420
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
419
421
  for (let i = 0; i < supervisor_1.MAX_SENSOR_DEFERRALS; i += 1) {
420
422
  expect(await sup.tick()).toBe('continue');
421
423
  expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('IN_PROGRESS');
424
+ now += 5000;
422
425
  }
423
426
  // The verdict never settled, so the supervisor must stop pretending it might.
424
427
  expect(await sup.tick()).toBe('custody');
@@ -443,6 +446,91 @@ describe('supervisor loop', () => {
443
446
  // Deferring dispatch was right throughout, and stays right.
444
447
  expect(spawned).toBe(0);
445
448
  });
449
+ test('unverifiable currentness retries durably across supervisor restart and does not spawn before admitted', async () => {
450
+ initUnattendedFixture(repo);
451
+ let now = Date.now();
452
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
453
+ let verdict = 'unverifiable';
454
+ const observe = async () => verdict === 'unverifiable'
455
+ ? { state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'not-required', currentness: 'unverifiable', sensors: 'not-required', diagnostics: [{ code: 'ADMISSION_CURRENTNESS_BLOCKED', message: 'registry:baseline' }] }
456
+ : admitted();
457
+ let spawned = 0;
458
+ const spawn = () => { spawned += 1; };
459
+ expect(await new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, spawn, undefined, observe, true).tick()).toBe('continue');
460
+ expect((0, store_1.readJournal)(repo, 'main').state).toHaveProperty('admissionRetry.attempts', 1);
461
+ expect((0, store_1.readJournal)(repo, 'main').state.generations).toHaveLength(0);
462
+ expect(spawned).toBe(0);
463
+ // New supervisor instance represents a process restart. Repeated early
464
+ // ticks cannot spend or reset the durable retry budget.
465
+ const restarted = new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, spawn, undefined, observe, true);
466
+ expect(await restarted.tick()).toBe('continue');
467
+ expect((0, store_1.readJournal)(repo, 'main').state).toHaveProperty('admissionRetry.attempts', 1);
468
+ now += 5000;
469
+ verdict = 'current';
470
+ expect(await restarted.tick()).toBe('continue');
471
+ expect((0, store_1.readJournal)(repo, 'main').state).not.toHaveProperty('admissionRetry');
472
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('IN_PROGRESS');
473
+ expect((0, store_1.readJournal)(repo, 'main').state.generations).toHaveLength(1);
474
+ expect(spawned).toBeGreaterThan(0);
475
+ });
476
+ test('stale currentness is conclusive and never receives transient retry budget', async () => {
477
+ initUnattendedFixture(repo);
478
+ const blocked = async () => ({ state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'current', currentness: 'stale', sensors: 'not-required', diagnostics: [{ code: 'ADMISSION_CURRENTNESS_BLOCKED', message: 'stale' }] });
479
+ let spawned = 0;
480
+ expect(await new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, () => { spawned++; }, undefined, blocked).tick()).toBe('custody');
481
+ const state = (0, store_1.readJournal)(repo, 'main').state;
482
+ expect(state.cycle.status).toBe('BLOCKED');
483
+ expect(state).not.toHaveProperty('admissionRetry');
484
+ expect(state.generations).toHaveLength(0);
485
+ expect(spawned).toBe(0);
486
+ });
487
+ test('first admission observation binds provider, runtime and autonomy; changed restart identity never dispatches', async () => {
488
+ initUnattendedFixture(repo);
489
+ const identity = { kind: 'native', version: '1.0.0', accountScopeDigest: 'a'.repeat(64) };
490
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, routingIdentity: identity, controllerAutonomy: 'approval-free' };
491
+ const notCurrent = async () => ({ state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'not-required', currentness: 'unverifiable', sensors: 'not-required', diagnostics: [{ code: 'ADMISSION_CURRENTNESS_BLOCKED', message: 'temporary' }] });
492
+ let spawns = 0;
493
+ expect(await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawns++; }, undefined, notCurrent).tick()).toBe('continue');
494
+ expect((0, store_1.readJournal)(repo, 'main').state).toHaveProperty('admissionContext', { provider: 'codex', runtime: identity, controllerAutonomy: 'approval-free' });
495
+ const changed = { ...cfg, routingIdentity: { ...identity, accountScopeDigest: 'b'.repeat(64) } };
496
+ expect(await new supervisor_1.Supervisor(repo, 'main', changed, () => { spawns++; }, undefined, admitted).tick()).toBe('custody');
497
+ expect((0, store_1.readJournal)(repo, 'main').state.cycle.status).toBe('BLOCKED');
498
+ expect(spawns).toBe(0);
499
+ });
500
+ test('alternating transient causes share one durable retry budget', async () => {
501
+ initUnattendedFixture(repo);
502
+ let now = Date.now();
503
+ jest.spyOn(Date, 'now').mockImplementation(() => now);
504
+ let reads = 0;
505
+ const alternating = async () => {
506
+ const sensor = reads++ % 2 === 1;
507
+ return sensor
508
+ ? { state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'not-required', currentness: 'current', sensors: 'not-certified', diagnostics: [{ code: 'ADMISSION_SENSORS_BLOCKED', message: 'temporary' }] }
509
+ : { state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'not-required', currentness: 'unverifiable', sensors: 'not-required', diagnostics: [{ code: 'ADMISSION_CURRENTNESS_BLOCKED', message: 'temporary' }] };
510
+ };
511
+ let spawns = 0;
512
+ for (let i = 0; i < supervisor_1.MAX_SENSOR_DEFERRALS + 1; i++) {
513
+ const outcome = await new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, () => { spawns++; }, undefined, alternating).tick();
514
+ expect(outcome).toBe(i < supervisor_1.MAX_SENSOR_DEFERRALS ? 'continue' : 'custody');
515
+ now += 5000;
516
+ }
517
+ expect(reads).toBe(supervisor_1.MAX_SENSOR_DEFERRALS + 1);
518
+ expect(spawns).toBe(0);
519
+ expect((0, store_1.readJournal)(repo, 'main').state.generations).toHaveLength(0);
520
+ });
521
+ test('an incomplete first configuration can be corrected before dispatch without trapping the cycle', async () => {
522
+ initUnattendedFixture(repo);
523
+ const notCurrent = async () => ({ state: 'blocked', planState: 'valid', executionMode: 'desatendido', journal: 'not-required', currentness: 'unverifiable', sensors: 'not-required', diagnostics: [{ code: 'ADMISSION_CURRENTNESS_BLOCKED', message: 'temporary' }] });
524
+ let spawned = 0;
525
+ expect(await new supervisor_1.Supervisor(repo, 'main', supervisor_1.DEFAULT_SUPERVISOR_CONFIG, () => { spawned++; }, undefined, notCurrent).tick()).toBe('continue');
526
+ expect((0, store_1.readJournal)(repo, 'main').state).not.toHaveProperty('admissionContext');
527
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, routingIdentity: { kind: 'native', version: '1.0.0', accountScopeDigest: 'a'.repeat(64) }, controllerAutonomy: 'approval-free' };
528
+ const retry = (0, store_1.readJournal)(repo, 'main').state.admissionRetry;
529
+ jest.spyOn(Date, 'now').mockReturnValue(Date.parse(retry.nextRetryAt));
530
+ expect(await new supervisor_1.Supervisor(repo, 'main', cfg, () => { spawned++; }, undefined, admitted, true).tick()).toBe('continue');
531
+ expect((0, store_1.readJournal)(repo, 'main').state).toHaveProperty('admissionContext', { provider: 'codex', runtime: cfg.routingIdentity, controllerAutonomy: 'approval-free' });
532
+ expect(spawned).toBeGreaterThan(0);
533
+ });
446
534
  test('una admisión compacta bloqueada no lanza controlador ni wrappers', async () => {
447
535
  initUnattendedFixture(repo);
448
536
  const state = (0, store_1.readJournal)(repo, 'main').state;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.12.1",
3
+ "version": "9.12.2",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file