agentic-workflow-manager 9.12.0 → 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.
@@ -2,6 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.computeGate = computeGate;
4
4
  exports.computeTrackGate = computeTrackGate;
5
+ // Interlock mecanico (design R3.2): falla CERRADO. Solo `pass` con fingerprint
6
+ // VIGENTE satisface (R1.4c, RF-2.8). Cada agujero del bloqueador 5 de la
7
+ // review tiene su categoria propia — nada aprueba por vacuidad ni por
8
+ // referencia falsa.
9
+ const types_1 = require("../../core/journal/types");
5
10
  const store_1 = require("../../core/journal/store");
6
11
  const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
7
12
  function sameSelection(left, right) {
@@ -12,7 +17,7 @@ function evaluateEvidence(state, fingerprintNow, scope) {
12
17
  if (state.cycle.status === 'BLOCKED') {
13
18
  reasons.push({ category: 'cycle-blocked', detail: `ciclo BLOCKED: ${state.cycle.blockedReason ?? 'sin razon registrada'}` });
14
19
  }
15
- for (const problem of state.requestProblems) {
20
+ for (const problem of (0, types_1.activeRequestProblems)(state)) {
16
21
  reasons.push({ category: 'request-problem', detail: `request ${problem.kind} en ${problem.file}: ${problem.detail}` });
17
22
  }
18
23
  for (const j of Object.values(state.jobs)) {
@@ -116,13 +116,34 @@ function registerJobCommand(program) {
116
116
  }
117
117
  if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
118
118
  throw new Error('--json requiere un objeto JSON');
119
+ if ('entity' in payload && payload.entity !== opts.entity) {
120
+ throw new Error('--json entity debe coincidir con --entity');
121
+ }
119
122
  const repo = process.cwd();
120
123
  const branch = branchOf(repo);
121
124
  assertAuthenticatedCwd(repo, branch);
125
+ if (opts.entity === 'task') {
126
+ const current = (0, store_1.readJournal)(repo, branch);
127
+ if (current.corrupt || current.state === null)
128
+ throw new Error('register --entity task requiere journal valido');
129
+ const taskId = payload.taskId;
130
+ const rawPlan = payload.verificationPlan;
131
+ const allowedKinds = ['test', 'lint', 'sensors', 'review', 'qa', 'interlock', 'track-integration'];
132
+ if (typeof taskId !== 'string' || taskId.length === 0)
133
+ throw new Error('register --entity task requiere taskId');
134
+ if (rawPlan !== undefined && (!Array.isArray(rawPlan) || !rawPlan.every(item => typeof item === 'object' && item !== null && typeof item.id === 'string' && item.id.length > 0
135
+ && allowedKinds.includes(item.kind) && (item.satisfiedBy === undefined || typeof item.satisfiedBy === 'string')))) {
136
+ throw new Error('register --entity task requiere verificationPlan valido');
137
+ }
138
+ const plan = (rawPlan ?? []);
139
+ const missing = current.state.requiredVerifiers.filter(kind => !plan.some(item => item.kind === kind));
140
+ if (missing.length > 0)
141
+ throw new Error(`register --entity task: verificationPlan no cubre los verificadores requeridos: ${missing.join(', ')}`);
142
+ }
122
143
  const r = (0, requests_1.emitRequest)(repo, branch, {
123
144
  kind: 'register-entity', generationToken: opts.generation,
124
145
  idempotencyKey: crypto_1.default.createHash('sha256').update(`${opts.entity}:${opts.json}`).digest('hex'),
125
- payload: { entity: opts.entity, ...payload },
146
+ payload: { ...payload, entity: opts.entity },
126
147
  });
127
148
  process.stdout.write(JSON.stringify({ requestId: r.requestId }, null, 2) + '\n');
128
149
  });
@@ -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
+ }
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.applyCyclePlanAppend = applyCyclePlanAppend;
7
7
  exports.registerTrackIntegrationItems = registerTrackIntegrationItems;
8
+ exports.applyRequestToState = applyRequestToState;
8
9
  exports.consumePendingRequests = consumePendingRequests;
9
10
  // Consumo transaccional de requests (bloqueador 4): mutar estado ->
10
11
  // writeJournal -> RECIEN AHI borrar archivos. El replay es seguro por
@@ -502,6 +503,8 @@ function applyRequestToState(s, env, digest, repoRoot, eventScope) {
502
503
  }
503
504
  if (s.cycle.status !== 'BLOCKED')
504
505
  throw new Error('custody-decision solo aplica a un ciclo BLOCKED');
506
+ if ((0, types_1.activeRequestProblems)(s).length > 0)
507
+ throw new Error('custody-decision no resuelve requests rechazadas pendientes');
505
508
  s.custodyDecisions ??= [];
506
509
  s.custodyDecisions.push({ at: now(), decision: 'resume', reason: p.reason, generationToken: env.generationToken });
507
510
  for (const generation of s.generations) {
@@ -666,7 +669,10 @@ function consumePendingRequests(repoRoot, branch, activeToken, eventScope) {
666
669
  rejectedInvalid++;
667
670
  deferredRenames.push({ from: p.file, to: `${p.file}.rejected` });
668
671
  if (!s.requestProblems.some((problem) => problem.file === p.file && problem.kind === 'rejected')) {
669
- s.requestProblems.push({ file: p.file, kind: 'rejected', detail: (0, redact_1.redactText)(e.message), at: now() });
672
+ s.requestProblems.push({ file: p.file, kind: 'rejected', detail: (0, redact_1.redactText)(e.message), at: now(),
673
+ requestId: env.requestId, generationToken: env.generationToken,
674
+ ...(env.kind === 'register-entity' && env.payload.entity === 'task' && typeof env.payload.taskId === 'string'
675
+ ? { taskId: env.payload.taskId } : {}) });
670
676
  }
671
677
  (0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-rejected-invalid', requestId: env.requestId, detail: e.message });
672
678
  dirChanged = true;
@@ -50,8 +50,10 @@ function watchJournalStatus(repoRoot, branch) {
50
50
  }
51
51
  catch { /* recognized present journal, but never eligible by absence alone */ }
52
52
  const binding = current.state.planBinding;
53
+ const latest = current.state.generations.reduce((prev, generation) => prev === undefined || generation.n > prev.n ? generation : prev, undefined);
53
54
  return {
54
55
  state: 'present', cycleState: current.state.cycle.status, bootstrapUnused,
56
+ ...(latest ? { latestGeneration: { n: latest.n, token: latest.token, state: latest.state } } : {}),
55
57
  ...(binding ? { binding: { path: binding.path, digest: binding.digest, ...(binding.executionDigest ? { executionDigest: binding.executionDigest, executionIdentitySchema: binding.executionIdentitySchema } : {}), schema: binding.schema, executionMode: binding.executionMode, boundAt: binding.boundAt } } : {}),
56
58
  };
57
59
  }
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerWatchCommand = registerWatchCommand;
7
7
  const child_process_1 = require("child_process");
8
8
  const init_1 = require("./init");
9
+ const request_recovery_1 = require("./request-recovery");
10
+ const admission_recovery_1 = require("./admission-recovery");
9
11
  const supervisor_1 = require("./supervisor");
10
12
  const capabilities_1 = require("../../core/model-policy/capabilities");
11
13
  const adapter_1 = require("../../core/journal/adapter");
@@ -144,6 +146,59 @@ function registerWatchCommand(program) {
144
146
  await (0, supervisor_1.runSupervisorLoop)(repo, branch, cfg);
145
147
  process.stdout.write('gate verde: ciclo COMPLETE — drenado, lock liberado, apagando\n');
146
148
  });
149
+ watch.command('recover-request')
150
+ .description('reconcilia una task rechazada con una request correctiva aplicada bajo lock exclusivo')
151
+ .requiredOption('--rejected <requestId>', 'request rechazada archivada')
152
+ .requiredOption('--replacement <requestId>', 'request correctiva pendiente')
153
+ .requiredOption('--generation <token>', 'generacion vigente')
154
+ .requiredOption('--reason <text>', 'motivo auditado de la recuperacion')
155
+ .option('--resume', 'reanuda explicitamente custodia si el unico bloqueo era la request')
156
+ .action((opts) => {
157
+ const repo = process.cwd();
158
+ const branch = currentBranch(repo);
159
+ try {
160
+ (0, context_1.resolveCommandContext)(repo, branch);
161
+ (0, request_recovery_1.recoverRejectedTask)(repo, branch, {
162
+ rejectedRequestId: opts.rejected, replacementRequestId: opts.replacement,
163
+ generationToken: opts.generation, reason: opts.reason, resume: opts.resume === true,
164
+ });
165
+ process.stdout.write(JSON.stringify({ recovered: true, rejectedRequestId: opts.rejected, replacementRequestId: opts.replacement, resumed: opts.resume === true }) + '\n');
166
+ }
167
+ catch (error) {
168
+ process.stderr.write(`${error.message}\n`);
169
+ process.exitCode = 1;
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
+ });
147
202
  watch
148
203
  .command('rebind')
149
204
  .description('reconcilia intencionalmente el binding desatendido tras un cambio válido del ciclo del plan')
@@ -13,6 +13,7 @@ const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const store_1 = require("../../core/journal/store");
15
15
  const paths_1 = require("../../core/journal/paths");
16
+ const types_1 = require("../../core/journal/types");
16
17
  const lock_1 = require("./lock");
17
18
  const validate_1 = require("../../core/plan/validate");
18
19
  const identity_1 = require("../../core/plan/identity");
@@ -104,7 +105,7 @@ function rebindBlocker(state) {
104
105
  const nonterminal = Object.values(state.jobs).filter(job => !TERMINAL_JOB_STATES.has(job.executionState));
105
106
  if (nonterminal.length > 0)
106
107
  return `hay jobs no terminales: ${nonterminal.map(job => job.id).join(', ')}`;
107
- if (state.requestProblems.length > 0)
108
+ if ((0, types_1.activeRequestProblems)(state).length > 0)
108
109
  return 'hay conflictos durables de requests';
109
110
  if (state.verdicts.some(verdict => verdict.result !== 'pass'))
110
111
  return 'hay evidencia adversa durable';
@@ -0,0 +1,153 @@
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.recoverRejectedTask = recoverRejectedTask;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const json_1 = require("../../core/plan/json");
10
+ const atomic_file_1 = require("../../core/atomic-file");
11
+ const types_1 = require("../../core/journal/types");
12
+ const requests_1 = require("../../core/journal/requests");
13
+ const paths_1 = require("../../core/journal/paths");
14
+ const store_1 = require("../../core/journal/store");
15
+ const process_1 = require("../../core/journal/process");
16
+ const apply_1 = require("./apply");
17
+ const lock_1 = require("./lock");
18
+ const REQUEST_ID = /^req-[0-9]+-[0-9]{10}-[a-f0-9]{8}$/;
19
+ const MAX_REQUEST_BYTES = 256 * 1024;
20
+ function validatedId(value, label) {
21
+ if (typeof value !== 'string' || !REQUEST_ID.test(value))
22
+ throw new Error(`${label} requiere un requestId valido`);
23
+ return value;
24
+ }
25
+ /** Reads only one canonical, bounded request file. The inode check closes a
26
+ * swap between lstat and open, including platforms without O_NOFOLLOW. */
27
+ function requestEnvelope(file, requestId) {
28
+ const before = fs_1.default.lstatSync(file);
29
+ if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > MAX_REQUEST_BYTES) {
30
+ throw new Error('request de recovery no es un archivo regular acotado');
31
+ }
32
+ const descriptor = fs_1.default.openSync(file, fs_1.default.constants.O_RDONLY | (fs_1.default.constants.O_NOFOLLOW ?? 0));
33
+ let body;
34
+ try {
35
+ const opened = fs_1.default.fstatSync(descriptor);
36
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) {
37
+ throw new Error('request de recovery cambio de identidad');
38
+ }
39
+ body = fs_1.default.readFileSync(descriptor, { encoding: 'utf8' });
40
+ }
41
+ finally {
42
+ fs_1.default.closeSync(descriptor);
43
+ }
44
+ const parsed = (0, json_1.parseJsonNoDuplicate)(body);
45
+ if (!(0, requests_1.isWellFormedEnvelope)(parsed) || parsed.requestId !== requestId)
46
+ throw new Error('request de recovery tiene envelope invalido');
47
+ return parsed;
48
+ }
49
+ function taskId(envelope) {
50
+ if (envelope.kind !== 'register-entity' || envelope.payload.entity !== 'task'
51
+ || typeof envelope.payload.taskId !== 'string' || envelope.payload.taskId.length === 0) {
52
+ throw new Error('recovery solo admite register-entity task con taskId');
53
+ }
54
+ return envelope.payload.taskId;
55
+ }
56
+ function mayResume(state) {
57
+ if (state.cycle.status !== 'BLOCKED' || state.cycle.blockedReason !== 'recovery no autorizado: hay conflictos durables de requests') {
58
+ throw new Error('resume requiere custodia BLOCKED por requests');
59
+ }
60
+ if ((0, types_1.activeRequestProblems)(state).length > 0)
61
+ throw new Error('resume requiere resolver todas las requests activas');
62
+ }
63
+ /** Offline recovery lane: no supervisor or controller may own this journal.
64
+ * The correction and its audit link are published in one state transaction. */
65
+ function recoverRejectedTask(repoRoot, branch, input) {
66
+ if (typeof repoRoot !== 'string' || !path_1.default.isAbsolute(repoRoot) || typeof branch !== 'string' || branch.length === 0
67
+ || !input || typeof input !== 'object' || typeof input.generationToken !== 'string' || input.generationToken.length === 0
68
+ || typeof input.reason !== 'string' || input.reason.trim().length === 0 || input.reason.length > 1024 || typeof input.resume !== 'boolean') {
69
+ throw new Error('parametros de recovery invalidos');
70
+ }
71
+ const rejectedId = validatedId(input.rejectedRequestId, '--rejected');
72
+ const replacementId = validatedId(input.replacementRequestId, '--replacement');
73
+ if (rejectedId === replacementId)
74
+ throw new Error('la request correctiva debe ser distinta');
75
+ const lock = (0, lock_1.acquireLock)(repoRoot);
76
+ try {
77
+ const read = (0, store_1.readJournal)(repoRoot, branch);
78
+ if (read.corrupt || !read.state || read.state.schema !== 2 || !read.state.planBinding)
79
+ throw new Error('recovery requiere journal schema-2 vigente');
80
+ const state = read.state;
81
+ const latest = [...state.generations].sort((a, b) => b.n - a.n)[0];
82
+ if (!latest || latest.token !== input.generationToken)
83
+ throw new Error('generation de recovery no es la vigente');
84
+ for (const generation of state.generations) {
85
+ for (const ref of [generation.processRef, generation.wrapperRef]) {
86
+ if (ref && (0, process_1.refIsAlive)(ref))
87
+ throw new Error('controller vivo: recovery requiere custodia exclusiva');
88
+ }
89
+ }
90
+ const dir = (0, paths_1.requestsDir)(repoRoot, branch);
91
+ const originalFile = path_1.default.join(dir, `${rejectedId}.json`);
92
+ const problem = state.requestProblems.find(item => item.kind === 'rejected' && item.file === originalFile);
93
+ if (!problem)
94
+ throw new Error('rechazo durable no encontrado');
95
+ if (problem.resolution) {
96
+ if (problem.resolution.replacementRequestId !== replacementId || problem.resolution.generationToken !== input.generationToken || problem.resolution.reason !== input.reason
97
+ || (0, types_1.activeRequestProblems)(state).includes(problem))
98
+ throw new Error('recovery previo incompatible');
99
+ if (input.resume && state.cycle.status === 'BLOCKED') {
100
+ const candidate = structuredClone(state);
101
+ mayResume(candidate);
102
+ candidate.custodyDecisions ??= [];
103
+ candidate.custodyDecisions.push({ at: new Date().toISOString(), decision: 'resume', reason: input.reason, generationToken: input.generationToken });
104
+ candidate.cycle.status = 'IN_PROGRESS';
105
+ candidate.cycle.blockedReason = undefined;
106
+ for (const generation of candidate.generations)
107
+ if (generation.state === 'active' || generation.state === 'controller-suspected-stall')
108
+ generation.state = 'superseded';
109
+ (0, store_1.writeJournal)(repoRoot, branch, candidate);
110
+ }
111
+ return;
112
+ }
113
+ const original = requestEnvelope(`${originalFile}.rejected`, rejectedId);
114
+ const replacementFile = path_1.default.join(dir, `${replacementId}.json`);
115
+ const replacement = requestEnvelope(replacementFile, replacementId);
116
+ if (original.generationToken !== input.generationToken || replacement.generationToken !== input.generationToken || taskId(original) !== taskId(replacement)) {
117
+ throw new Error('request correctiva no enlaza el mismo taskId o generation');
118
+ }
119
+ if (state.tasks.some(task => task.id === taskId(original)))
120
+ throw new Error('task ya existente: recovery requiere un registro rechazado sin aplicar');
121
+ if (state.appliedRequests[replacementId] !== undefined || Object.values(state.appliedRequests).some(item => item.idempotencyKey === replacement.idempotencyKey && item.payloadDigest !== (0, requests_1.digestOf)(replacement.payload))) {
122
+ throw new Error('request correctiva ya tiene outcome o idempotencyKey conflictiva');
123
+ }
124
+ const candidate = structuredClone(state);
125
+ (0, apply_1.applyRequestToState)(candidate, replacement, (0, requests_1.digestOf)(replacement.payload), repoRoot);
126
+ if (candidate.appliedRequests[replacementId]?.outcome !== 'applied' || !candidate.tasks.some(task => task.id === taskId(replacement))) {
127
+ throw new Error('request correctiva no fue aplicada');
128
+ }
129
+ const target = candidate.requestProblems.find(item => item.kind === 'rejected' && item.file === originalFile);
130
+ target.requestId = rejectedId;
131
+ target.taskId = taskId(original);
132
+ target.generationToken = original.generationToken;
133
+ target.resolution = { replacementRequestId: replacementId, replacementPayloadDigest: (0, requests_1.digestOf)(replacement.payload), reason: input.reason,
134
+ generationToken: input.generationToken, at: new Date().toISOString() };
135
+ if (input.resume) {
136
+ mayResume(candidate);
137
+ candidate.custodyDecisions ??= [];
138
+ candidate.custodyDecisions.push({ at: new Date().toISOString(), decision: 'resume', reason: input.reason, generationToken: input.generationToken });
139
+ candidate.cycle.status = 'IN_PROGRESS';
140
+ candidate.cycle.blockedReason = undefined;
141
+ for (const generation of candidate.generations)
142
+ if (generation.state === 'active' || generation.state === 'controller-suspected-stall')
143
+ generation.state = 'superseded';
144
+ }
145
+ (0, store_1.writeJournal)(repoRoot, branch, candidate);
146
+ fs_1.default.rmSync(replacementFile);
147
+ (0, atomic_file_1.fsyncDirSync)(dir);
148
+ (0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-recovered', rejectedRequestId: rejectedId, replacementRequestId: replacementId, generationToken: input.generationToken, resumed: input.resume });
149
+ }
150
+ finally {
151
+ (0, lock_1.releaseLock)(repoRoot, lock);
152
+ }
153
+ }