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.
- package/dist/src/commands/watch/admission-recovery.js +161 -0
- package/dist/src/commands/watch/index.js +31 -0
- package/dist/src/commands/watch/supervisor.js +98 -43
- package/dist/src/core/journal/types.js +38 -1
- package/dist/tests/commands/watch/admission-recovery.test.js +278 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +88 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
//
|
|
345
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|