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