ai-maestro 1.0.0

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +15 -0
  3. package/README.md +727 -0
  4. package/bin/maestro.mjs +246 -0
  5. package/package.json +29 -0
  6. package/src/checkpoint.mjs +102 -0
  7. package/src/codex-diagnostics.mjs +682 -0
  8. package/src/codex-home-inspect.mjs +252 -0
  9. package/src/codex-home.mjs +258 -0
  10. package/src/commands.mjs +809 -0
  11. package/src/config.mjs +11 -0
  12. package/src/debug.mjs +164 -0
  13. package/src/encoding-guard.mjs +125 -0
  14. package/src/engines/ai-router-engine.mjs +37 -0
  15. package/src/engines/codex-engine.mjs +21 -0
  16. package/src/engines/engine.mjs +21 -0
  17. package/src/engines/registry.mjs +29 -0
  18. package/src/files.mjs +65 -0
  19. package/src/format.mjs +132 -0
  20. package/src/lock.mjs +439 -0
  21. package/src/memory-log.mjs +18 -0
  22. package/src/memory.mjs +1 -0
  23. package/src/orchestration/attempt-chain-runtime.mjs +627 -0
  24. package/src/orchestration/budget-manager.mjs +121 -0
  25. package/src/orchestration/capability-registry.mjs +108 -0
  26. package/src/orchestration/consolidator.mjs +772 -0
  27. package/src/orchestration/delegation-executor.mjs +262 -0
  28. package/src/orchestration/delegation-manager.mjs +116 -0
  29. package/src/orchestration/deployment-intent.mjs +36 -0
  30. package/src/orchestration/engine-history.mjs +110 -0
  31. package/src/orchestration/engine-policy.mjs +73 -0
  32. package/src/orchestration/engine-selector.mjs +187 -0
  33. package/src/orchestration/execution-context.mjs +45 -0
  34. package/src/orchestration/failure-classifier.mjs +136 -0
  35. package/src/orchestration/failure-evidence.mjs +237 -0
  36. package/src/orchestration/fallback-chain-lock.mjs +217 -0
  37. package/src/orchestration/fallback-chain-trail.mjs +218 -0
  38. package/src/orchestration/fallback-executor.mjs +146 -0
  39. package/src/orchestration/fallback-graph.mjs +106 -0
  40. package/src/orchestration/fallback-recommendation.mjs +73 -0
  41. package/src/orchestration/file-conflict-detector.mjs +126 -0
  42. package/src/orchestration/host-validation.mjs +241 -0
  43. package/src/orchestration/orchestration-loop.mjs +1971 -0
  44. package/src/orchestration/orchestration-runtime.mjs +1019 -0
  45. package/src/orchestration/orchestration-scheduler.mjs +135 -0
  46. package/src/orchestration/orchestration-trail.mjs +192 -0
  47. package/src/orchestration/preflight.mjs +212 -0
  48. package/src/orchestration/provider-health.mjs +566 -0
  49. package/src/orchestration/provider-router.mjs +352 -0
  50. package/src/orchestration/rc-check-adapters.mjs +817 -0
  51. package/src/orchestration/rc-check.mjs +544 -0
  52. package/src/orchestration/rc-policy.mjs +200 -0
  53. package/src/orchestration/runtime-gate.mjs +146 -0
  54. package/src/orchestration/sector-managers.mjs +215 -0
  55. package/src/orchestration/self-hosting-canary.mjs +231 -0
  56. package/src/orchestration/self-hosting-readiness.mjs +244 -0
  57. package/src/orchestration/spec-planner.mjs +877 -0
  58. package/src/orchestration/subtask-planner.mjs +176 -0
  59. package/src/orchestration/task-classifier.mjs +241 -0
  60. package/src/orchestration/verifier.mjs +1608 -0
  61. package/src/orchestration-commands.mjs +279 -0
  62. package/src/planner.mjs +38 -0
  63. package/src/project.mjs +1 -0
  64. package/src/run-diagnostics.mjs +641 -0
  65. package/src/runner.mjs +243 -0
  66. package/src/safety.mjs +116 -0
  67. package/src/schema.mjs +371 -0
  68. package/src/session-commands.mjs +521 -0
  69. package/src/shell.mjs +93 -0
  70. package/src/smart-planner.mjs +249 -0
  71. package/src/task-commands.mjs +1182 -0
  72. package/src/tasks.mjs +134 -0
  73. package/src/ui/commands.mjs +76 -0
  74. package/src/ui/events.mjs +45 -0
  75. package/src/ui/public/app.js +600 -0
  76. package/src/ui/public/index.html +88 -0
  77. package/src/ui/public/style.css +460 -0
  78. package/src/ui/server.mjs +112 -0
  79. package/src/ui/state.mjs +504 -0
  80. package/src/usage.mjs +178 -0
  81. package/src/workspace-diff.mjs +228 -0
@@ -0,0 +1,566 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+
4
+ import { CONFIG } from '../config.mjs';
5
+ import { readJsonSafe, stripBom } from '../files.mjs';
6
+ import { redactCredentials, redactObject } from '../format.mjs';
7
+ import { stampSchemaVersion } from '../schema.mjs';
8
+ import { withAccountingLock } from '../task-commands.mjs';
9
+ import {
10
+ HEALTH_AFFECTING_FAILURE_CLASSES,
11
+ LONG_COOLDOWN_FAILURE_CLASSES,
12
+ EVIDENCE_SUMMARY_LIMIT,
13
+ validateRetryAfterMs
14
+ } from './failure-evidence.mjs';
15
+
16
+ // 03H1 intentionally reuses withAccountingLock, which is an in-memory
17
+ // promise mutex in one Node process. It protects concurrent calls made by the
18
+ // same process only. It does not provide cross-process mutual exclusion, does
19
+ // not prevent lost updates between separate CLI invocations, and is not an
20
+ // exactly-once guarantee between processes.
21
+
22
+ export const PROVIDER_HEALTH_FILE = 'PROVIDER_HEALTH.json';
23
+ export const HEALTH_EVENTS_FILE = 'HEALTH_EVENTS.jsonl';
24
+ export const FAILURE_THRESHOLD = 3;
25
+ export const CIRCUIT_STATES = new Set(['CLOSED', 'OPEN', 'HALF_OPEN']);
26
+ export const SHORT_COOLDOWN_INITIAL_MS = 60000;
27
+ export const LONG_COOLDOWN_INITIAL_MS = 1800000;
28
+ export const SHORT_COOLDOWN_MAX_MS = 3600000;
29
+ export const LONG_COOLDOWN_MAX_MS = 14400000;
30
+
31
+ const VALID_EVENT_TYPES = new Set([
32
+ 'health_observation_recorded',
33
+ 'circuit_opened',
34
+ 'circuit_half_opened',
35
+ 'circuit_closed',
36
+ 'health_snapshot_rebuilt',
37
+ 'failure_classified',
38
+ 'fallback_recommended'
39
+ ]);
40
+
41
+ const inProcessHalfOpenProbes = new Map();
42
+
43
+ function nowIso(timestamp) {
44
+ if (timestamp instanceof Date) return timestamp.toISOString();
45
+ if (typeof timestamp === 'string' && !Number.isNaN(Date.parse(timestamp))) return new Date(timestamp).toISOString();
46
+ return new Date().toISOString();
47
+ }
48
+
49
+ function nowMs(timestamp) {
50
+ return Date.parse(nowIso(timestamp));
51
+ }
52
+
53
+ function healthPath(projectRoot = process.cwd()) {
54
+ return path.join(projectRoot, CONFIG.maestroPath, PROVIDER_HEALTH_FILE);
55
+ }
56
+
57
+ function eventsPath(projectRoot = process.cwd()) {
58
+ return path.join(projectRoot, CONFIG.maestroPath, HEALTH_EVENTS_FILE);
59
+ }
60
+
61
+ function emptySnapshot(generatedAt = null) {
62
+ return {
63
+ schemaVersion: 1,
64
+ generatedAt,
65
+ lastAppliedEventSequence: 0,
66
+ targets: {}
67
+ };
68
+ }
69
+
70
+ function sanitizeString(value) {
71
+ return redactCredentials(value == null ? '' : String(value));
72
+ }
73
+
74
+ function normalizeMetadata(metadata = {}) {
75
+ const safe = redactObject({
76
+ provider: metadata && metadata.provider != null ? String(metadata.provider) : null,
77
+ model: metadata && metadata.model != null ? String(metadata.model) : null
78
+ });
79
+ return {
80
+ provider: safe.provider || null,
81
+ model: safe.model || null
82
+ };
83
+ }
84
+
85
+ function normalizeEvidenceSummary(value) {
86
+ const redacted = redactCredentials(value == null ? '' : String(value));
87
+ return redacted.length > EVIDENCE_SUMMARY_LIMIT ? redacted.slice(0, EVIDENCE_SUMMARY_LIMIT) : redacted;
88
+ }
89
+
90
+ function makeEventId(sequence) {
91
+ return 'health-' + process.pid + '-' + Date.now().toString(36) + '-' + sequence + '-' + Math.random().toString(36).slice(2, 8);
92
+ }
93
+
94
+ export function deriveHealthTargetId(engineRecord = {}) {
95
+ if (!engineRecord || typeof engineRecord.id !== 'string' || engineRecord.id.trim() === '') {
96
+ throw new TypeError('engineRecord.id is required to derive healthTargetId');
97
+ }
98
+ return engineRecord.id;
99
+ }
100
+
101
+ function defaultEntry(healthTargetId, timestamp = null, metadata = {}) {
102
+ const iso = timestamp ? nowIso(timestamp) : null;
103
+ return {
104
+ healthTargetId: sanitizeString(healthTargetId),
105
+ state: 'CLOSED',
106
+ consecutiveFailures: 0,
107
+ consecutiveSuccesses: 0,
108
+ lastFailureAt: null,
109
+ lastSuccessAt: null,
110
+ lastFailureClass: null,
111
+ openedAt: null,
112
+ cooldownUntil: null,
113
+ halfOpenProbeAt: null,
114
+ consecutiveOpenCycles: 0,
115
+ observedRetryAfterMs: null,
116
+ metadata: normalizeMetadata(metadata),
117
+ updatedAt: iso
118
+ };
119
+ }
120
+
121
+ function normalizeEntry(entry, healthTargetId) {
122
+ const base = defaultEntry(healthTargetId);
123
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return base;
124
+ const state = CIRCUIT_STATES.has(entry.state) ? entry.state : 'CLOSED';
125
+ const normalized = {
126
+ ...base,
127
+ ...entry,
128
+ healthTargetId: sanitizeString(entry.healthTargetId || healthTargetId),
129
+ state,
130
+ consecutiveFailures: Number.isInteger(entry.consecutiveFailures) && entry.consecutiveFailures >= 0 ? entry.consecutiveFailures : 0,
131
+ consecutiveSuccesses: Number.isInteger(entry.consecutiveSuccesses) && entry.consecutiveSuccesses >= 0 ? entry.consecutiveSuccesses : 0,
132
+ lastFailureAt: typeof entry.lastFailureAt === 'string' ? entry.lastFailureAt : null,
133
+ lastSuccessAt: typeof entry.lastSuccessAt === 'string' ? entry.lastSuccessAt : null,
134
+ lastFailureClass: typeof entry.lastFailureClass === 'string' ? entry.lastFailureClass : null,
135
+ openedAt: typeof entry.openedAt === 'string' ? entry.openedAt : null,
136
+ cooldownUntil: typeof entry.cooldownUntil === 'string' ? entry.cooldownUntil : null,
137
+ halfOpenProbeAt: typeof entry.halfOpenProbeAt === 'string' ? entry.halfOpenProbeAt : null,
138
+ consecutiveOpenCycles: Number.isInteger(entry.consecutiveOpenCycles) && entry.consecutiveOpenCycles >= 0 ? entry.consecutiveOpenCycles : 0,
139
+ observedRetryAfterMs: typeof entry.observedRetryAfterMs === 'number' && Number.isFinite(entry.observedRetryAfterMs) ? entry.observedRetryAfterMs : null,
140
+ metadata: normalizeMetadata(entry.metadata || {}),
141
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : null
142
+ };
143
+ return redactObject(normalized);
144
+ }
145
+
146
+ function normalizeSnapshot(value, generatedAt = null) {
147
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return emptySnapshot(generatedAt);
148
+ const snapshot = {
149
+ schemaVersion: 1,
150
+ generatedAt: typeof value.generatedAt === 'string' ? value.generatedAt : generatedAt,
151
+ lastAppliedEventSequence: Number.isInteger(value.lastAppliedEventSequence) && value.lastAppliedEventSequence >= 0 ? value.lastAppliedEventSequence : 0,
152
+ targets: {}
153
+ };
154
+ const targets = value.targets && typeof value.targets === 'object' && !Array.isArray(value.targets) ? value.targets : {};
155
+ for (const [targetId, entry] of Object.entries(targets)) {
156
+ const safeId = sanitizeString(targetId);
157
+ snapshot.targets[safeId] = normalizeEntry(entry, safeId);
158
+ }
159
+ return snapshot;
160
+ }
161
+
162
+ export function validateProviderHealthSnapshot(value) {
163
+ const errors = [];
164
+ if (!value || typeof value !== 'object' || Array.isArray(value)) errors.push('snapshot must be an object');
165
+ if (value && value.schemaVersion !== 1) errors.push('schemaVersion must be 1');
166
+ if (value && (!Number.isInteger(value.lastAppliedEventSequence) || value.lastAppliedEventSequence < 0)) errors.push('lastAppliedEventSequence must be a non-negative integer');
167
+ if (value && (!value.targets || typeof value.targets !== 'object' || Array.isArray(value.targets))) errors.push('targets must be an object');
168
+ return { ok: errors.length === 0, errors };
169
+ }
170
+
171
+ export function validateHealthEvent(value) {
172
+ const errors = [];
173
+ if (!value || typeof value !== 'object' || Array.isArray(value)) errors.push('event must be an object');
174
+ if (value && value.schemaVersion !== 1) errors.push('schemaVersion must be 1');
175
+ if (value && !VALID_EVENT_TYPES.has(value.eventType)) errors.push('eventType is invalid');
176
+ if (value && (!Number.isInteger(value.sequence) || value.sequence < 1)) errors.push('sequence must be a positive integer');
177
+ if (value && typeof value.healthTargetId !== 'string') errors.push('healthTargetId must be a string');
178
+ if (value && value.entry !== undefined && (!value.entry || typeof value.entry !== 'object' || Array.isArray(value.entry))) errors.push('entry must be an object when present');
179
+ return { ok: errors.length === 0, errors };
180
+ }
181
+
182
+ async function readHealthFileQueryOnly(filePath, fallback) {
183
+ try {
184
+ const content = await fs.readFile(filePath, 'utf-8');
185
+ return { value: JSON.parse(stripBom(content)), degraded: false, degradedReason: null };
186
+ } catch (error) {
187
+ if (error.code === 'ENOENT') {
188
+ return { value: fallback, degraded: false, degradedReason: null };
189
+ }
190
+ if (error instanceof SyntaxError) {
191
+ return { value: fallback, degraded: true, degradedReason: 'corrupted-json' };
192
+ }
193
+ return { value: fallback, degraded: true, degradedReason: 'io-error', errorMessage: error.message };
194
+ }
195
+ }
196
+
197
+ async function readSnapshotForUpdate(filePath, timestamp) {
198
+ try {
199
+ return normalizeSnapshot(await readJsonSafe(filePath, emptySnapshot(timestamp)), timestamp);
200
+ } catch (error) {
201
+ if (error instanceof SyntaxError) return emptySnapshot(timestamp);
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ async function readEventsForUpdate(filePath) {
207
+ try {
208
+ const content = await fs.readFile(filePath, 'utf-8');
209
+ return content.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
210
+ } catch (error) {
211
+ if (error.code === 'ENOENT') return [];
212
+ if (error instanceof SyntaxError) return [];
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ async function appendEvents(filePath, events) {
218
+ if (!events.length) return;
219
+ const content = events.map(event => JSON.stringify(redactObject(event))).join('\n') + '\n';
220
+ await fs.appendFile(filePath, content, { encoding: 'utf8' });
221
+ }
222
+
223
+ async function writeSnapshotAtomic(filePath, snapshot) {
224
+ const tempPath = filePath + '.tmp-' + process.pid + '-' + Date.now();
225
+ await fs.writeFile(tempPath, JSON.stringify(redactObject(snapshot), null, 2) + '\n', { encoding: 'utf8' });
226
+ await fs.rename(tempPath, filePath);
227
+ }
228
+
229
+ function cooldownFamily(failureClass) {
230
+ return LONG_COOLDOWN_FAILURE_CLASSES.has(failureClass) ? 'long' : 'short';
231
+ }
232
+
233
+ function cooldownBase(failureClass) {
234
+ return cooldownFamily(failureClass) === 'long' ? LONG_COOLDOWN_INITIAL_MS : SHORT_COOLDOWN_INITIAL_MS;
235
+ }
236
+
237
+ function cooldownMax(failureClass) {
238
+ return cooldownFamily(failureClass) === 'long' ? LONG_COOLDOWN_MAX_MS : SHORT_COOLDOWN_MAX_MS;
239
+ }
240
+
241
+ function computeCooldownMs(failureClass, openCycles) {
242
+ const cycles = Math.max(1, openCycles || 1);
243
+ return Math.min(cooldownBase(failureClass) * (2 ** (cycles - 1)), cooldownMax(failureClass));
244
+ }
245
+
246
+ function openEntry(entry, { failureClass, timestamp, previousState }) {
247
+ const cycles = previousState === 'HALF_OPEN' || entry.state === 'OPEN' || entry.consecutiveOpenCycles > 0
248
+ ? entry.consecutiveOpenCycles + 1
249
+ : 1;
250
+ const cooldownMs = computeCooldownMs(failureClass, cycles);
251
+ return {
252
+ ...entry,
253
+ state: 'OPEN',
254
+ consecutiveFailures: Math.max(entry.consecutiveFailures, FAILURE_THRESHOLD),
255
+ lastFailureAt: timestamp,
256
+ lastFailureClass: failureClass,
257
+ openedAt: timestamp,
258
+ cooldownUntil: new Date(Date.parse(timestamp) + cooldownMs).toISOString(),
259
+ halfOpenProbeAt: null,
260
+ consecutiveOpenCycles: cycles,
261
+ updatedAt: timestamp
262
+ };
263
+ }
264
+
265
+ function closeEntry(entry, timestamp, metadata = {}) {
266
+ return {
267
+ ...entry,
268
+ state: 'CLOSED',
269
+ consecutiveFailures: 0,
270
+ consecutiveSuccesses: entry.consecutiveSuccesses + 1,
271
+ lastSuccessAt: timestamp,
272
+ openedAt: null,
273
+ cooldownUntil: null,
274
+ halfOpenProbeAt: null,
275
+ consecutiveOpenCycles: 0,
276
+ metadata: normalizeMetadata(metadata || entry.metadata || {}),
277
+ updatedAt: timestamp
278
+ };
279
+ }
280
+
281
+ function lazyState(entry, timestamp = null, { reserveProbe = false } = {}) {
282
+ const observed = normalizeEntry(entry, entry.healthTargetId);
283
+ if (observed.state !== 'OPEN' || !observed.cooldownUntil) {
284
+ return { entry: observed, lazyTransitioned: false, halfOpenProbeAvailable: observed.state === 'HALF_OPEN' && !inProcessHalfOpenProbes.has(observed.healthTargetId) };
285
+ }
286
+ const referenceMs = timestamp ? nowMs(timestamp) : Date.now();
287
+ if (Date.parse(observed.cooldownUntil) > referenceMs) {
288
+ return { entry: observed, lazyTransitioned: false, halfOpenProbeAvailable: false };
289
+ }
290
+ const probeAvailable = !inProcessHalfOpenProbes.has(observed.healthTargetId);
291
+ const half = {
292
+ ...observed,
293
+ state: 'HALF_OPEN',
294
+ halfOpenProbeAt: probeAvailable && reserveProbe ? new Date(referenceMs).toISOString() : observed.halfOpenProbeAt,
295
+ updatedAt: new Date(referenceMs).toISOString()
296
+ };
297
+ if (reserveProbe && probeAvailable) {
298
+ inProcessHalfOpenProbes.set(observed.healthTargetId, half.halfOpenProbeAt);
299
+ }
300
+ return { entry: half, lazyTransitioned: true, halfOpenProbeAvailable: probeAvailable };
301
+ }
302
+
303
+ function eventFrom({ sequence, eventType, timestamp, healthTargetId, taskId, runId, orchestrationId, failureClass = null, evidenceSource = null, evidenceSummary = '', previousState = null, nextState = null, cooldownUntil = null, recommendation = null, reason = '', metadata = {}, entry = null, observedRetryAfterMs = null }) {
304
+ return stampSchemaVersion({
305
+ sequence,
306
+ eventId: makeEventId(sequence),
307
+ eventType,
308
+ timestamp,
309
+ healthTargetId: sanitizeString(healthTargetId),
310
+ taskId: taskId == null ? null : sanitizeString(taskId),
311
+ runId: runId == null ? null : sanitizeString(runId),
312
+ orchestrationId: orchestrationId == null ? null : sanitizeString(orchestrationId),
313
+ failureClass,
314
+ evidenceSource,
315
+ evidenceSummary: normalizeEvidenceSummary(evidenceSummary),
316
+ previousState,
317
+ nextState,
318
+ cooldownUntil,
319
+ recommendation,
320
+ reason: normalizeEvidenceSummary(reason),
321
+ metadata: normalizeMetadata(metadata),
322
+ observedRetryAfterMs,
323
+ entry: entry ? normalizeEntry(entry, healthTargetId) : null
324
+ });
325
+ }
326
+
327
+ function applyEvent(snapshot, event) {
328
+ if (!validateHealthEvent(event).ok) return snapshot;
329
+ if (event.sequence <= snapshot.lastAppliedEventSequence) return snapshot;
330
+ if (event.entry) {
331
+ const targetId = sanitizeString(event.healthTargetId);
332
+ snapshot.targets[targetId] = normalizeEntry(event.entry, targetId);
333
+ }
334
+ snapshot.lastAppliedEventSequence = event.sequence;
335
+ snapshot.generatedAt = event.timestamp || snapshot.generatedAt;
336
+ return snapshot;
337
+ }
338
+
339
+ export function rebuildSnapshotFromEvents(events = [], options = {}) {
340
+ const snapshot = normalizeSnapshot(options.baseSnapshot || emptySnapshot(options.generatedAt || null), options.generatedAt || null);
341
+ const seen = new Set();
342
+ const ordered = [...events]
343
+ .filter(event => event && Number.isInteger(event.sequence))
344
+ .sort((a, b) => a.sequence - b.sequence);
345
+ for (const event of ordered) {
346
+ if (seen.has(event.sequence)) continue;
347
+ seen.add(event.sequence);
348
+ applyEvent(snapshot, event);
349
+ }
350
+ return normalizeSnapshot(snapshot, snapshot.generatedAt);
351
+ }
352
+
353
+ function catchUpSnapshot(snapshot, events) {
354
+ const pending = events.filter(event => event && Number.isInteger(event.sequence) && event.sequence > snapshot.lastAppliedEventSequence);
355
+ if (!pending.length) return snapshot;
356
+ return rebuildSnapshotFromEvents(pending, { baseSnapshot: snapshot });
357
+ }
358
+
359
+ function nextSequence(snapshot) {
360
+ return (snapshot.lastAppliedEventSequence || 0) + 1;
361
+ }
362
+
363
+ async function updateHealth({ healthTargetId, metadata = {}, timestamp, runId = null, taskId = null, orchestrationId = null, kind, failureClass = null, evidenceSummary = '', evidenceSource = null, retryAfterMs = undefined, reason = '' }) {
364
+ const iso = nowIso(timestamp);
365
+ const targetId = sanitizeString(healthTargetId);
366
+ if (!targetId) throw new TypeError('healthTargetId is required');
367
+ return withAccountingLock(async () => {
368
+ await fs.mkdir(path.join(process.cwd(), CONFIG.maestroPath), { recursive: true });
369
+ const snapshotFile = healthPath();
370
+ const eventFile = eventsPath();
371
+ let snapshot = await readSnapshotForUpdate(snapshotFile, iso);
372
+ snapshot = catchUpSnapshot(snapshot, await readEventsForUpdate(eventFile));
373
+ const beforePersisted = snapshot.targets[targetId] ? normalizeEntry(snapshot.targets[targetId], targetId) : defaultEntry(targetId, iso, metadata);
374
+ const lazy = lazyState(beforePersisted, iso);
375
+ let previous = lazy.entry;
376
+ const events = [];
377
+ if (lazy.lazyTransitioned) {
378
+ previous = { ...previous, halfOpenProbeAt: iso, updatedAt: iso };
379
+ const sequence = nextSequence(snapshot);
380
+ const halfEvent = eventFrom({ sequence, eventType: 'circuit_half_opened', timestamp: iso, healthTargetId: targetId, taskId, runId, orchestrationId, previousState: 'OPEN', nextState: 'HALF_OPEN', cooldownUntil: previous.cooldownUntil, reason: 'cooldown elapsed; next real update observes HALF_OPEN', metadata, entry: previous });
381
+ events.push(halfEvent);
382
+ applyEvent(snapshot, halfEvent);
383
+ }
384
+
385
+ const previousState = previous.state;
386
+ let next = { ...previous, metadata: normalizeMetadata(metadata || previous.metadata || {}), updatedAt: iso };
387
+ let eventType = 'health_observation_recorded';
388
+ let transitioned = false;
389
+ let resultReason = reason || 'health observation recorded';
390
+ const retryAfter = validateRetryAfterMs(retryAfterMs);
391
+ const observedRetryAfterMs = retryAfter.valid ? retryAfter.value : null;
392
+ next.observedRetryAfterMs = observedRetryAfterMs;
393
+
394
+ if (kind === 'success') {
395
+ if (previous.state === 'HALF_OPEN') {
396
+ next = closeEntry(previous, iso, metadata);
397
+ next.observedRetryAfterMs = observedRetryAfterMs;
398
+ eventType = 'circuit_closed';
399
+ transitioned = true;
400
+ resultReason = 'success in HALF_OPEN closed the circuit';
401
+ inProcessHalfOpenProbes.delete(targetId);
402
+ } else {
403
+ next = closeEntry(previous, iso, metadata);
404
+ next.consecutiveOpenCycles = previous.state === 'CLOSED' ? previous.consecutiveOpenCycles : 0;
405
+ next.openedAt = previous.state === 'CLOSED' ? previous.openedAt : null;
406
+ next.cooldownUntil = previous.state === 'CLOSED' ? previous.cooldownUntil : null;
407
+ next.state = 'CLOSED';
408
+ next.observedRetryAfterMs = observedRetryAfterMs;
409
+ }
410
+ } else if (kind === 'reset') {
411
+ next = closeEntry(defaultEntry(targetId, iso, metadata), iso, metadata);
412
+ next.consecutiveSuccesses = 0;
413
+ eventType = 'health_snapshot_rebuilt';
414
+ transitioned = previous.state !== 'CLOSED';
415
+ resultReason = reason;
416
+ inProcessHalfOpenProbes.delete(targetId);
417
+ } else {
418
+ const affectsHealth = HEALTH_AFFECTING_FAILURE_CLASSES.has(failureClass);
419
+ next.lastFailureAt = iso;
420
+ next.lastFailureClass = failureClass;
421
+ if (affectsHealth) {
422
+ if (previous.state === 'HALF_OPEN') {
423
+ next = openEntry(next, { failureClass, timestamp: iso, previousState: 'HALF_OPEN' });
424
+ eventType = 'circuit_opened';
425
+ transitioned = true;
426
+ resultReason = 'failure in HALF_OPEN reopened the circuit';
427
+ inProcessHalfOpenProbes.delete(targetId);
428
+ } else if (previous.state === 'CLOSED') {
429
+ next.consecutiveFailures = previous.consecutiveFailures + 1;
430
+ next.consecutiveSuccesses = 0;
431
+ if (next.consecutiveFailures >= FAILURE_THRESHOLD) {
432
+ next = openEntry(next, { failureClass, timestamp: iso, previousState: 'CLOSED' });
433
+ eventType = 'circuit_opened';
434
+ transitioned = true;
435
+ resultReason = 'failure threshold reached';
436
+ }
437
+ } else if (previous.state === 'OPEN') {
438
+ next = openEntry(next, { failureClass, timestamp: iso, previousState: 'OPEN' });
439
+ eventType = 'circuit_opened';
440
+ transitioned = true;
441
+ resultReason = 'failure observed while OPEN; circuit remains open';
442
+ }
443
+ }
444
+ next.observedRetryAfterMs = observedRetryAfterMs;
445
+ }
446
+
447
+ const sequence = nextSequence(snapshot);
448
+ const event = eventFrom({
449
+ sequence,
450
+ eventType,
451
+ timestamp: iso,
452
+ healthTargetId: targetId,
453
+ taskId,
454
+ runId,
455
+ orchestrationId,
456
+ failureClass,
457
+ evidenceSource,
458
+ evidenceSummary,
459
+ previousState,
460
+ nextState: next.state,
461
+ cooldownUntil: next.cooldownUntil,
462
+ reason: resultReason,
463
+ metadata,
464
+ entry: next,
465
+ observedRetryAfterMs
466
+ });
467
+ events.push(event);
468
+ applyEvent(snapshot, event);
469
+ snapshot.generatedAt = iso;
470
+ snapshot = normalizeSnapshot(snapshot, iso);
471
+ await appendEvents(eventFile, events);
472
+ await writeSnapshotAtomic(snapshotFile, snapshot);
473
+ return {
474
+ previousState,
475
+ nextState: next.state,
476
+ transitioned,
477
+ cooldownUntil: next.cooldownUntil,
478
+ eventId: event.eventId,
479
+ sequence: event.sequence,
480
+ observedRetryAfterMs
481
+ };
482
+ });
483
+ }
484
+
485
+ export async function recordHealthSuccess(input = {}) {
486
+ return updateHealth({ ...input, kind: 'success' });
487
+ }
488
+
489
+ export async function recordHealthFailure(input = {}) {
490
+ return updateHealth({ ...input, kind: 'failure' });
491
+ }
492
+
493
+ export async function resetHealthManually(input = {}) {
494
+ if (!input.reason || typeof input.reason !== 'string' || input.reason.trim() === '') {
495
+ throw new TypeError('reason is required for manual health reset');
496
+ }
497
+ return updateHealth({ ...input, kind: 'reset' });
498
+ }
499
+
500
+ function queryEntry(snapshot, healthTargetId, timestamp = null, { reserveProbe = false } = {}) {
501
+ const targetId = sanitizeString(healthTargetId);
502
+ const persisted = snapshot.targets[targetId] ? normalizeEntry(snapshot.targets[targetId], targetId) : defaultEntry(targetId, timestamp, {});
503
+ const lazy = lazyState(persisted, timestamp, { reserveProbe });
504
+ return {
505
+ ...lazy.entry,
506
+ halfOpenProbeAvailable: lazy.halfOpenProbeAvailable,
507
+ lazyTransitioned: lazy.lazyTransitioned
508
+ };
509
+ }
510
+
511
+ export async function getHealthState(healthTargetId, options = {}) {
512
+ return withAccountingLock(async () => {
513
+ const result = await readHealthFileQueryOnly(healthPath(), emptySnapshot(null));
514
+ const snapshot = normalizeSnapshot(result.value, null);
515
+ const entry = queryEntry(snapshot, healthTargetId, options.timestamp || null, { reserveProbe: options.reserveProbe === true });
516
+ return {
517
+ ...entry,
518
+ diagnostics: {
519
+ degraded: result.degraded,
520
+ degradedReason: result.degradedReason || null,
521
+ consistency: 'eventually_consistent_same_process_lock_only'
522
+ }
523
+ };
524
+ });
525
+ }
526
+
527
+ export async function getFullHealthSnapshot(options = {}) {
528
+ return withAccountingLock(async () => {
529
+ const result = await readHealthFileQueryOnly(healthPath(), emptySnapshot(null));
530
+ const snapshot = normalizeSnapshot(result.value, null);
531
+ const targets = {};
532
+ for (const [targetId, entry] of Object.entries(snapshot.targets)) {
533
+ targets[targetId] = queryEntry(snapshot, targetId, options.timestamp || null, { reserveProbe: false });
534
+ }
535
+ return {
536
+ ...snapshot,
537
+ targets,
538
+ diagnostics: {
539
+ degraded: result.degraded,
540
+ degradedReason: result.degradedReason || null,
541
+ consistency: 'eventually_consistent_same_process_lock_only',
542
+ crossProcessLocking: false,
543
+ queryOnly: true
544
+ }
545
+ };
546
+ });
547
+ }
548
+
549
+ export function computeNextAttemptTime(healthEntry) {
550
+ if (!healthEntry || healthEntry.state === 'CLOSED') return null;
551
+ if (healthEntry.state === 'HALF_OPEN') return null;
552
+ if (!healthEntry.cooldownUntil) return null;
553
+ return Date.parse(healthEntry.cooldownUntil) > Date.now() ? healthEntry.cooldownUntil : null;
554
+ }
555
+
556
+ export function computeNextAttemptTimeAt(healthEntry, timestamp) {
557
+ if (!healthEntry || healthEntry.state === 'CLOSED') return null;
558
+ if (healthEntry.state === 'HALF_OPEN') return null;
559
+ if (!healthEntry.cooldownUntil) return null;
560
+ return Date.parse(healthEntry.cooldownUntil) > nowMs(timestamp) ? healthEntry.cooldownUntil : null;
561
+ }
562
+
563
+ export const providerHealthPaths = {
564
+ healthPath,
565
+ eventsPath
566
+ };