@ornexus/neocortex 4.60.73 → 4.60.74

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 (241) hide show
  1. package/dist/release-source.json +1 -1
  2. package/dist/sbom.cdx.json +1216 -1210
  3. package/install.ps1 +1 -1
  4. package/install.sh +1 -1
  5. package/package.json +2 -2
  6. package/packages/client/dist/adapters/adapter-registry.js +1 -108
  7. package/packages/client/dist/adapters/antigravity-adapter.js +2 -77
  8. package/packages/client/dist/adapters/claude-code-adapter.js +3 -79
  9. package/packages/client/dist/adapters/codex-adapter.js +2 -80
  10. package/packages/client/dist/adapters/cursor-adapter.js +4 -115
  11. package/packages/client/dist/adapters/gemini-adapter.js +2 -71
  12. package/packages/client/dist/adapters/grok-adapter.js +2 -42
  13. package/packages/client/dist/adapters/index.js +1 -22
  14. package/packages/client/dist/adapters/platform-detector.js +1 -212
  15. package/packages/client/dist/adapters/target-adapter.js +0 -12
  16. package/packages/client/dist/adapters/vscode-adapter.js +2 -72
  17. package/packages/client/dist/agent/refresh-stubs.js +2 -417
  18. package/packages/client/dist/agent/update-agent-yaml.js +1 -102
  19. package/packages/client/dist/agent/update-description.js +1 -251
  20. package/packages/client/dist/cache/crypto-utils.js +1 -78
  21. package/packages/client/dist/cache/encrypted-cache.js +1 -108
  22. package/packages/client/dist/cache/in-memory-asset-cache.js +1 -73
  23. package/packages/client/dist/cache/index.js +1 -13
  24. package/packages/client/dist/cache/protected-pi-boundary.js +1 -44
  25. package/packages/client/dist/checkpoint/checkpoint-client-reader.js +2 -127
  26. package/packages/client/dist/checkpoint/index.js +1 -11
  27. package/packages/client/dist/checkpoint/shared-checkpoint-types.js +1 -32
  28. package/packages/client/dist/cli.js +5 -358
  29. package/packages/client/dist/commands/activate.js +8 -394
  30. package/packages/client/dist/commands/architecture-answers.js +7 -274
  31. package/packages/client/dist/commands/architecture-memory-terminal.js +4 -253
  32. package/packages/client/dist/commands/architecture-non-ui-continuation.js +1 -100
  33. package/packages/client/dist/commands/architecture-root-binding.js +1 -51
  34. package/packages/client/dist/commands/cache-status.js +2 -112
  35. package/packages/client/dist/commands/config.js +1 -88
  36. package/packages/client/dist/commands/doctor.js +2 -635
  37. package/packages/client/dist/commands/invoke.js +143 -7200
  38. package/packages/client/dist/commands/legacy-epic-terminal.js +1 -71
  39. package/packages/client/dist/commands/legacy-story-branch.js +1 -96
  40. package/packages/client/dist/commands/legacy-story-pr.js +1 -276
  41. package/packages/client/dist/commands/mutation-qa-checkpoint.js +5 -285
  42. package/packages/client/dist/commands/mutation-qa-gate.js +1 -54
  43. package/packages/client/dist/commands/mutation-qa-git.js +1 -41
  44. package/packages/client/dist/commands/mutation-qa-policy.js +1 -102
  45. package/packages/client/dist/commands/mutation-qa-run.js +3 -219
  46. package/packages/client/dist/commands/mutation-qa-verdict.js +1 -149
  47. package/packages/client/dist/commands/mutation-qa.js +1 -220
  48. package/packages/client/dist/commands/native-agent.js +2 -136
  49. package/packages/client/dist/commands/planning-diagnosis.js +1 -43
  50. package/packages/client/dist/commands/planning-generation.js +1 -53
  51. package/packages/client/dist/commands/planning-memory.js +1 -40
  52. package/packages/client/dist/commands/planning-research.js +1 -42
  53. package/packages/client/dist/commands/private-session.js +1 -75
  54. package/packages/client/dist/commands/property-qa.js +1 -139
  55. package/packages/client/dist/commands/qa-control.js +1 -16
  56. package/packages/client/dist/commands/qa-scan-browser-launch.js +1 -55
  57. package/packages/client/dist/commands/qa-scan-browser-route.js +1 -28
  58. package/packages/client/dist/commands/qa-scan-capture.js +5 -663
  59. package/packages/client/dist/commands/qa-scan-discovery.js +3 -376
  60. package/packages/client/dist/commands/qa-scan-review.js +5 -826
  61. package/packages/client/dist/commands/qa-synthetic-backend-capture.js +1 -266
  62. package/packages/client/dist/commands/qa-synthetic-backend-review.js +1 -297
  63. package/packages/client/dist/commands/qa-synthetic-backend.js +2 -340
  64. package/packages/client/dist/commands/qa-synthetic-broker-port.js +1 -85
  65. package/packages/client/dist/commands/qa-synthetic-browser.js +1 -39
  66. package/packages/client/dist/commands/refactor-characterization.js +21 -671
  67. package/packages/client/dist/commands/refactor-commit.js +13 -399
  68. package/packages/client/dist/commands/refactor-control.js +3 -142
  69. package/packages/client/dist/commands/refactor-cpp-assertions.js +3 -93
  70. package/packages/client/dist/commands/refactor-review.js +5 -283
  71. package/packages/client/dist/commands/refactor-root.js +7 -588
  72. package/packages/client/dist/commands/refresh-memory.js +3 -156
  73. package/packages/client/dist/commands/shared-architecture-non-ui.js +1 -24
  74. package/packages/client/dist/commands/story-checkpoint.js +1 -171
  75. package/packages/client/dist/config/profile-directory.js +1 -13
  76. package/packages/client/dist/config/resolver-selection.js +1 -278
  77. package/packages/client/dist/config/secure-config.js +12 -281
  78. package/packages/client/dist/config.js +2 -223
  79. package/packages/client/dist/constants.js +1 -25
  80. package/packages/client/dist/context/context-collector.js +2 -222
  81. package/packages/client/dist/context/context-sanitizer.js +1 -145
  82. package/packages/client/dist/continuity/autonomous-governance.js +1 -285
  83. package/packages/client/dist/continuity/continuity-client-state-store.js +1 -1587
  84. package/packages/client/dist/continuity/invoke-hooks.js +1 -129
  85. package/packages/client/dist/continuity/migrations/001-initial-schema.js +2 -12
  86. package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +2 -12
  87. package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +2 -12
  88. package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +2 -12
  89. package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +2 -12
  90. package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +2 -10
  91. package/packages/client/dist/continuity/native-agent-store.js +1 -69
  92. package/packages/client/dist/continuity/native-agent-types.js +1 -90
  93. package/packages/client/dist/continuity/outbox.js +1 -117
  94. package/packages/client/dist/continuity/runner-bootstrap-policy.js +1 -172
  95. package/packages/client/dist/continuity/sqlite-store.js +81 -2612
  96. package/packages/client/dist/effects/invoke-effect-gateways.js +1 -62
  97. package/packages/client/dist/errors/error-messages.js +2 -228
  98. package/packages/client/dist/errors/frontmatter-diagnostics.js +1 -125
  99. package/packages/client/dist/evidence/collector.js +1 -347
  100. package/packages/client/dist/evidence/index.js +1 -1
  101. package/packages/client/dist/evidence/types.js +0 -2
  102. package/packages/client/dist/fs/atomic-state-replace.js +1 -152
  103. package/packages/client/dist/generated/command-registry.generated.js +1 -4076
  104. package/packages/client/dist/generated/public-projections.generated.js +1 -149
  105. package/packages/client/dist/git/direct-yolo-c01-retry.js +4 -138
  106. package/packages/client/dist/git/direct-yolo-checkpoint.js +4 -1234
  107. package/packages/client/dist/git/epic-operational-state.js +2 -672
  108. package/packages/client/dist/git/git-lifecycle-step-ids.js +1 -14
  109. package/packages/client/dist/git/git-lifecycle-store.d.ts +23 -0
  110. package/packages/client/dist/git/git-lifecycle-store.js +10 -1981
  111. package/packages/client/dist/git/legacy-story-branch-checkpoint.js +9 -259
  112. package/packages/client/dist/git/legacy-story-branch-setup.js +4 -348
  113. package/packages/client/dist/git/legacy-story-pr.js +70 -3538
  114. package/packages/client/dist/git/secure-filesystem-boundary.js +5 -182
  115. package/packages/client/dist/graph-retrieval/pre-command-hook.js +1 -284
  116. package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +1 -237
  117. package/packages/client/dist/i18n/first-run.js +2 -60
  118. package/packages/client/dist/index.js +1 -47
  119. package/packages/client/dist/invocation/invoke-input.js +1 -24
  120. package/packages/client/dist/license/index.js +1 -5
  121. package/packages/client/dist/license/license-client.js +1 -304
  122. package/packages/client/dist/machine/fingerprint.js +2 -163
  123. package/packages/client/dist/machine/index.js +1 -5
  124. package/packages/client/dist/memory/project-memory-path-scope.js +1 -62
  125. package/packages/client/dist/memory/project-memory-writer.js +40 -1007
  126. package/packages/client/dist/memory/shared-project-memory-types.js +2 -253
  127. package/packages/client/dist/migrations/epic-lifecycle-backfill.js +3 -1060
  128. package/packages/client/dist/migrations/project-identity-capability.js +1 -50
  129. package/packages/client/dist/migrations/project-identity.js +7 -493
  130. package/packages/client/dist/migrations/state-json.js +4 -393
  131. package/packages/client/dist/policy/architecture-policy.js +2 -184
  132. package/packages/client/dist/policy/index.js +1 -7
  133. package/packages/client/dist/policy/shared-policy-types.js +0 -38
  134. package/packages/client/dist/policy/subagent-approval-policy.js +1 -55
  135. package/packages/client/dist/private-execution/clean-baseline.js +2 -279
  136. package/packages/client/dist/private-execution/codex-executor.js +4 -574
  137. package/packages/client/dist/private-execution/codex-host-runtime.js +1 -137
  138. package/packages/client/dist/private-execution/codex-lifecycle-guard.js +1 -97
  139. package/packages/client/dist/private-execution/codex-scoped-permissions.js +1 -128
  140. package/packages/client/dist/private-execution/delivery.js +13 -410
  141. package/packages/client/dist/private-execution/diagnosis-root-review.js +2 -294
  142. package/packages/client/dist/private-execution/frozen-handoff.js +6 -19
  143. package/packages/client/dist/private-execution/implementation-validation.js +2 -106
  144. package/packages/client/dist/private-execution/isolated-admission.js +23 -1432
  145. package/packages/client/dist/private-execution/isolated-session.js +5 -725
  146. package/packages/client/dist/private-execution/isolated-workspace.js +1 -122
  147. package/packages/client/dist/private-execution/pact-consumer-runtime.js +3 -409
  148. package/packages/client/dist/private-execution/pact-test-source.js +1 -258
  149. package/packages/client/dist/private-execution/pact-toolchain.js +3 -290
  150. package/packages/client/dist/private-execution/pact-write-set.js +1 -172
  151. package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +5 -406
  152. package/packages/client/dist/private-execution/planning-generation-checkpoint.js +5 -462
  153. package/packages/client/dist/private-execution/planning-memory-checkpoint.js +3 -139
  154. package/packages/client/dist/private-execution/planning-research-checkpoint.js +4 -325
  155. package/packages/client/dist/private-execution/private-report-store.js +1 -42
  156. package/packages/client/dist/private-execution/property-test-candidate.js +1 -103
  157. package/packages/client/dist/private-execution/property-test-choices.js +3 -147
  158. package/packages/client/dist/private-execution/property-test-plan.js +7 -271
  159. package/packages/client/dist/private-execution/property-test-review.js +4 -258
  160. package/packages/client/dist/private-execution/property-test-runtime.js +9 -372
  161. package/packages/client/dist/private-execution/property-test-source.js +1 -139
  162. package/packages/client/dist/private-execution/root-checkpoint.js +1 -80
  163. package/packages/client/dist/private-execution/root-memory-git-evidence.js +3 -135
  164. package/packages/client/dist/private-execution/root-memory-recovery.js +1 -93
  165. package/packages/client/dist/private-execution/root-memory-request.js +1 -29
  166. package/packages/client/dist/private-execution/root-memory-scope.js +1 -178
  167. package/packages/client/dist/private-execution/root-memory-writer.js +1 -102
  168. package/packages/client/dist/private-execution/session-lease.js +1 -63
  169. package/packages/client/dist/private-execution/shared-private-admission-types.js +1 -50
  170. package/packages/client/dist/private-execution/shared-project-validation.js +1 -66
  171. package/packages/client/dist/private-execution/shared-root-memory-types.js +1 -61
  172. package/packages/client/dist/private-execution/ux-source-validation.js +1 -372
  173. package/packages/client/dist/private-execution/ux-write-set.js +2 -191
  174. package/packages/client/dist/registry/registry-reconciliation.js +1 -234
  175. package/packages/client/dist/resilience/circuit-breaker.js +1 -170
  176. package/packages/client/dist/resilience/degradation-manager.js +1 -164
  177. package/packages/client/dist/resilience/freshness-indicator.js +1 -100
  178. package/packages/client/dist/resilience/index.js +1 -8
  179. package/packages/client/dist/resilience/recovery-detector.js +1 -74
  180. package/packages/client/dist/resolvers/asset-resolver.js +0 -13
  181. package/packages/client/dist/resolvers/local-resolver.js +8 -255
  182. package/packages/client/dist/resolvers/remote-resolver.js +1 -233
  183. package/packages/client/dist/runner/binary.js +1 -404
  184. package/packages/client/dist/runner/cli.js +17 -1571
  185. package/packages/client/dist/runner/headless/adapters.js +1 -49
  186. package/packages/client/dist/runner/headless/claude-code-adapter.js +1 -79
  187. package/packages/client/dist/runner/headless/continuous-story-workspace.js +11 -1628
  188. package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +1 -69
  189. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.d.ts +1 -1
  190. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +2 -110
  191. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.d.ts +2 -0
  192. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +10 -1236
  193. package/packages/client/dist/runner/headless/epic-terminal-git-runner.d.ts +18 -2
  194. package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +2 -318
  195. package/packages/client/dist/runner/headless/fake-adapter.js +1 -62
  196. package/packages/client/dist/runner/headless/managed-profile.js +1 -132
  197. package/packages/client/dist/runner/headless/opencode-adapter.js +2 -145
  198. package/packages/client/dist/runner/headless/prepared-story-bundle.js +1 -43
  199. package/packages/client/dist/runner/headless/resolver.js +1 -231
  200. package/packages/client/dist/runner/headless/run-yolo.js +1 -885
  201. package/packages/client/dist/runner/headless/run-yoloop.d.ts +1 -0
  202. package/packages/client/dist/runner/headless/run-yoloop.js +2 -2574
  203. package/packages/client/dist/runner/headless/types.js +2 -574
  204. package/packages/client/dist/runner/local-console.js +13 -396
  205. package/packages/client/dist/runner/portal-sync-worker.js +1 -1487
  206. package/packages/client/dist/runner/project-identity.js +1 -106
  207. package/packages/client/dist/runner/scheduler.js +7 -995
  208. package/packages/client/dist/runner-cli.js +2 -18
  209. package/packages/client/dist/state/invocation-shape.js +1 -13
  210. package/packages/client/dist/state/project-state-snapshot.js +1 -1731
  211. package/packages/client/dist/state/state-json-repair.js +3 -785
  212. package/packages/client/dist/state/structural-dag-projection.js +1 -118
  213. package/packages/client/dist/telemetry/index.js +1 -5
  214. package/packages/client/dist/telemetry/offline-queue.js +1 -131
  215. package/packages/client/dist/tier/index.js +1 -5
  216. package/packages/client/dist/tier/tier-aware-client.js +1 -405
  217. package/packages/client/dist/types/index.js +1 -38
  218. package/packages/client/dist/version.js +1 -27
  219. package/packages/client/dist/yolo/change-evidence-collector.js +1 -126
  220. package/packages/client/dist/yolo/invoke-envelope-capability.js +1 -62
  221. package/packages/client/dist/yolo/spec-envelope-collector.js +5 -184
  222. package/packages/client/dist/yoloop/discovery-hook.js +2 -300
  223. package/packages/client/dist/yoloop/index.js +1 -8
  224. package/packages/client/dist/yoloop/invoke-hooks.js +12 -669
  225. package/packages/client/dist/yoloop/shared-discover-epics.js +1 -1007
  226. package/packages/client/dist/yoloop/shared-yoloop-types.js +1 -33
  227. package/packages/client/dist/yoloop/target-normalization.js +1 -180
  228. package/packages/client/dist/yoloop/yoloop-client-state-store.js +1 -1920
  229. package/packages/shared/dist/yolo/change-evidence.js +1 -1
  230. package/packages/shared/dist/yoloop/epic-terminal-git.js +144 -2
  231. package/targets-stubs/antigravity/gemini.md +1 -1
  232. package/targets-stubs/antigravity/skill/SKILL.md +1 -1
  233. package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
  234. package/targets-stubs/claude-code/neocortex-root.md +2 -2
  235. package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
  236. package/targets-stubs/claude-code/neocortex.md +2 -2
  237. package/targets-stubs/codex/AGENTS.md +1 -1
  238. package/targets-stubs/cursor/agent.md +2 -2
  239. package/targets-stubs/gemini-cli/agent.md +2 -2
  240. package/targets-stubs/opencode/neocortex-root.md +1 -1
  241. package/targets-stubs/vscode/neocortex.agent.md +2 -2
@@ -1,1920 +1 @@
1
- /**
2
- * Epic P101.06 -- YoloopClientStateStore.
3
- *
4
- * Client-side counterpart of the (soon-to-be-deleted) server YoloopStateStore.
5
- * Owns `.neocortex/yoloop.lock` and `.neocortex/yoloop-state.json` in the
6
- * user's local project workspace.
7
- *
8
- * The client is the ONLY filesystem writer for yoloop state after P101
9
- * lands. The server is pure: it reads state echoed by the client via
10
- * `stateSnapshot.yoloop` and returns `metadata.yoloopStateUpdate` with an
11
- * operation log the client applies here.
12
- *
13
- * Atomicity guarantees (P96.03):
14
- * - acquireLock uses fs.open(... 'wx') -- POSIX exclusive create
15
- * - writeState uses tmp + rename (POSIX atomic rename)
16
- *
17
- * Fail-open philosophy (P61.04 / P66.01):
18
- * - chmod / ACL failure -> warn log, NEVER blocks operation
19
- * - release on missing lock is idempotent (no-op)
20
- * - readAll returns defaults on any I/O or parse error
21
- *
22
- * Cross-platform: Unix chmod + Windows icacls via existing
23
- * setSecureFilePermissions helper from secure-config.ts.
24
- */
25
- import { promises as fs } from 'node:fs';
26
- import { existsSync } from 'node:fs';
27
- import { createHash } from 'node:crypto';
28
- import { join, dirname } from 'node:path';
29
- import { hostname } from 'node:os';
30
- import { setSecureFilePermissions, setSecureDirPermissions } from '../config/secure-config.js';
31
- import { RECOVERY_HISTORY_CAP } from './shared-yoloop-types.js';
32
- import { applyCrossEpicContinuationReconciliation, epicOperationalCanonicalJson, epicOperationalHash, mutateEpicOperationalState, readEpicOperationalState, } from '../git/epic-operational-state.js';
33
- const NEOCORTEX_DIR = '.neocortex';
34
- const STATE_FILE = 'yoloop-state.json';
35
- const LOCK_FILE = 'yoloop.lock';
36
- const MUTATION_LOCK_FILE = 'yoloop-state.mutation.lock';
37
- const ROLLOVER_MANIFEST_FILE = 'yoloop-session-rollover.json';
38
- const SUCCESSOR_TRANSITION_MANIFEST_FILE = 'yoloop-successor-transition.json';
39
- const DEFAULT_LOCK_LEASE_MS = 6 * 60 * 60 * 1000;
40
- const MUTATION_LOCK_STALE_MS = 30_000;
41
- const MUTATION_LOCK_RETRIES = 200;
42
- export const YOLOOP_PERSISTENCE_CAPABILITIES = Object.freeze([
43
- 'rollover-session-v1',
44
- 'successor-transition-v1',
45
- 'continuation-replan-v1',
46
- ]);
47
- const NOOP_LOGGER = {
48
- warn: () => { },
49
- info: () => { },
50
- error: () => { },
51
- };
52
- /** Build default counters for a fresh project (no state file present). */
53
- export function createDefaultCounters(nowIso) {
54
- const dateOnly = nowIso.slice(0, 10);
55
- return {
56
- version: '1.1',
57
- lastResetAt: nowIso,
58
- storiesRunToday: 0,
59
- iterationsToday: 0,
60
- iterationsTodayDate: dateOnly,
61
- tokensUsedToday: 0,
62
- lastRunAt: null,
63
- coolDownUntil: null,
64
- totalRunsAllTime: 0,
65
- totalStoriesAllTime: 0,
66
- currentSession: null,
67
- continuationPolicy: 'p205-finite-resume-v2',
68
- pendingContinuationAuthorities: [],
69
- continuationReceipts: [],
70
- continuationRecoveryReceipts: [],
71
- };
72
- }
73
- /** Build default state envelope (no lock, default counters). */
74
- export function createDefaultState(nowIso) {
75
- return {
76
- lock: null,
77
- state: createDefaultCounters(nowIso),
78
- capabilities: YOLOOP_PERSISTENCE_CAPABILITIES,
79
- };
80
- }
81
- export class YoloopClientStateStore {
82
- projectRoot;
83
- stateDir;
84
- statePath;
85
- lockPath;
86
- mutationLockPath;
87
- rolloverManifestPath;
88
- successorTransitionManifestPath;
89
- logger;
90
- constructor(projectRoot, logger = NOOP_LOGGER) {
91
- this.projectRoot = projectRoot;
92
- this.stateDir = join(projectRoot, NEOCORTEX_DIR);
93
- this.statePath = join(this.stateDir, STATE_FILE);
94
- this.lockPath = join(this.stateDir, LOCK_FILE);
95
- this.mutationLockPath = join(this.stateDir, MUTATION_LOCK_FILE);
96
- this.rolloverManifestPath = join(this.stateDir, ROLLOVER_MANIFEST_FILE);
97
- this.successorTransitionManifestPath = join(this.stateDir, SUCCESSOR_TRANSITION_MANIFEST_FILE);
98
- this.logger = logger;
99
- }
100
- // ── Public read/apply API (P101.06 AC3 + AC4) ───────────────────────
101
- /**
102
- * Read lock + state in a single call. Used by invoke.ts pre-request hook
103
- * to populate `stateSnapshot.yoloop` for the server. Handles ENOENT
104
- * gracefully by returning default state.
105
- */
106
- async readAll(nowIso) {
107
- const now = nowIso ?? new Date().toISOString();
108
- const common = this.readCommonAuthorityIfPresent();
109
- if (common) {
110
- return {
111
- lock: common.yoloop.lock,
112
- state: common.yoloop.state,
113
- capabilities: common.yoloop.capabilities,
114
- };
115
- }
116
- await this.cleanupOrphanTempFiles();
117
- await this.recoverSessionRolloverIfPresent(now);
118
- await this.recoverSuccessorTransitionIfPresent(now);
119
- const [rawLock, state] = await Promise.all([this.readLock(), this.readState(now)]);
120
- const lock = await this.prepareLockForSnapshot(rawLock, now);
121
- return { lock, state, capabilities: YOLOOP_PERSISTENCE_CAPABILITIES };
122
- }
123
- /**
124
- * Apply server-provided operations in order. Called by invoke.ts
125
- * post-response hook. Operations are applied atomically per-op; if one
126
- * fails, subsequent ops are NOT applied and the caller (invoke.ts)
127
- * decides whether to log-and-continue or abort.
128
- *
129
- * Contract: partial application is safe because each operation is a
130
- * single atomic filesystem mutation. Next invoke's readAll() reconciles.
131
- */
132
- async applyUpdate(update, nowIso) {
133
- const now = nowIso ?? new Date().toISOString();
134
- if (this.readCommonAuthorityIfPresent()) {
135
- for (const op of update.operations)
136
- await this.applyCommonOperation(op, now);
137
- return;
138
- }
139
- await this.ensureDir();
140
- await this.recoverSessionRolloverIfPresent(now);
141
- await this.recoverSuccessorTransitionIfPresent(now);
142
- for (const op of update.operations) {
143
- await this.applyOperation(op, now);
144
- }
145
- }
146
- /**
147
- * Local typed lifecycle-control API. Server transitions and local
148
- * pause/resume/cancel requests share this serialized CAS boundary.
149
- */
150
- async requestRunTransition(operation, nowIso) {
151
- const now = nowIso ?? new Date().toISOString();
152
- await this.ensureDir();
153
- return this.withMutationLock(() => this.transitionRun(operation, now));
154
- }
155
- /** Persist a cooperative control intent before the next orchestration effect. */
156
- async requestLifecycleAction(action, options = {}, nowIso) {
157
- const now = nowIso ?? new Date().toISOString();
158
- await this.ensureDir();
159
- return this.withMutationLock(async () => {
160
- const state = await this.readState(now);
161
- const current = state.runControl;
162
- if (!current)
163
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
164
- const suffix = Date.parse(now).toString(36);
165
- const operationId = options.operationId ?? `op-${action}-${suffix}`;
166
- const idempotencyKey = options.idempotencyKey ?? `idem-${action}-${suffix}`;
167
- const replay = current.receipts.find((receipt) => receipt.operationId === operationId || receipt.idempotencyKey === idempotencyKey);
168
- if (replay) {
169
- if (replay.operationId === operationId && replay.idempotencyKey === idempotencyKey)
170
- return replay;
171
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
172
- }
173
- const target = localLifecycleRequestTarget(current.status, action);
174
- if (!target) {
175
- throw new Error(TERMINAL_RUN_STATUSES.has(current.status)
176
- ? 'YOLOOP_RUN_TERMINAL'
177
- : 'YOLOOP_RUN_TRANSITION_INVALID');
178
- }
179
- return this.transitionRun(buildLocalLifecycleOperation({
180
- current, action, target, operationId, idempotencyKey, occurredAt: now,
181
- }), now);
182
- });
183
- }
184
- // ── Per-operation dispatch ──────────────────────────────────────────
185
- async applyOperation(op, nowIso) {
186
- switch (op.type) {
187
- case 'acquire_lock':
188
- await this.withMutationLock(() => this.acquireLock(op.lock, nowIso));
189
- return;
190
- case 'release_lock':
191
- await this.withMutationLock(() => this.releaseLock(op));
192
- return;
193
- case 'write_state':
194
- await this.withMutationLock(async () => {
195
- const current = await this.readState(nowIso);
196
- const { runControl: _untrustedRunControl, ...legacyCounters } = op.state;
197
- await this.writeState({
198
- ...legacyCounters,
199
- ...(current.runControl
200
- ? { version: '1.2', runControl: current.runControl }
201
- : {}),
202
- });
203
- });
204
- return;
205
- case 'advance_epic':
206
- await this.advanceEpic(op.nextEpicIndex, op.nextEpicId, nowIso);
207
- return;
208
- case 'no_op':
209
- this.logger.info({ event: 'yoloop_noop' }, 'yoloop server sent no-op');
210
- return;
211
- case 'reserve_continuation_authority':
212
- await this.reserveContinuationAuthority(op.authority, nowIso);
213
- return;
214
- case 'acknowledge_continuation_authority':
215
- await this.acknowledgeContinuationAuthority(op.idempotencyKey, op.receipt, nowIso);
216
- return;
217
- case 'repair_continuation_authority':
218
- await this.repairContinuationAuthority(op.beforeHash, op.authority, op.receipt, nowIso);
219
- return;
220
- case 'switch_continuation_policy':
221
- await this.switchContinuationPolicy(op.policy, op.session, nowIso);
222
- return;
223
- case 'transition_run':
224
- await this.requestRunTransition(op, nowIso);
225
- return;
226
- case 'start_successor_run':
227
- await this.withMutationLock(() => this.startSuccessorRun(op, nowIso));
228
- return;
229
- case 'commit_successor_transition':
230
- await this.withMutationLock(() => this.commitSuccessorTransition(op, nowIso));
231
- return;
232
- case 'rollover_session':
233
- await this.withMutationLock(() => this.rolloverSession(op, nowIso));
234
- return;
235
- case 'replan_continuation_session':
236
- await this.withMutationLock(() => this.replanContinuationSession(op, nowIso));
237
- return;
238
- case 'reconcile_cross_epic_continuation':
239
- await applyCrossEpicContinuationReconciliation(this.projectRoot, op);
240
- return;
241
- // ── P104.06: self-healing recovery audit ops ──────────────────────
242
- case 'recovery_attempted':
243
- await this.appendRecoveryAttempt(op.attempt, nowIso);
244
- return;
245
- case 'recovery_resolved':
246
- await this.resolveRecoveryAttempt(op.attempt_id, op.outcome, nowIso);
247
- return;
248
- default: {
249
- // Exhaustiveness check; never reached for typed inputs but
250
- // defensive against forward-compat shape drift.
251
- const _exhaustive = op;
252
- this.logger.error({ event: 'yoloop_unknown_op', op: _exhaustive }, 'unknown yoloop operation rejected');
253
- throw new Error('YOLOOP_STATE_OPERATION_UNSUPPORTED');
254
- }
255
- }
256
- }
257
- /**
258
- * Runtime v2 is authoritative once present. Missing/non-Git/v1 projects keep
259
- * the legacy checkout-local adapter; a corrupt v2 never falls back.
260
- */
261
- readCommonAuthorityIfPresent() {
262
- try {
263
- const operational = readEpicOperationalState(this.projectRoot).operational;
264
- if (operational.activeJournal !== null)
265
- throw new Error('epic-operational-recovery-required');
266
- return operational;
267
- }
268
- catch (error) {
269
- if (error instanceof Error && [
270
- 'git-lifecycle-runtime-missing',
271
- 'epic-operational-store-missing',
272
- ].includes(error.message))
273
- return null;
274
- if (error instanceof Error && error.message === 'git-lifecycle-common-dir-invalid'
275
- && !existsSync(join(this.projectRoot, '.git')))
276
- return null;
277
- throw error;
278
- }
279
- }
280
- /** Apply legacy operation semantics to the common authority under its Git CAS. */
281
- async applyCommonOperation(op, nowIso) {
282
- if (op.type === 'no_op')
283
- return;
284
- if (op.type === 'reconcile_cross_epic_continuation') {
285
- await applyCrossEpicContinuationReconciliation(this.projectRoot, op);
286
- return;
287
- }
288
- const { document, operational } = readEpicOperationalState(this.projectRoot);
289
- const identities = Object.keys(operational.identities);
290
- if (identities.length !== 1)
291
- throw new Error('epic-operational-identity-mismatch');
292
- const identityHash = identities[0];
293
- await mutateEpicOperationalState(this.projectRoot, {
294
- identityHash,
295
- expectedRevision: document.revision,
296
- expectedWriterEpoch: operational.writerEpoch,
297
- }, (state) => {
298
- const current = state.yoloop.state;
299
- let lock = state.yoloop.lock;
300
- let next = current;
301
- switch (op.type) {
302
- case 'acquire_lock': {
303
- if (lock && lock.sessionId !== op.lock.sessionId)
304
- throw new Error('YOLOOP_LOCK_HELD');
305
- lock = {
306
- ...op.lock,
307
- pid: process.pid,
308
- hostname: hostname(),
309
- ownerKind: 'client',
310
- leaseId: op.lock.leaseId ?? buildPublicLeaseId(op.lock.sessionId, nowIso),
311
- leaseState: 'active',
312
- leaseAcquiredAt: op.lock.leaseAcquiredAt ?? nowIso,
313
- leaseDurationMs: op.lock.leaseDurationMs ?? DEFAULT_LOCK_LEASE_MS,
314
- leaseExpiresAt: op.lock.leaseExpiresAt ?? op.lock.ttlExpiresAt,
315
- };
316
- break;
317
- }
318
- case 'release_lock': {
319
- if (op.expectedSessionId && lock?.sessionId !== op.expectedSessionId)
320
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
321
- if (op.expectedLeaseId && lock?.leaseId !== op.expectedLeaseId)
322
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
323
- if (op.expectedRunId && current.runControl?.runId !== op.expectedRunId)
324
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
325
- lock = null;
326
- break;
327
- }
328
- case 'write_state': {
329
- const { runControl: _untrusted, ...legacy } = op.state;
330
- next = { ...legacy, ...(current.runControl ? { version: '1.2', runControl: current.runControl } : {}) };
331
- break;
332
- }
333
- case 'advance_epic': {
334
- if (!current.currentSession)
335
- return;
336
- next = { ...current, currentSession: { ...current.currentSession, currentEpicIndex: op.nextEpicIndex } };
337
- break;
338
- }
339
- case 'reserve_continuation_authority': {
340
- const pending = current.pendingContinuationAuthorities ?? [];
341
- const key = String(op.authority.idempotencyKey ?? '');
342
- const existing = pending.find((item) => item.idempotencyKey === key);
343
- if (existing) {
344
- if (epicOperationalCanonicalJson(existing) !== epicOperationalCanonicalJson(op.authority)) {
345
- throw new Error('YOLOOP_CONTINUATION_AUTHORITY_CONFLICT');
346
- }
347
- return;
348
- }
349
- if (!key || pending.length >= 64)
350
- throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
351
- next = { ...current, pendingContinuationAuthorities: [...pending, op.authority] };
352
- break;
353
- }
354
- case 'acknowledge_continuation_authority': {
355
- const pending = current.pendingContinuationAuthorities ?? [];
356
- const authority = pending.find((item) => item.idempotencyKey === op.idempotencyKey);
357
- if (!authority)
358
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_ORPHAN');
359
- const receipts = current.continuationReceipts ?? [];
360
- if (receipts.length >= 64)
361
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED');
362
- next = {
363
- ...current,
364
- pendingContinuationAuthorities: pending.filter((item) => item !== authority),
365
- continuationReceipts: [...receipts, op.receipt],
366
- };
367
- break;
368
- }
369
- case 'repair_continuation_authority': {
370
- const pending = current.pendingContinuationAuthorities ?? [];
371
- if (pending.length !== 1 || epicOperationalHash('p208-legacy-authority-before-v1', pending[0]) !== op.beforeHash) {
372
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH');
373
- }
374
- const receipts = current.continuationRecoveryReceipts ?? [];
375
- if (receipts.length >= 64)
376
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED');
377
- next = { ...current, pendingContinuationAuthorities: [op.authority], continuationRecoveryReceipts: [...receipts, op.receipt] };
378
- break;
379
- }
380
- case 'switch_continuation_policy': {
381
- if (current.currentSession || (current.pendingContinuationAuthorities?.length ?? 0) > 0)
382
- throw new Error('YOLOOP_POLICY_SWITCH_ACTIVE');
383
- next = { ...current, continuationPolicy: op.policy, continuationSession: op.session };
384
- break;
385
- }
386
- case 'transition_run':
387
- case 'start_successor_run': {
388
- const run = current.runControl;
389
- if (!run || run.revision !== op.expectedRevision || run.latestSnapshotHash !== op.expectedSnapshotHash) {
390
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
391
- }
392
- next = { ...current, version: '1.2', runControl: op.next };
393
- break;
394
- }
395
- case 'commit_successor_transition':
396
- next = { ...op.state, version: '1.2', runControl: op.successor.next };
397
- break;
398
- case 'rollover_session':
399
- next = op.next;
400
- lock = null;
401
- break;
402
- case 'replan_continuation_session': {
403
- const pending = current.pendingContinuationAuthorities ?? [];
404
- if (epicOperationalHash('p222-yoloop-replan-session-v1', current.continuationSession ?? null) !== op.expectedSessionHash
405
- || epicOperationalHash('p222-yoloop-replan-pending-v1', pending) !== op.expectedPendingHash
406
- || pending.length > 1 || pending.some((item) => item.delegated !== false)) {
407
- throw new Error('YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT');
408
- }
409
- const receipts = current.continuationReplanReceipts ?? [];
410
- if (receipts.length >= 64)
411
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED');
412
- next = { ...current, continuationSession: op.nextSession, pendingContinuationAuthorities: [], continuationReplanReceipts: [...receipts, op.receipt] };
413
- break;
414
- }
415
- case 'recovery_attempted':
416
- next = { ...current, recovery_history: capRecoveryHistory([...(current.recovery_history ?? []), op.attempt]) };
417
- break;
418
- case 'recovery_resolved': {
419
- const history = [...(current.recovery_history ?? [])];
420
- const index = history.findIndex((item) => item.recovery_step_id_tokenized === op.attempt_id);
421
- if (index >= 0)
422
- history[index] = { ...history[index], outcome: op.outcome, finished_at: nowIso };
423
- next = { ...current, recovery_history: history };
424
- break;
425
- }
426
- default: {
427
- const _exhaustive = op;
428
- throw new Error(`YOLOOP_STATE_OPERATION_UNSUPPORTED:${String(_exhaustive)}`);
429
- }
430
- }
431
- state.yoloop = { ...state.yoloop, lock, state: next };
432
- });
433
- }
434
- async reserveContinuationAuthority(authority, nowIso) {
435
- const current = await this.readState(nowIso);
436
- const pending = current.pendingContinuationAuthorities ?? [];
437
- const key = typeof authority.idempotencyKey === 'string' ? authority.idempotencyKey : '';
438
- if (!key)
439
- throw new Error('YOLOOP_CONTINUATION_AUTHORITY_INVALID');
440
- const existing = pending.find((item) => item.idempotencyKey === key);
441
- if (existing) {
442
- if (stablePublicJson(existing) === stablePublicJson(authority))
443
- return;
444
- throw new Error('YOLOOP_CONTINUATION_AUTHORITY_CONFLICT');
445
- }
446
- if (pending.length >= 64)
447
- throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
448
- await this.writeState({ ...current, pendingContinuationAuthorities: [...pending, authority] });
449
- }
450
- async acknowledgeContinuationAuthority(idempotencyKey, receipt, nowIso) {
451
- const current = await this.readState(nowIso);
452
- const pending = current.pendingContinuationAuthorities ?? [];
453
- const index = pending.findIndex((item) => item.idempotencyKey === idempotencyKey);
454
- if (index < 0)
455
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_ORPHAN');
456
- const authority = pending[index];
457
- if (receipt.idempotencyKey !== idempotencyKey || receipt.sessionId !== authority.sessionId || JSON.stringify(receipt.storyIds) !== JSON.stringify(authority.storyIds))
458
- throw new Error('YOLOOP_CONTINUATION_RECEIPT_MISMATCH');
459
- const retained = pending.filter((_, itemIndex) => itemIndex !== index);
460
- const receipts = current.continuationReceipts ?? [];
461
- await this.writeState({ ...current, pendingContinuationAuthorities: retained, continuationReceipts: [...receipts.slice(-63), receipt] });
462
- }
463
- async repairContinuationAuthority(beforeHash, authority, receipt, nowIso) {
464
- const current = await this.readState(nowIso);
465
- const pending = current.pendingContinuationAuthorities ?? [];
466
- const recoveryReceipts = current.continuationRecoveryReceipts ?? [];
467
- const receiptKey = typeof receipt.idempotencyKey === 'string' ? receipt.idempotencyKey : '';
468
- const existingReceipt = recoveryReceipts.find((item) => item.idempotencyKey === receiptKey);
469
- if (existingReceipt) {
470
- if (stablePublicJson(existingReceipt) === stablePublicJson(receipt))
471
- return;
472
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_RECEIPT_CONFLICT');
473
- }
474
- if (pending.length !== 1 || domainHash('p208-legacy-authority-before-v1', pending[0]) !== beforeHash) {
475
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH');
476
- }
477
- const afterHash = domainHash('p208-legacy-authority-after-v1', authority);
478
- if (receipt.beforeHash !== beforeHash || receipt.afterHash !== afterHash) {
479
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_HASH_MISMATCH');
480
- }
481
- const receiptBase = { ...receipt };
482
- delete receiptBase.idempotencyKey;
483
- if (!receiptKey || domainHash('p208-legacy-authority-recovery-receipt-v1', receiptBase) !== receiptKey) {
484
- throw new Error('YOLOOP_CONTINUATION_RECOVERY_RECEIPT_INVALID');
485
- }
486
- await this.writeState({
487
- ...current,
488
- pendingContinuationAuthorities: [authority],
489
- continuationRecoveryReceipts: [...recoveryReceipts.slice(-63), receipt],
490
- });
491
- }
492
- async switchContinuationPolicy(policy, session, nowIso) {
493
- const current = await this.readState(nowIso);
494
- if (current.currentSession || (current.pendingContinuationAuthorities?.length ?? 0) > 0)
495
- throw new Error('YOLOOP_POLICY_SWITCH_ACTIVE');
496
- try {
497
- await fs.copyFile(this.statePath, `${this.statePath}.policy-backup`);
498
- }
499
- catch (error) {
500
- if (error.code !== 'ENOENT')
501
- throw error;
502
- }
503
- await this.writeState({ ...current, continuationPolicy: policy, continuationSession: session });
504
- }
505
- async replanContinuationSession(operation, nowIso) {
506
- const { type: _type, payloadHash: _payloadHash, receipt: _receipt, ...payload } = operation;
507
- if (buildYoloopContinuationReplanPayloadHash(payload) !== operation.payloadHash
508
- || !isSealedYoloopFrozenSession(operation.nextSession)) {
509
- throw new Error('YOLOOP_CONTINUATION_REPLAN_PAYLOAD_INVALID');
510
- }
511
- const expectedReceipt = {
512
- schemaVersion: 'YoloopContinuationReplanReceipt/1.0',
513
- operationId: operation.operationId,
514
- idempotencyKey: operation.idempotencyKey,
515
- runId: operation.expectedRunId,
516
- previousSessionHash: operation.expectedSessionHash,
517
- nextSessionHash: domainHash('p222-yoloop-replan-session-v1', operation.nextSession),
518
- retiredAuthorityHash: operation.expectedPendingHash,
519
- retiredAuthorityCount: operation.expectedPendingHash === domainHash('p222-yoloop-replan-pending-v1', []) ? 0 : 1,
520
- payloadHash: operation.payloadHash,
521
- outcome: 'graph-replanned',
522
- publicSafe: true,
523
- };
524
- if (stablePublicJson(operation.receipt) !== stablePublicJson(expectedReceipt)) {
525
- throw new Error('YOLOOP_CONTINUATION_REPLAN_RECEIPT_INVALID');
526
- }
527
- const current = await this.readState(nowIso);
528
- if (await this.readLock() || current.currentSession !== null) {
529
- throw new Error('YOLOOP_CONTINUATION_REPLAN_ACTIVE');
530
- }
531
- const run = current.runControl;
532
- if (!run || run.runId !== operation.expectedRunId
533
- || !['success', 'exhausted'].includes(run.status)
534
- || run.revision !== operation.expectedRevision
535
- || run.latestSnapshotHash !== operation.expectedSnapshotHash
536
- || current.continuationPolicy !== 'p205-finite-resume-v2') {
537
- throw new Error('YOLOOP_CONTINUATION_REPLAN_RUN_CONFLICT');
538
- }
539
- const receipts = current.continuationReplanReceipts ?? [];
540
- const existing = receipts.find((item) => item.idempotencyKey === operation.idempotencyKey
541
- || item.operationId === operation.operationId);
542
- if (existing) {
543
- if (stablePublicJson(existing) === stablePublicJson(expectedReceipt)
544
- && stablePublicJson(current.continuationSession) === stablePublicJson(operation.nextSession)
545
- && (current.pendingContinuationAuthorities?.length ?? 0) === 0)
546
- return;
547
- throw new Error('YOLOOP_CONTINUATION_REPLAN_REPLAY_CONFLICT');
548
- }
549
- const session = current.continuationSession;
550
- const pending = current.pendingContinuationAuthorities ?? [];
551
- if (!isSealedYoloopFrozenSession(session)
552
- || domainHash('p222-yoloop-replan-session-v1', session) !== operation.expectedSessionHash
553
- || domainHash('p222-yoloop-replan-pending-v1', pending) !== operation.expectedPendingHash
554
- || pending.length > 1
555
- || pending.some((authority) => authority.delegated !== false
556
- || authority.sessionId !== session.sessionId
557
- || !authority.certificate
558
- || authority.certificate.sessionId !== session.sessionId)) {
559
- throw new Error('YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT');
560
- }
561
- await this.writeState({
562
- ...current,
563
- continuationSession: operation.nextSession,
564
- pendingContinuationAuthorities: [],
565
- continuationReplanReceipts: [...receipts.slice(-63), expectedReceipt],
566
- });
567
- }
568
- // ── P104.06: recovery_history audit semantics ───────────────────────────
569
- /**
570
- * Append a new recovery attempt to `state.recovery_history[]`. FIFO cap
571
- * via `capRecoveryHistory`. Idempotent against retry storms: duplicate
572
- * (story_id + attempted_at) entries are skipped with a warn log.
573
- */
574
- async appendRecoveryAttempt(attempt, nowIso) {
575
- const current = await this.readState(nowIso);
576
- const history = current.recovery_history ?? [];
577
- // P104.06 AC8: idempotency via composite key
578
- const isDuplicate = history.some((a) => a.attempted_at === attempt.attempted_at && a.story_id === attempt.story_id);
579
- if (isDuplicate) {
580
- this.logger.warn({ event: 'recovery_attempted_duplicate', story_id: attempt.story_id }, 'duplicate recovery_attempted op; skipping');
581
- return;
582
- }
583
- const next = capRecoveryHistory([...history, attempt]);
584
- const updated = {
585
- ...current,
586
- recovery_history: next,
587
- };
588
- await this.writeState(updated);
589
- this.logger.info({
590
- event: 'recovery_attempted_persisted',
591
- story_id: attempt.story_id,
592
- history_size: next.length,
593
- }, 'recovery attempt persisted');
594
- }
595
- /**
596
- * Resolve a pending recovery attempt by tokenized step id. Updates
597
- * `outcome` and stamps `finished_at`. If the attempt was already evicted
598
- * by the FIFO cap or never existed, emits a warn log (`recovery_resolve_orphan`)
599
- * but does not throw -- bound state cap is a deliberate trade-off.
600
- */
601
- async resolveRecoveryAttempt(attemptId, outcome, nowIso) {
602
- const current = await this.readState(nowIso);
603
- const history = current.recovery_history ?? [];
604
- const idx = history.findIndex((a) => a.recovery_step_id_tokenized === attemptId);
605
- if (idx === -1) {
606
- this.logger.warn({ event: 'recovery_resolve_orphan', attempt_id: attemptId }, 'recovery_resolved op had no matching attempt; skipping');
607
- return;
608
- }
609
- const currentAttempt = history[idx];
610
- if (currentAttempt.outcome === outcome && currentAttempt.finished_at) {
611
- this.logger.info({ event: 'recovery_resolved_duplicate', attempt_id: attemptId, outcome }, 'duplicate recovery_resolved op; skipping');
612
- return;
613
- }
614
- const updatedHistory = history.slice();
615
- updatedHistory[idx] = {
616
- ...currentAttempt,
617
- outcome,
618
- finished_at: nowIso,
619
- };
620
- const updated = {
621
- ...current,
622
- recovery_history: updatedHistory,
623
- };
624
- await this.writeState(updated);
625
- this.logger.info({ event: 'recovery_resolved_persisted', attempt_id: attemptId, outcome }, 'recovery attempt resolved');
626
- }
627
- // ── Lock semantics (AC5 + AC6) ──────────────────────────────────────
628
- /**
629
- * Atomically acquire the yoloop lock. On EEXIST, performs PID liveness
630
- * check (same-host) and retries once if orphan detected. Cross-host
631
- * locks are honored (cannot signal across hosts).
632
- *
633
- * The server's `acquire_lock` operation provides the lock payload; we
634
- * overwrite the `pid` and `hostname` fields with OUR values (the client
635
- * is the authoritative writer of pid/hostname; server can't know them).
636
- */
637
- async acquireLock(lock, nowIso) {
638
- // Replace server-suggested pid+hostname with real client values.
639
- const payload = {
640
- ...lock,
641
- pid: process.pid,
642
- hostname: hostname(),
643
- ownerKind: 'client',
644
- startedAt: lock.startedAt || nowIso,
645
- leaseId: lock.leaseId ?? buildPublicLeaseId(lock.sessionId, nowIso),
646
- leaseState: 'active',
647
- leaseAcquiredAt: lock.leaseAcquiredAt ?? nowIso,
648
- leaseDurationMs: lock.leaseDurationMs ?? DEFAULT_LOCK_LEASE_MS,
649
- leaseExpiresAt: lock.leaseExpiresAt ?? lock.ttlExpiresAt,
650
- };
651
- const json = JSON.stringify(payload, null, 2);
652
- try {
653
- const fh = await fs.open(this.lockPath, 'wx');
654
- try {
655
- await fh.writeFile(json, 'utf8');
656
- }
657
- finally {
658
- await fh.close();
659
- }
660
- this.trySetFilePerms(this.lockPath);
661
- this.logger.info({ event: 'yoloop_lock_acquired', pid: payload.pid, sessionId: payload.sessionId }, 'yoloop lock acquired');
662
- return;
663
- }
664
- catch (err) {
665
- const e = err;
666
- if (e.code !== 'EEXIST') {
667
- this.logger.error({ event: 'yoloop_lock_acquire_error', err: e.message }, 'failed to acquire yoloop lock');
668
- throw err;
669
- }
670
- const existing = await this.readLock();
671
- if (existing && this.isSameClientSessionLock(existing, payload)) {
672
- this.logger.info({ event: 'yoloop_lock_acquire_duplicate', sessionId: payload.sessionId }, 'duplicate acquire_lock for current yoloop session; treating as no-op');
673
- return;
674
- }
675
- // EEXIST: try orphan recovery once.
676
- const recovered = await this.maybeRecoverOrphanLock(nowIso);
677
- if (!recovered) {
678
- this.logger.warn({ event: 'yoloop_lock_held' }, 'yoloop lock held by another live process');
679
- throw err;
680
- }
681
- // Retry after orphan recovery.
682
- const fh = await fs.open(this.lockPath, 'wx');
683
- try {
684
- await fh.writeFile(json, 'utf8');
685
- }
686
- finally {
687
- await fh.close();
688
- }
689
- this.trySetFilePerms(this.lockPath);
690
- this.logger.info({ event: 'yoloop_lock_acquired_after_recovery', pid: payload.pid }, 'yoloop lock acquired after orphan recovery');
691
- }
692
- }
693
- /** Release the lock. V1.2 active runs require compare-bound ownership. */
694
- async releaseLock(binding) {
695
- const current = await this.readState(new Date().toISOString());
696
- const lock = await this.readLock();
697
- if (current.runControl) {
698
- if (!binding.expectedRunId || !binding.expectedSessionId || !binding.expectedLeaseId) {
699
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
700
- }
701
- if (binding.expectedRunId !== current.runControl.runId) {
702
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
703
- }
704
- if (!lock
705
- || binding.expectedSessionId !== lock.sessionId
706
- || binding.expectedLeaseId !== lock.leaseId) {
707
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
708
- }
709
- }
710
- try {
711
- await fs.unlink(this.lockPath);
712
- this.logger.info({ event: 'yoloop_lock_released' }, 'yoloop lock released');
713
- }
714
- catch (err) {
715
- const e = err;
716
- if (e.code === 'ENOENT') {
717
- this.logger.info({ event: 'yoloop_lock_release_noop' }, 'yoloop lock release noop (file absent)');
718
- return;
719
- }
720
- this.logger.warn({ event: 'yoloop_lock_release_error', err: e.message }, 'unexpected error releasing yoloop lock');
721
- }
722
- }
723
- /** Read lock file. Returns null on ENOENT / parse error. */
724
- async readLock() {
725
- try {
726
- const raw = await fs.readFile(this.lockPath, 'utf8');
727
- const parsed = JSON.parse(raw);
728
- const currentEpicId = parsed.currentEpicId ?? parsed.epicId ?? '';
729
- return {
730
- pid: parsed.pid ?? 0,
731
- hostname: parsed.hostname ?? '',
732
- ...(parsed.ownerKind ? { ownerKind: parsed.ownerKind } : {}),
733
- ...(parsed.ownerPidState ? { ownerPidState: parsed.ownerPidState } : {}),
734
- ...(typeof parsed.leaseId === 'string' ? { leaseId: parsed.leaseId } : {}),
735
- ...(parsed.leaseState ? { leaseState: parsed.leaseState } : {}),
736
- ...(typeof parsed.leaseAcquiredAt === 'string' ? { leaseAcquiredAt: parsed.leaseAcquiredAt } : {}),
737
- ...(typeof parsed.leaseDurationMs === 'number' ? { leaseDurationMs: parsed.leaseDurationMs } : {}),
738
- ...(typeof parsed.leaseExpiresAt === 'string' ? { leaseExpiresAt: parsed.leaseExpiresAt } : {}),
739
- startedAt: parsed.startedAt ?? '',
740
- sessionId: parsed.sessionId ?? '',
741
- mode: parsed.mode ?? 'epic-id',
742
- inputPath: parsed.inputPath ?? '',
743
- epicQueue: parsed.epicQueue ?? (currentEpicId ? [currentEpicId] : []),
744
- currentEpicIndex: parsed.currentEpicIndex ?? 0,
745
- currentEpicId,
746
- ttlExpiresAt: parsed.ttlExpiresAt ?? '',
747
- epicId: parsed.epicId ?? currentEpicId,
748
- };
749
- }
750
- catch {
751
- return null;
752
- }
753
- }
754
- // ── State semantics (AC5 + AC7) ─────────────────────────────────────
755
- /** Read state file; returns defaults on any I/O or parse error. */
756
- async readState(nowIso) {
757
- try {
758
- const raw = await fs.readFile(this.statePath, 'utf8');
759
- const parsed = JSON.parse(raw);
760
- return mergeCountersWithDefaults(parsed, nowIso);
761
- }
762
- catch (err) {
763
- const e = err;
764
- if (e.code !== 'ENOENT') {
765
- this.logger.warn({ event: 'yoloop_state_read_error', err: e.message }, 'yoloop state file unreadable; using defaults');
766
- }
767
- return createDefaultCounters(nowIso);
768
- }
769
- }
770
- /** Durable atomic state write: same-filesystem temp + fsync + rename + dir fsync. */
771
- async writeState(state) {
772
- const tmpPath = `${this.statePath}.tmp.${process.pid}.${Date.now()}`;
773
- const json = JSON.stringify(state, null, 2);
774
- try {
775
- const handle = await fs.open(tmpPath, 'w', 0o600);
776
- try {
777
- await handle.writeFile(json, 'utf8');
778
- await handle.sync();
779
- }
780
- finally {
781
- await handle.close();
782
- }
783
- this.trySetFilePerms(tmpPath);
784
- await fs.rename(tmpPath, this.statePath);
785
- await this.syncStateDirectory();
786
- }
787
- catch (err) {
788
- const e = err;
789
- this.logger.error({ event: 'yoloop_state_write_error', err: e.message }, 'failed to write yoloop state');
790
- try {
791
- await fs.unlink(tmpPath);
792
- }
793
- catch {
794
- // best-effort cleanup
795
- }
796
- throw err;
797
- }
798
- }
799
- /**
800
- * Apply the per-run-cap boundary as one journaled logical transaction over
801
- * yoloop-state.json and yoloop.lock. The mutation lock serializes local
802
- * writers; the durable manifest makes every crash point resumable.
803
- */
804
- async rolloverSession(operation, nowIso) {
805
- validateBoundedSessionRollover(operation);
806
- const manifest = await this.readRolloverManifest();
807
- if (manifest) {
808
- if (!sameRolloverIdentity(manifest.operation, operation)) {
809
- if (manifest.phase === 'prepared')
810
- throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
811
- }
812
- else {
813
- if (manifest.operation.payloadHash !== operation.payloadHash) {
814
- throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
815
- }
816
- if (manifest.phase === 'applied')
817
- return;
818
- await this.completePreparedSessionRollover(manifest.operation, nowIso);
819
- return;
820
- }
821
- }
822
- const current = await this.readState(nowIso);
823
- const lock = await this.readLock();
824
- validateSessionRolloverBeforeState(operation, current, lock);
825
- await this.writeRolloverManifest({
826
- schemaVersion: 'YoloopSessionRolloverManifest/1.0',
827
- phase: 'prepared',
828
- operation,
829
- });
830
- await this.completePreparedSessionRollover(operation, nowIso);
831
- }
832
- async completePreparedSessionRollover(operation, nowIso) {
833
- const current = await this.readState(nowIso);
834
- let stateApplied = stablePublicJson(current) === stablePublicJson(operation.next);
835
- if (!stateApplied) {
836
- const lockBefore = await this.readLock();
837
- validateSessionRolloverBeforeState(operation, current, lockBefore);
838
- await this.writeState(operation.next);
839
- stateApplied = true;
840
- }
841
- const lock = await this.readLock();
842
- if (operation.expectedSessionId === null || operation.expectedLeaseId === null) {
843
- if (operation.expectedSessionId !== null || operation.expectedLeaseId !== null || lock) {
844
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
845
- }
846
- }
847
- else if (lock) {
848
- if (lock.sessionId !== operation.expectedSessionId
849
- || lock.leaseId !== operation.expectedLeaseId) {
850
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
851
- }
852
- await fs.unlink(this.lockPath);
853
- await this.syncStateDirectory();
854
- }
855
- else if (!stateApplied) {
856
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
857
- }
858
- await this.writeRolloverManifest({
859
- schemaVersion: 'YoloopSessionRolloverManifest/1.0',
860
- phase: 'applied',
861
- operation,
862
- });
863
- this.logger.info({
864
- event: 'yoloop_session_rollover_applied',
865
- runId: operation.runId,
866
- operationId: operation.operationId,
867
- coolDownUntil: operation.coolDownUntil,
868
- }, 'yoloop per-run-cap session rollover applied');
869
- }
870
- async recoverSessionRolloverIfPresent(nowIso) {
871
- try {
872
- await fs.access(this.rolloverManifestPath);
873
- }
874
- catch (error) {
875
- if (error.code === 'ENOENT')
876
- return;
877
- throw error;
878
- }
879
- await this.ensureDir();
880
- await this.withMutationLock(async () => {
881
- const manifest = await this.readRolloverManifest();
882
- if (manifest?.phase === 'prepared') {
883
- await this.completePreparedSessionRollover(manifest.operation, nowIso);
884
- }
885
- });
886
- }
887
- async readRolloverManifest() {
888
- try {
889
- const parsed = JSON.parse(await fs.readFile(this.rolloverManifestPath, 'utf8'));
890
- if (parsed.schemaVersion !== 'YoloopSessionRolloverManifest/1.0'
891
- || (parsed.phase !== 'prepared' && parsed.phase !== 'applied')
892
- || !parsed.operation
893
- || parsed.operation.type !== 'rollover_session')
894
- throw new Error('YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT');
895
- validateBoundedSessionRollover(parsed.operation);
896
- return parsed;
897
- }
898
- catch (error) {
899
- if (error.code === 'ENOENT')
900
- return null;
901
- if (error.message === 'YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT')
902
- throw error;
903
- throw new Error('YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT');
904
- }
905
- }
906
- async writeRolloverManifest(manifest) {
907
- const tmpPath = `${this.rolloverManifestPath}.tmp.${process.pid}.${Date.now()}`;
908
- const handle = await fs.open(tmpPath, 'w', 0o600);
909
- try {
910
- await handle.writeFile(JSON.stringify(manifest, null, 2), 'utf8');
911
- await handle.sync();
912
- }
913
- finally {
914
- await handle.close();
915
- }
916
- this.trySetFilePerms(tmpPath);
917
- await fs.rename(tmpPath, this.rolloverManifestPath);
918
- await this.syncStateDirectory();
919
- }
920
- /**
921
- * Journal the predecessor replacement, completed-story acknowledgement and
922
- * next authority reservation as one recoverable logical CAS transition.
923
- * Individual state-file renames remain atomic; the prepared/applied manifest
924
- * closes every crash window between them.
925
- */
926
- async commitSuccessorTransition(operation, nowIso) {
927
- validateBoundedSuccessorTransition(operation);
928
- const manifest = await this.readSuccessorTransitionManifest();
929
- if (manifest) {
930
- if (sameSuccessorTransitionIdentity(manifest.operation, operation)) {
931
- if (manifest.operation.payloadHash !== operation.payloadHash) {
932
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
933
- }
934
- if (manifest.phase === 'applied')
935
- return;
936
- await this.completePreparedSuccessorTransition(manifest.operation, nowIso);
937
- return;
938
- }
939
- if (manifest.phase === 'prepared') {
940
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
941
- }
942
- }
943
- const current = await this.readState(nowIso);
944
- if (successorTransitionApplied(operation, current)) {
945
- await this.writeSuccessorTransitionManifest({
946
- schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
947
- phase: 'applied',
948
- operation,
949
- });
950
- return;
951
- }
952
- validateSuccessorTransitionBeforeState(operation, current);
953
- await this.writeSuccessorTransitionManifest({
954
- schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
955
- phase: 'prepared',
956
- operation,
957
- });
958
- await this.completePreparedSuccessorTransition(operation, nowIso);
959
- }
960
- async completePreparedSuccessorTransition(operation, nowIso) {
961
- let current = await this.readState(nowIso);
962
- if (!successorTransitionApplied(operation, current)) {
963
- const successorApplied = stablePublicJson(current.runControl)
964
- === stablePublicJson(operation.successor.next);
965
- if (!successorApplied) {
966
- await this.startSuccessorRun(operation.successor, nowIso);
967
- current = await this.readState(nowIso);
968
- }
969
- const next = buildSuccessorTransitionState(operation, current);
970
- await this.writeState(next);
971
- current = await this.readState(nowIso);
972
- }
973
- if (!successorTransitionApplied(operation, current)) {
974
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT');
975
- }
976
- await this.writeSuccessorTransitionManifest({
977
- schemaVersion: 'YoloopSuccessorTransitionManifest/1.0',
978
- phase: 'applied',
979
- operation,
980
- });
981
- this.logger.info({
982
- event: 'yoloop_successor_transition_applied',
983
- runId: operation.successor.runId,
984
- operationId: operation.operationId,
985
- }, 'yoloop successor transition applied');
986
- }
987
- async recoverSuccessorTransitionIfPresent(nowIso) {
988
- try {
989
- await fs.access(this.successorTransitionManifestPath);
990
- }
991
- catch (error) {
992
- if (error.code === 'ENOENT')
993
- return;
994
- throw error;
995
- }
996
- await this.ensureDir();
997
- await this.withMutationLock(async () => {
998
- const manifest = await this.readSuccessorTransitionManifest();
999
- if (manifest?.phase === 'prepared') {
1000
- await this.completePreparedSuccessorTransition(manifest.operation, nowIso);
1001
- }
1002
- });
1003
- }
1004
- async readSuccessorTransitionManifest() {
1005
- try {
1006
- const parsed = JSON.parse(await fs.readFile(this.successorTransitionManifestPath, 'utf8'));
1007
- if (parsed.schemaVersion !== 'YoloopSuccessorTransitionManifest/1.0'
1008
- || (parsed.phase !== 'prepared' && parsed.phase !== 'applied')
1009
- || !parsed.operation
1010
- || parsed.operation.type !== 'commit_successor_transition')
1011
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT');
1012
- validateBoundedSuccessorTransition(parsed.operation);
1013
- return parsed;
1014
- }
1015
- catch (error) {
1016
- if (error.code === 'ENOENT')
1017
- return null;
1018
- if (error.message === 'YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT')
1019
- throw error;
1020
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT');
1021
- }
1022
- }
1023
- async writeSuccessorTransitionManifest(manifest) {
1024
- const tmpPath = `${this.successorTransitionManifestPath}.tmp.${process.pid}.${Date.now()}`;
1025
- const handle = await fs.open(tmpPath, 'w', 0o600);
1026
- try {
1027
- await handle.writeFile(JSON.stringify(manifest, null, 2), 'utf8');
1028
- await handle.sync();
1029
- }
1030
- finally {
1031
- await handle.close();
1032
- }
1033
- this.trySetFilePerms(tmpPath);
1034
- await fs.rename(tmpPath, this.successorTransitionManifestPath);
1035
- await this.syncStateDirectory();
1036
- }
1037
- /**
1038
- * Advance to next epic: read-modify-write on currentSession.currentEpicIndex.
1039
- * Fail-open if no active session (logs warn; subsequent *yoloop will
1040
- * emit a fresh acquire_lock).
1041
- */
1042
- async advanceEpic(nextIndex, nextEpicId, nowIso) {
1043
- const current = await this.readState(nowIso);
1044
- if (!current.currentSession) {
1045
- this.logger.warn({ event: 'yoloop_advance_no_session', nextEpicId }, 'advance_epic with no active session; skipping');
1046
- return;
1047
- }
1048
- const updated = {
1049
- ...current,
1050
- currentSession: {
1051
- ...current.currentSession,
1052
- currentEpicIndex: nextIndex,
1053
- },
1054
- };
1055
- await this.writeState(updated);
1056
- this.logger.info({ event: 'yoloop_epic_advance', nextEpicId, nextIndex }, 'yoloop advanced to next epic');
1057
- }
1058
- // ── Internal helpers ────────────────────────────────────────────────
1059
- async ensureDir() {
1060
- try {
1061
- await fs.mkdir(this.stateDir, { recursive: true });
1062
- this.trySetDirPerms(this.stateDir);
1063
- }
1064
- catch (err) {
1065
- const e = err;
1066
- this.logger.error({ event: 'yoloop_mkdir_error', err: e.message, path: this.stateDir }, 'failed to ensure .neocortex/ directory');
1067
- throw err;
1068
- }
1069
- }
1070
- async syncStateDirectory() {
1071
- try {
1072
- const directory = await fs.open(this.stateDir, 'r');
1073
- try {
1074
- await directory.sync();
1075
- }
1076
- finally {
1077
- await directory.close();
1078
- }
1079
- }
1080
- catch (error) {
1081
- const code = error.code;
1082
- if (code !== 'EINVAL' && code !== 'EISDIR' && code !== 'ENOTSUP' && code !== 'EPERM') {
1083
- throw error;
1084
- }
1085
- }
1086
- }
1087
- async withMutationLock(operation) {
1088
- let handle = null;
1089
- for (let attempt = 0; attempt < MUTATION_LOCK_RETRIES; attempt += 1) {
1090
- try {
1091
- handle = await fs.open(this.mutationLockPath, 'wx', 0o600);
1092
- await handle.writeFile(JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }));
1093
- await handle.sync();
1094
- break;
1095
- }
1096
- catch (error) {
1097
- if (error.code !== 'EEXIST')
1098
- throw error;
1099
- try {
1100
- const stat = await fs.stat(this.mutationLockPath);
1101
- if (Date.now() - stat.mtimeMs > MUTATION_LOCK_STALE_MS) {
1102
- await fs.unlink(this.mutationLockPath);
1103
- continue;
1104
- }
1105
- }
1106
- catch {
1107
- continue;
1108
- }
1109
- await new Promise((resolve) => setTimeout(resolve, 5));
1110
- }
1111
- }
1112
- if (!handle)
1113
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1114
- try {
1115
- return await operation();
1116
- }
1117
- finally {
1118
- await handle.close().catch(() => undefined);
1119
- await fs.unlink(this.mutationLockPath).catch(() => undefined);
1120
- }
1121
- }
1122
- async transitionRun(operation, nowIso) {
1123
- validateBoundedRunOperation(operation);
1124
- const current = await this.readState(nowIso);
1125
- const existing = current.runControl;
1126
- const replay = existing?.receipts.find((receipt) => receipt.operationId === operation.operationId
1127
- || receipt.idempotencyKey === operation.idempotencyKey);
1128
- if (replay) {
1129
- if (replay.operationId === operation.operationId
1130
- && replay.idempotencyKey === operation.idempotencyKey
1131
- && replay.payloadHash === operation.payloadHash)
1132
- return replay;
1133
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1134
- }
1135
- if (existing && existing.runId !== operation.runId) {
1136
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1137
- }
1138
- const actualRevision = existing?.revision ?? 0;
1139
- if (actualRevision !== operation.expectedRevision) {
1140
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1141
- }
1142
- const actualSnapshotHash = existing?.latestSnapshotHash ?? null;
1143
- if (actualSnapshotHash !== operation.expectedSnapshotHash) {
1144
- throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1145
- }
1146
- validateProposedRunTransition(existing, operation);
1147
- const receipt = operation.next.receipts.at(-1);
1148
- if (!receipt)
1149
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1150
- await this.writeState({ ...current, version: '1.2', runControl: operation.next });
1151
- this.logger.info({
1152
- event: 'yoloop_run_transition_committed',
1153
- runId: operation.runId,
1154
- operationId: operation.operationId,
1155
- revision: receipt.revision,
1156
- status: receipt.status,
1157
- }, 'yoloop run transition committed');
1158
- return receipt;
1159
- }
1160
- /** Replace a proven exhausted run, or an explicitly reopened success, with a fresh successor. */
1161
- async startSuccessorRun(operation, nowIso) {
1162
- validateBoundedSuccessorRunOperation(operation);
1163
- const current = await this.readState(nowIso);
1164
- const existing = current.runControl;
1165
- const expectedPredecessor = {
1166
- schemaVersion: 'YoloopRunPredecessor/1.0',
1167
- runId: operation.previousRunId,
1168
- revision: operation.expectedRevision,
1169
- snapshotHash: operation.expectedSnapshotHash,
1170
- successorOperationId: operation.operationId,
1171
- publicSafe: true,
1172
- };
1173
- const replay = existing?.receipts.find((receipt) => receipt.operationId === operation.operationId
1174
- || receipt.idempotencyKey === operation.idempotencyKey);
1175
- if (replay) {
1176
- if (replay.operationId === operation.operationId
1177
- && replay.idempotencyKey === operation.idempotencyKey
1178
- && operation.next.receipts.at(-1)?.payloadHash === replay.payloadHash
1179
- && stablePublicJson(existing?.predecessor) === stablePublicJson(expectedPredecessor))
1180
- return replay;
1181
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1182
- }
1183
- if (!existing
1184
- || existing.runId !== operation.previousRunId
1185
- || !['exhausted', 'success'].includes(existing.status)
1186
- || existing.revision !== operation.expectedRevision
1187
- || existing.latestSnapshotHash !== operation.expectedSnapshotHash
1188
- || current.currentSession !== null)
1189
- throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1190
- const createdReceipt = operation.next.receipts.at(-1);
1191
- if (!createdReceipt
1192
- || stablePublicJson(operation.next.predecessor) !== stablePublicJson(expectedPredecessor))
1193
- throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1194
- const createdTransition = {
1195
- type: 'transition_run',
1196
- runId: operation.runId,
1197
- operationId: operation.operationId,
1198
- idempotencyKey: operation.idempotencyKey,
1199
- expectedRevision: 0,
1200
- expectedSnapshotHash: null,
1201
- payloadHash: createdReceipt.payloadHash,
1202
- next: operation.next,
1203
- };
1204
- validateBoundedRunOperation(createdTransition);
1205
- validateProposedRunTransition(undefined, createdTransition);
1206
- await this.writeState({ ...current, version: '1.2', runControl: operation.next });
1207
- return createdReceipt;
1208
- }
1209
- /**
1210
- * Fire-and-forget chmod/ACL. Uses sync helpers from secure-config.ts
1211
- * which are fail-open by contract (never throw).
1212
- */
1213
- trySetFilePerms(path) {
1214
- try {
1215
- setSecureFilePermissions(path);
1216
- }
1217
- catch {
1218
- // setSecureFilePermissions is fail-open but be defensive
1219
- }
1220
- }
1221
- trySetDirPerms(path) {
1222
- try {
1223
- setSecureDirPermissions(path);
1224
- }
1225
- catch {
1226
- // ignore
1227
- }
1228
- }
1229
- /**
1230
- * Normalize a lock before echoing it to the server. The local client can
1231
- * check PID liveness for client-owned same-host locks; the server cannot.
1232
- */
1233
- async prepareLockForSnapshot(meta, nowIso) {
1234
- if (!meta)
1235
- return null;
1236
- if (meta.ownerKind !== 'client') {
1237
- return meta;
1238
- }
1239
- const leaseState = classifyClientLease(meta, nowIso);
1240
- const metaWithLease = { ...meta, leaseState };
1241
- if (leaseState === 'expired') {
1242
- await this.deleteLockBestEffort();
1243
- this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'lease_expired' }, 'expired client-owned yoloop lock lease recovered before snapshot echo');
1244
- return null;
1245
- }
1246
- if (!metaWithLease.pid || metaWithLease.pid <= 0) {
1247
- await this.deleteLockBestEffort();
1248
- this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'invalid_pid' }, 'invalid PID in client-owned yoloop lock; recovered');
1249
- return null;
1250
- }
1251
- if (metaWithLease.hostname && metaWithLease.hostname !== hostname()) {
1252
- return { ...metaWithLease, ownerPidState: 'unknown' };
1253
- }
1254
- try {
1255
- process.kill(metaWithLease.pid, 0);
1256
- return { ...metaWithLease, ownerPidState: 'live' };
1257
- }
1258
- catch {
1259
- await this.deleteLockBestEffort();
1260
- this.logger.warn({ event: 'yoloop_client_lock_recovered', reason: 'dead_pid' }, 'stale client-owned yoloop lock recovered before snapshot echo');
1261
- return null;
1262
- }
1263
- }
1264
- /**
1265
- * If the existing lock is held by a dead PID on the same host, delete
1266
- * it and return true (caller retries acquisition). Otherwise return
1267
- * false (honor the lock).
1268
- */
1269
- async maybeRecoverOrphanLock(nowIso) {
1270
- const meta = await this.readLock();
1271
- if (!meta) {
1272
- // Lock file disappeared between EEXIST and our read -> safe to retry
1273
- return true;
1274
- }
1275
- if (classifyClientLease(meta, nowIso) === 'expired') {
1276
- await this.deleteLockBestEffort();
1277
- this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'lease_expired' }, 'expired yoloop lock lease recovered');
1278
- return true;
1279
- }
1280
- if (!meta.pid || meta.pid <= 0) {
1281
- await this.deleteLockBestEffort();
1282
- this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'invalid_pid' }, 'invalid PID in lock file; recovered');
1283
- return true;
1284
- }
1285
- // Cross-host locks cannot be killed; honor them.
1286
- if (meta.hostname && meta.hostname !== hostname()) {
1287
- this.logger.warn({
1288
- event: 'yoloop_lock_remote_host',
1289
- ownerKind: meta.ownerKind ?? 'server',
1290
- ownerPidState: 'unknown',
1291
- }, 'lock held by a remote host; honoring');
1292
- return false;
1293
- }
1294
- try {
1295
- process.kill(meta.pid, 0); // liveness check
1296
- return false; // alive -> honor
1297
- }
1298
- catch {
1299
- await this.deleteLockBestEffort();
1300
- this.logger.warn({ event: 'yoloop_orphan_lock_recovered', reason: 'dead_pid' }, 'orphan yoloop lock recovered');
1301
- return true;
1302
- }
1303
- }
1304
- async deleteLockBestEffort() {
1305
- try {
1306
- await fs.unlink(this.lockPath);
1307
- }
1308
- catch {
1309
- // ignore
1310
- }
1311
- }
1312
- isSameClientSessionLock(existing, next) {
1313
- return (existing.ownerKind === 'client' &&
1314
- existing.sessionId === next.sessionId &&
1315
- existing.pid === process.pid &&
1316
- existing.hostname === hostname());
1317
- }
1318
- /** Remove `yoloop-state.json.tmp.*` orphans from crashed writes. */
1319
- async cleanupOrphanTempFiles() {
1320
- try {
1321
- const dir = dirname(this.statePath);
1322
- const entries = await fs.readdir(dir);
1323
- const stems = [
1324
- `${STATE_FILE}.tmp.`,
1325
- `${ROLLOVER_MANIFEST_FILE}.tmp.`,
1326
- `${SUCCESSOR_TRANSITION_MANIFEST_FILE}.tmp.`,
1327
- ];
1328
- const candidates = entries.filter((entry) => stems.some((stem) => entry.startsWith(stem)));
1329
- for (const entry of candidates) {
1330
- try {
1331
- await fs.unlink(join(dir, entry));
1332
- }
1333
- catch {
1334
- // ignore individual failures
1335
- }
1336
- }
1337
- }
1338
- catch {
1339
- // dir may not exist yet; ignore
1340
- }
1341
- }
1342
- }
1343
- function stablePublicJson(value) {
1344
- if (value === null || typeof value !== 'object')
1345
- return JSON.stringify(value);
1346
- if (Array.isArray(value))
1347
- return `[${value.map(stablePublicJson).join(',')}]`;
1348
- return `{${Object.entries(value).filter(([, child]) => child !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => `${JSON.stringify(key)}:${stablePublicJson(child)}`).join(',')}}`;
1349
- }
1350
- function domainHash(domain, value) {
1351
- return createHash('sha256').update(`${domain}\0${stablePublicJson(value)}`).digest('hex');
1352
- }
1353
- export function buildYoloopContinuationReplanPayloadHash(payload) {
1354
- return domainHash('p222-yoloop-continuation-replan-v1', payload);
1355
- }
1356
- function isSealedYoloopFrozenSession(value) {
1357
- if (!value || typeof value !== 'object' || Array.isArray(value))
1358
- return false;
1359
- const { sessionId, ...pinned } = value;
1360
- return typeof sessionId === 'string'
1361
- && /^[a-f0-9]{64}$/.test(sessionId)
1362
- && sessionId === domainHash('p205-yoloop-session-v1', pinned);
1363
- }
1364
- export function fingerprintYoloopSession(session) {
1365
- return domainHash('p9390812898200217364-yoloop-session-v1', session);
1366
- }
1367
- export function buildYoloopSessionRolloverPayloadHash(operation) {
1368
- return domainHash('p9390812898200217364-yoloop-rollover-v1', operation);
1369
- }
1370
- export function buildYoloopSuccessorRunPayloadHash(operation) {
1371
- return domainHash('p9390812898200217364-yoloop-successor-run-v1', operation);
1372
- }
1373
- export function buildYoloopSuccessorTransitionPayloadHash(operation) {
1374
- return domainHash('p9046281030721413419-yoloop-successor-transition-v1', operation);
1375
- }
1376
- function sameSuccessorTransitionIdentity(left, right) {
1377
- return left.operationId === right.operationId
1378
- && left.idempotencyKey === right.idempotencyKey;
1379
- }
1380
- function validateBoundedSuccessorTransition(operation) {
1381
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1382
- validateBoundedSuccessorRunOperation(operation.successor);
1383
- const base = {
1384
- operationId: operation.operationId,
1385
- idempotencyKey: operation.idempotencyKey,
1386
- successor: operation.successor,
1387
- state: operation.state,
1388
- ...(operation.acknowledgement ? { acknowledgement: operation.acknowledgement } : {}),
1389
- ...(operation.reservation ? { reservation: operation.reservation } : {}),
1390
- };
1391
- const acknowledgementKey = operation.acknowledgement?.idempotencyKey;
1392
- const acknowledgementReceiptKey = operation.acknowledgement?.receipt.idempotencyKey;
1393
- const reservationKey = operation.reservation?.authority.idempotencyKey;
1394
- if (operation.successor.type !== 'start_successor_run'
1395
- || !boundedId.test(operation.operationId)
1396
- || !boundedId.test(operation.idempotencyKey)
1397
- || operation.operationId !== operation.successor.operationId
1398
- || operation.idempotencyKey !== operation.successor.idempotencyKey
1399
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1400
- || operation.payloadHash !== buildYoloopSuccessorTransitionPayloadHash(base)
1401
- || Buffer.byteLength(stablePublicJson(base), 'utf8') > 1_048_576
1402
- || (operation.acknowledgement !== undefined && (operation.acknowledgement.type !== 'acknowledge_continuation_authority'
1403
- || typeof acknowledgementKey !== 'string'
1404
- || !boundedId.test(acknowledgementKey)
1405
- || acknowledgementReceiptKey !== acknowledgementKey))
1406
- || (operation.reservation !== undefined && (operation.reservation.type !== 'reserve_continuation_authority'
1407
- || typeof reservationKey !== 'string'
1408
- || !boundedId.test(reservationKey)))
1409
- || (acknowledgementKey !== undefined && reservationKey === acknowledgementKey))
1410
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_CONFLICT');
1411
- }
1412
- function validateSuccessorTransitionBeforeState(operation, current) {
1413
- validateBoundedSuccessorTransition(operation);
1414
- const successorAlreadyApplied = stablePublicJson(current.runControl)
1415
- === stablePublicJson(operation.successor.next);
1416
- if (!successorAlreadyApplied) {
1417
- const predecessor = current.runControl;
1418
- if (!predecessor
1419
- || predecessor.runId !== operation.successor.previousRunId
1420
- || !['exhausted', 'success'].includes(predecessor.status)
1421
- || predecessor.revision !== operation.successor.expectedRevision
1422
- || predecessor.latestSnapshotHash !== operation.successor.expectedSnapshotHash
1423
- || current.currentSession !== null)
1424
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT');
1425
- }
1426
- validateSuccessorTransitionAuthorityProjection(operation, current);
1427
- }
1428
- function validateSuccessorTransitionAuthorityProjection(operation, current) {
1429
- const pending = current.pendingContinuationAuthorities ?? [];
1430
- const receipts = current.continuationReceipts ?? [];
1431
- const acknowledgement = operation.acknowledgement;
1432
- if (acknowledgement) {
1433
- const authority = pending.find((item) => item.idempotencyKey === acknowledgement.idempotencyKey);
1434
- if (!authority
1435
- || acknowledgement.receipt.sessionId !== authority.sessionId
1436
- || stablePublicJson(acknowledgement.receipt.storyIds) !== stablePublicJson(authority.storyIds))
1437
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1438
- const projectedPending = pending.filter((item) => item.idempotencyKey !== acknowledgement.idempotencyKey);
1439
- const projectedReceipts = [...receipts.slice(-63), acknowledgement.receipt];
1440
- if (stablePublicJson(operation.state.pendingContinuationAuthorities ?? [])
1441
- !== stablePublicJson(projectedPending)
1442
- || stablePublicJson(operation.state.continuationReceipts ?? [])
1443
- !== stablePublicJson(projectedReceipts))
1444
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1445
- }
1446
- else if (stablePublicJson(operation.state.pendingContinuationAuthorities ?? []) !== stablePublicJson(pending)
1447
- || stablePublicJson(operation.state.continuationReceipts ?? []) !== stablePublicJson(receipts)) {
1448
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1449
- }
1450
- const reservation = operation.reservation?.authority;
1451
- if (!reservation)
1452
- return;
1453
- const key = String(reservation.idempotencyKey ?? '');
1454
- const projected = operation.state.pendingContinuationAuthorities ?? [];
1455
- const existing = projected.find((item) => item.idempotencyKey === key);
1456
- if (existing && stablePublicJson(existing) !== stablePublicJson(reservation)) {
1457
- throw new Error('YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT');
1458
- }
1459
- if (!existing && projected.length >= 64) {
1460
- throw new Error('YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED');
1461
- }
1462
- }
1463
- function buildSuccessorTransitionState(operation, current) {
1464
- validateSuccessorTransitionBeforeState(operation, current);
1465
- const reservation = operation.reservation?.authority;
1466
- const projected = [...(operation.state.pendingContinuationAuthorities ?? [])];
1467
- if (reservation && !projected.some((item) => item.idempotencyKey === reservation.idempotencyKey)) {
1468
- projected.push(reservation);
1469
- }
1470
- return {
1471
- ...operation.state,
1472
- version: '1.2',
1473
- runControl: operation.successor.next,
1474
- ...(reservation || operation.state.pendingContinuationAuthorities !== undefined
1475
- ? { pendingContinuationAuthorities: projected }
1476
- : {}),
1477
- };
1478
- }
1479
- function successorTransitionApplied(operation, current) {
1480
- const reservation = operation.reservation?.authority;
1481
- const projected = [...(operation.state.pendingContinuationAuthorities ?? [])];
1482
- if (reservation && !projected.some((item) => item.idempotencyKey === reservation.idempotencyKey)) {
1483
- projected.push(reservation);
1484
- }
1485
- const expected = {
1486
- ...operation.state,
1487
- version: '1.2',
1488
- runControl: operation.successor.next,
1489
- ...(reservation || operation.state.pendingContinuationAuthorities !== undefined
1490
- ? { pendingContinuationAuthorities: projected }
1491
- : {}),
1492
- };
1493
- return stablePublicJson(current) === stablePublicJson(expected);
1494
- }
1495
- function sameRolloverIdentity(left, right) {
1496
- return left.operationId === right.operationId
1497
- && left.idempotencyKey === right.idempotencyKey;
1498
- }
1499
- function validateBoundedSessionRollover(operation) {
1500
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1501
- const base = {
1502
- runId: operation.runId,
1503
- operationId: operation.operationId,
1504
- idempotencyKey: operation.idempotencyKey,
1505
- expectedRevision: operation.expectedRevision,
1506
- expectedSnapshotHash: operation.expectedSnapshotHash,
1507
- expectedSessionId: operation.expectedSessionId,
1508
- expectedSessionFingerprint: operation.expectedSessionFingerprint,
1509
- expectedLeaseId: operation.expectedLeaseId,
1510
- coolDownUntil: operation.coolDownUntil,
1511
- reason: operation.reason,
1512
- exhaustedRun: operation.exhaustedRun,
1513
- next: operation.next,
1514
- };
1515
- if (!boundedId.test(operation.runId)
1516
- || !boundedId.test(operation.operationId)
1517
- || !boundedId.test(operation.idempotencyKey)
1518
- || !/^[a-f0-9]{64}$/.test(operation.expectedSessionFingerprint)
1519
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1520
- || !Number.isSafeInteger(operation.expectedRevision)
1521
- || operation.expectedRevision < 0
1522
- || !Number.isFinite(Date.parse(operation.coolDownUntil))
1523
- || !['YOLOOP_PER_RUN_CAP_REACHED', 'YOLOOP_SESSION_TIMEOUT'].includes(operation.reason)
1524
- || operation.payloadHash !== buildYoloopSessionRolloverPayloadHash(base)
1525
- || operation.receipt.schemaVersion !== 'YoloopSessionRolloverReceipt/1.0'
1526
- || operation.receipt.runId !== operation.runId
1527
- || operation.receipt.operationId !== operation.operationId
1528
- || operation.receipt.idempotencyKey !== operation.idempotencyKey
1529
- || operation.receipt.payloadHash !== operation.payloadHash
1530
- || operation.receipt.coolDownUntil !== operation.coolDownUntil
1531
- || operation.receipt.revision !== (operation.exhaustedRun?.revision ?? 0)
1532
- || operation.receipt.snapshotHash !== (operation.exhaustedRun?.latestSnapshotHash ?? null)
1533
- || operation.receipt.status !== 'exhausted'
1534
- || operation.receipt.publicSafe !== true)
1535
- throw new Error('YOLOOP_SESSION_ROLLOVER_CONFLICT');
1536
- if ((operation.expectedSessionId === null) !== (operation.expectedLeaseId === null)
1537
- || (operation.expectedSessionId !== null && !boundedId.test(operation.expectedSessionId))
1538
- || (operation.expectedLeaseId !== null && !boundedId.test(operation.expectedLeaseId)))
1539
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1540
- }
1541
- function validateBoundedSuccessorRunOperation(operation) {
1542
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1543
- const base = {
1544
- runId: operation.runId,
1545
- previousRunId: operation.previousRunId,
1546
- operationId: operation.operationId,
1547
- idempotencyKey: operation.idempotencyKey,
1548
- expectedRevision: operation.expectedRevision,
1549
- expectedSnapshotHash: operation.expectedSnapshotHash,
1550
- next: operation.next,
1551
- };
1552
- if (!boundedId.test(operation.runId)
1553
- || !boundedId.test(operation.previousRunId)
1554
- || operation.runId === operation.previousRunId
1555
- || !boundedId.test(operation.operationId)
1556
- || !boundedId.test(operation.idempotencyKey)
1557
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1558
- || !Number.isSafeInteger(operation.expectedRevision)
1559
- || operation.expectedRevision < 1
1560
- || operation.payloadHash !== buildYoloopSuccessorRunPayloadHash(base))
1561
- throw new Error('YOLOOP_RUN_SUCCESSOR_CONFLICT');
1562
- }
1563
- function validateSessionRolloverBeforeState(operation, current, lock) {
1564
- validateBoundedSessionRollover(operation);
1565
- const session = current.currentSession;
1566
- if (!session || fingerprintYoloopSession(session) !== operation.expectedSessionFingerprint) {
1567
- throw new Error('YOLOOP_SESSION_ROLLOVER_SESSION_CONFLICT');
1568
- }
1569
- const currentRun = current.runControl;
1570
- if ((currentRun?.revision ?? 0) !== operation.expectedRevision
1571
- || (currentRun?.latestSnapshotHash ?? null) !== operation.expectedSnapshotHash
1572
- || (currentRun && currentRun.runId !== operation.runId))
1573
- throw new Error('YOLOOP_RUN_REVISION_CONFLICT');
1574
- if (operation.expectedSessionId === null || operation.expectedLeaseId === null) {
1575
- if (lock)
1576
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1577
- }
1578
- else if (!lock
1579
- || lock.sessionId !== operation.expectedSessionId
1580
- || lock.leaseId !== operation.expectedLeaseId) {
1581
- throw new Error('YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT');
1582
- }
1583
- const nextRun = operation.exhaustedRun;
1584
- if (!currentRun) {
1585
- const createdReceipt = nextRun?.receipts[0];
1586
- const exhaustedReceipt = nextRun?.receipts[1];
1587
- if (nextRun && (nextRun.runId !== operation.runId
1588
- || nextRun.status !== 'exhausted'
1589
- || nextRun.revision !== 2
1590
- || nextRun.events.length !== 2
1591
- || nextRun.progressSnapshots.length !== 4
1592
- || nextRun.receipts.length !== 2
1593
- || createdReceipt?.runId !== operation.runId
1594
- || createdReceipt.revision !== 1
1595
- || createdReceipt.status !== 'created'
1596
- || exhaustedReceipt?.runId !== operation.runId
1597
- || exhaustedReceipt.revision !== 2
1598
- || exhaustedReceipt.status !== 'exhausted'
1599
- || exhaustedReceipt.operationId !== `${operation.operationId}:run`
1600
- || exhaustedReceipt.idempotencyKey !== `${operation.idempotencyKey}:run`
1601
- || nextRun.latestSnapshotHash !== exhaustedReceipt.snapshotHash
1602
- || nextRun.predecessor !== undefined))
1603
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1604
- }
1605
- else if (currentRun.status === 'exhausted') {
1606
- if (stablePublicJson(nextRun) !== stablePublicJson(currentRun)) {
1607
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1608
- }
1609
- }
1610
- else {
1611
- if (!nextRun || nextRun.status !== 'exhausted') {
1612
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1613
- }
1614
- const receipt = nextRun.receipts.at(-1);
1615
- if (!receipt)
1616
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1617
- const transition = {
1618
- type: 'transition_run',
1619
- runId: currentRun.runId,
1620
- operationId: receipt.operationId,
1621
- idempotencyKey: receipt.idempotencyKey,
1622
- expectedRevision: currentRun.revision,
1623
- expectedSnapshotHash: currentRun.latestSnapshotHash,
1624
- payloadHash: receipt.payloadHash,
1625
- next: nextRun,
1626
- };
1627
- validateBoundedRunOperation(transition);
1628
- validateProposedRunTransition(currentRun, transition);
1629
- }
1630
- const expectedNext = {
1631
- ...current,
1632
- version: '1.2',
1633
- coolDownUntil: operation.coolDownUntil,
1634
- currentSession: null,
1635
- ...(nextRun ? { runControl: nextRun } : {}),
1636
- };
1637
- if (stablePublicJson(expectedNext) !== stablePublicJson(operation.next)) {
1638
- throw new Error('YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT');
1639
- }
1640
- }
1641
- export function buildYoloopRunTransitionPayloadHash(input) {
1642
- const { receipts: _receipts, ...nextWithoutReceipts } = input.next;
1643
- return domainHash('p220-yoloop-run-transition-payload-v1', {
1644
- runId: input.runId,
1645
- operationId: input.operationId,
1646
- idempotencyKey: input.idempotencyKey,
1647
- expectedRevision: input.expectedRevision,
1648
- expectedSnapshotHash: input.expectedSnapshotHash,
1649
- next: nextWithoutReceipts,
1650
- });
1651
- }
1652
- const TERMINAL_RUN_STATUSES = new Set([
1653
- 'success', 'partial', 'blocked', 'failure', 'exhausted', 'cancelled',
1654
- ]);
1655
- const RUN_EDGES = {
1656
- created: ['running', 'cancelling', 'exhausted'],
1657
- running: ['pausing', 'cancelling', 'success', 'partial', 'blocked', 'failure', 'exhausted'],
1658
- pausing: ['paused', 'cancelling', 'failure', 'exhausted'],
1659
- paused: ['resuming', 'cancelling', 'exhausted'],
1660
- resuming: ['running', 'cancelling', 'failure', 'exhausted'],
1661
- cancelling: ['cancelled', 'failure', 'exhausted'],
1662
- };
1663
- function localLifecycleRequestTarget(status, action) {
1664
- if (action === 'pause')
1665
- return status === 'running' ? 'pausing' : null;
1666
- if (action === 'resume')
1667
- return status === 'paused' ? 'resuming' : null;
1668
- return TERMINAL_RUN_STATUSES.has(status) || status === 'cancelling' ? null : 'cancelling';
1669
- }
1670
- function buildLocalLifecycleOperation(input) {
1671
- const revision = input.current.revision + 1;
1672
- const reasonCodes = input.action === 'cancel' ? ['YOLOOP_RUN_CANCEL_REQUESTED'] : [];
1673
- const budget = {
1674
- schemaVersion: 'YoloopOperationalBudgetSummary/1.0',
1675
- limits: input.current.operationalBudget.limits,
1676
- usage: input.current.usage,
1677
- exhaustedDimension: null,
1678
- };
1679
- const beforeBase = {
1680
- schemaVersion: 'ProgressSnapshot/1.0',
1681
- runId: input.current.runId,
1682
- sequence: input.current.progressSnapshots.length + 1,
1683
- revision,
1684
- operationId: input.operationId,
1685
- boundary: input.action,
1686
- phase: 'before',
1687
- status: input.current.status,
1688
- occurredAt: input.occurredAt,
1689
- parentSnapshotHash: input.current.latestSnapshotHash,
1690
- budget,
1691
- reasonCodes,
1692
- publicSafe: true,
1693
- };
1694
- const beforeId = domainHash('p220-yoloop-progress-snapshot-id-v1', {
1695
- runId: beforeBase.runId, operationId: beforeBase.operationId,
1696
- sequence: beforeBase.sequence, phase: beforeBase.phase,
1697
- });
1698
- const before = {
1699
- ...beforeBase,
1700
- snapshotId: beforeId,
1701
- snapshotHash: domainHash('p220-yoloop-progress-snapshot-v1', { ...beforeBase, snapshotId: beforeId }),
1702
- };
1703
- const afterBase = {
1704
- ...beforeBase,
1705
- sequence: before.sequence + 1,
1706
- phase: 'after',
1707
- status: input.target,
1708
- parentSnapshotHash: before.snapshotHash,
1709
- };
1710
- const afterId = domainHash('p220-yoloop-progress-snapshot-id-v1', {
1711
- runId: afterBase.runId, operationId: afterBase.operationId,
1712
- sequence: afterBase.sequence, phase: afterBase.phase,
1713
- });
1714
- const after = {
1715
- ...afterBase,
1716
- snapshotId: afterId,
1717
- snapshotHash: domainHash('p220-yoloop-progress-snapshot-v1', { ...afterBase, snapshotId: afterId }),
1718
- };
1719
- const eventBase = {
1720
- schemaVersion: 'YoloopRunEvent/1.0',
1721
- runId: input.current.runId,
1722
- operationId: input.operationId,
1723
- kind: 'intent',
1724
- sequence: input.current.events.length + 1,
1725
- revision,
1726
- occurredAt: input.occurredAt,
1727
- fromStatus: input.current.status,
1728
- toStatus: input.target,
1729
- reasonCodes,
1730
- budgetDimension: null,
1731
- parentEventHash: input.current.events.at(-1)?.eventHash ?? null,
1732
- publicSafe: true,
1733
- };
1734
- const eventId = domainHash('p220-yoloop-run-event-id-v1', {
1735
- runId: eventBase.runId, operationId: eventBase.operationId, sequence: eventBase.sequence,
1736
- });
1737
- const event = {
1738
- ...eventBase,
1739
- eventId,
1740
- eventHash: domainHash('p220-yoloop-run-event-v1', { ...eventBase, eventId }),
1741
- };
1742
- const nextWithoutReceipt = {
1743
- ...input.current,
1744
- revision,
1745
- status: input.target,
1746
- events: [...input.current.events, event],
1747
- progressSnapshots: [...input.current.progressSnapshots, before, after],
1748
- latestSnapshotHash: after.snapshotHash,
1749
- receipts: [...input.current.receipts],
1750
- };
1751
- const base = {
1752
- runId: input.current.runId,
1753
- operationId: input.operationId,
1754
- idempotencyKey: input.idempotencyKey,
1755
- expectedRevision: input.current.revision,
1756
- expectedSnapshotHash: input.current.latestSnapshotHash,
1757
- next: nextWithoutReceipt,
1758
- };
1759
- const payloadHash = buildYoloopRunTransitionPayloadHash(base);
1760
- const receipt = {
1761
- schemaVersion: 'YoloopRunTransitionReceipt/1.0',
1762
- runId: input.current.runId,
1763
- operationId: input.operationId,
1764
- idempotencyKey: input.idempotencyKey,
1765
- payloadHash,
1766
- revision,
1767
- snapshotHash: after.snapshotHash,
1768
- status: input.target,
1769
- };
1770
- return {
1771
- type: 'transition_run', ...base, payloadHash,
1772
- next: { ...nextWithoutReceipt, receipts: [...nextWithoutReceipt.receipts, receipt] },
1773
- };
1774
- }
1775
- function validateBoundedRunOperation(operation) {
1776
- const boundedId = /^[A-Za-z0-9._:-]{1,128}$/;
1777
- if (!boundedId.test(operation.runId)
1778
- || !boundedId.test(operation.operationId)
1779
- || !boundedId.test(operation.idempotencyKey)
1780
- || !/^[a-f0-9]{64}$/.test(operation.payloadHash)
1781
- || !Number.isSafeInteger(operation.expectedRevision)
1782
- || operation.expectedRevision < 0)
1783
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1784
- if (operation.payloadHash !== buildYoloopRunTransitionPayloadHash(operation)) {
1785
- throw new Error('YOLOOP_RUN_OPERATION_CONFLICT');
1786
- }
1787
- }
1788
- function validateProposedRunTransition(current, operation) {
1789
- const next = operation.next;
1790
- const currentEvents = current?.events ?? [];
1791
- const currentSnapshots = current?.progressSnapshots ?? [];
1792
- const currentReceipts = current?.receipts ?? [];
1793
- if (next.schemaVersion !== 'YoloopRunControl/1.0'
1794
- || next.runId !== operation.runId
1795
- || next.revision !== operation.expectedRevision + 1
1796
- || next.events.length !== currentEvents.length + 1
1797
- || next.progressSnapshots.length !== currentSnapshots.length + 2
1798
- || next.receipts.length !== currentReceipts.length + 1
1799
- || stablePublicJson(next.events.slice(0, currentEvents.length)) !== stablePublicJson(currentEvents)
1800
- || stablePublicJson(next.progressSnapshots.slice(0, currentSnapshots.length)) !== stablePublicJson(currentSnapshots)
1801
- || stablePublicJson(next.receipts.slice(0, currentReceipts.length)) !== stablePublicJson(currentReceipts))
1802
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1803
- if (current) {
1804
- if (TERMINAL_RUN_STATUSES.has(current.status))
1805
- throw new Error('YOLOOP_RUN_TERMINAL');
1806
- const proposedBoundary = next.progressSnapshots.at(-2)?.boundary;
1807
- const boundaryOnly = current.status === next.status
1808
- && (proposedBoundary === 'budget' || proposedBoundary === 'retry');
1809
- if (!(RUN_EDGES[current.status] ?? []).includes(next.status) && !boundaryOnly) {
1810
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1811
- }
1812
- if (current.startedAt !== next.startedAt || current.deadlineAt !== next.deadlineAt) {
1813
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1814
- }
1815
- if (stablePublicJson(current.predecessor) !== stablePublicJson(next.predecessor)) {
1816
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1817
- }
1818
- }
1819
- else if (next.status !== 'created') {
1820
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1821
- }
1822
- const before = next.progressSnapshots.at(-2);
1823
- const after = next.progressSnapshots.at(-1);
1824
- const event = next.events.at(-1);
1825
- const receipt = next.receipts.at(-1);
1826
- if (!before || !after || !event || !receipt
1827
- || before.phase !== 'before' || after.phase !== 'after'
1828
- || before.operationId !== operation.operationId || after.operationId !== operation.operationId
1829
- || event.operationId !== operation.operationId
1830
- || before.runId !== operation.runId || after.runId !== operation.runId || event.runId !== operation.runId
1831
- || before.revision !== next.revision || after.revision !== next.revision || event.revision !== next.revision
1832
- || before.parentSnapshotHash !== (current?.latestSnapshotHash ?? null)
1833
- || after.parentSnapshotHash !== before.snapshotHash
1834
- || next.latestSnapshotHash !== after.snapshotHash
1835
- || receipt.schemaVersion !== 'YoloopRunTransitionReceipt/1.0'
1836
- || receipt.runId !== operation.runId
1837
- || receipt.operationId !== operation.operationId
1838
- || receipt.idempotencyKey !== operation.idempotencyKey
1839
- || receipt.payloadHash !== operation.payloadHash
1840
- || receipt.revision !== next.revision
1841
- || receipt.snapshotHash !== next.latestSnapshotHash
1842
- || receipt.status !== next.status
1843
- || Date.parse(next.deadlineAt) <= Date.parse(next.startedAt))
1844
- throw new Error('YOLOOP_RUN_TRANSITION_INVALID');
1845
- validateSnapshotHash(before);
1846
- validateSnapshotHash(after);
1847
- validateEventHash(event, currentEvents.at(-1)?.eventHash ?? null);
1848
- }
1849
- function validateSnapshotHash(snapshot) {
1850
- const { snapshotHash, ...hashed } = snapshot;
1851
- if (snapshotHash !== domainHash('p220-yoloop-progress-snapshot-v1', hashed)) {
1852
- throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1853
- }
1854
- }
1855
- function validateEventHash(event, expectedParent) {
1856
- const { eventHash, ...hashed } = event;
1857
- if (event.parentEventHash !== expectedParent
1858
- || eventHash !== domainHash('p220-yoloop-run-event-v1', hashed))
1859
- throw new Error('YOLOOP_RUN_SNAPSHOT_CONFLICT');
1860
- }
1861
- function buildPublicLeaseId(sessionId, nowIso) {
1862
- return `lease-${publicStableHash(`${sessionId}:${nowIso}`)}`;
1863
- }
1864
- function publicStableHash(value) {
1865
- let hash = 0x811c9dc5;
1866
- for (let i = 0; i < value.length; i += 1) {
1867
- hash ^= value.charCodeAt(i);
1868
- hash = Math.imul(hash, 0x01000193) >>> 0;
1869
- }
1870
- return hash.toString(36).padStart(7, '0');
1871
- }
1872
- function classifyClientLease(lock, nowIso) {
1873
- const expiresAt = lock.leaseExpiresAt ?? lock.ttlExpiresAt;
1874
- const expiresMs = Date.parse(expiresAt);
1875
- const nowMs = Date.parse(nowIso);
1876
- if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs))
1877
- return 'unknown';
1878
- return expiresMs <= nowMs ? 'expired' : 'active';
1879
- }
1880
- // ── Pure merge helper (used by readState) ───────────────────────────────
1881
- function mergeCountersWithDefaults(parsed, nowIso) {
1882
- const defaults = createDefaultCounters(nowIso);
1883
- return {
1884
- // Preserve additive/unknown fields byte-semantically across read/write cycles.
1885
- ...parsed,
1886
- version: parsed.version === '1.2' ? '1.2' : '1.1',
1887
- lastResetAt: parsed.lastResetAt ?? defaults.lastResetAt,
1888
- storiesRunToday: parsed.storiesRunToday ?? 0,
1889
- iterationsToday: parsed.iterationsToday ?? 0,
1890
- iterationsTodayDate: parsed.iterationsTodayDate ?? defaults.iterationsTodayDate,
1891
- tokensUsedToday: parsed.tokensUsedToday ?? 0,
1892
- lastRunAt: parsed.lastRunAt ?? null,
1893
- coolDownUntil: parsed.coolDownUntil ?? null,
1894
- totalRunsAllTime: parsed.totalRunsAllTime ?? 0,
1895
- totalStoriesAllTime: parsed.totalStoriesAllTime ?? 0,
1896
- currentSession: parsed.currentSession ?? null,
1897
- ...(parsed.runControl !== undefined ? { runControl: parsed.runControl } : {}),
1898
- ...(parsed.continuationPolicy !== undefined ? { continuationPolicy: parsed.continuationPolicy } : {}),
1899
- ...(parsed.continuationSession !== undefined ? { continuationSession: parsed.continuationSession } : {}),
1900
- ...(parsed.pendingContinuationAuthorities !== undefined ? { pendingContinuationAuthorities: parsed.pendingContinuationAuthorities } : {}),
1901
- ...(parsed.continuationReceipts !== undefined ? { continuationReceipts: parsed.continuationReceipts } : {}),
1902
- ...(parsed.continuationRecoveryReceipts !== undefined ? { continuationRecoveryReceipts: parsed.continuationRecoveryReceipts } : {}),
1903
- ...(parsed.continuationReplanReceipts !== undefined ? { continuationReplanReceipts: parsed.continuationReplanReceipts } : {}),
1904
- // P104.06: preserve recovery_history (defensive default to [])
1905
- recovery_history: Array.isArray(parsed.recovery_history)
1906
- ? capRecoveryHistory(parsed.recovery_history)
1907
- : [],
1908
- };
1909
- }
1910
- // ── P104.06: pure FIFO cap helper (exported for tests) ─────────────────────
1911
- /**
1912
- * Enforce FIFO bound on `recovery_history[]`. Returns a new array of length
1913
- * <= `RECOVERY_HISTORY_CAP`, preserving the most recent entries (drops from
1914
- * head). Pure: input is not mutated.
1915
- */
1916
- export function capRecoveryHistory(history) {
1917
- if (history.length <= RECOVERY_HISTORY_CAP)
1918
- return history;
1919
- return history.slice(history.length - RECOVERY_HISTORY_CAP);
1920
- }
1
+ import{promises as O}from"node:fs";import{existsSync as z}from"node:fs";import{createHash as X}from"node:crypto";import{join as S,dirname as W}from"node:path";import{hostname as w}from"node:os";import{setSecureFilePermissions as G,setSecureDirPermissions as Z}from"../config/secure-config.js";import{RECOVERY_HISTORY_CAP as A}from"./shared-yoloop-types.js";import{applyCrossEpicContinuationReconciliation as P,epicOperationalCanonicalJson as x,epicOperationalHash as f,mutateEpicOperationalState as Q,readEpicOperationalState as k}from"../git/epic-operational-state.js";const ee=".neocortex",Y="yoloop-state.json",te="yoloop.lock",ne="yoloop-state.mutation.lock",H="yoloop-session-rollover.json",U="yoloop-successor-transition.json",D=360*60*1e3,se=3e4,oe=200,F=Object.freeze(["rollover-session-v1","successor-transition-v1","continuation-replan-v1"]),re={warn:()=>{},info:()=>{},error:()=>{}};function R(t){const e=t.slice(0,10);return{version:"1.1",lastResetAt:t,storiesRunToday:0,iterationsToday:0,iterationsTodayDate:e,tokensUsedToday:0,lastRunAt:null,coolDownUntil:null,totalRunsAllTime:0,totalStoriesAllTime:0,currentSession:null,continuationPolicy:"p205-finite-resume-v2",pendingContinuationAuthorities:[],continuationReceipts:[],continuationRecoveryReceipts:[]}}function ge(t){return{lock:null,state:R(t),capabilities:F}}class Ae{projectRoot;stateDir;statePath;lockPath;mutationLockPath;rolloverManifestPath;successorTransitionManifestPath;logger;constructor(e,n=re){this.projectRoot=e,this.stateDir=S(e,ee),this.statePath=S(this.stateDir,Y),this.lockPath=S(this.stateDir,te),this.mutationLockPath=S(this.stateDir,ne),this.rolloverManifestPath=S(this.stateDir,H),this.successorTransitionManifestPath=S(this.stateDir,U),this.logger=n}async readAll(e){const n=e??new Date().toISOString(),s=this.readCommonAuthorityIfPresent();if(s)return{lock:s.yoloop.lock,state:s.yoloop.state,capabilities:s.yoloop.capabilities};await this.cleanupOrphanTempFiles(),await this.recoverSessionRolloverIfPresent(n),await this.recoverSuccessorTransitionIfPresent(n);const[o,r]=await Promise.all([this.readLock(),this.readState(n)]);return{lock:await this.prepareLockForSnapshot(o,n),state:r,capabilities:F}}async applyUpdate(e,n){const s=n??new Date().toISOString();if(this.readCommonAuthorityIfPresent()){for(const o of e.operations)await this.applyCommonOperation(o,s);return}await this.ensureDir(),await this.recoverSessionRolloverIfPresent(s),await this.recoverSuccessorTransitionIfPresent(s);for(const o of e.operations)await this.applyOperation(o,s)}async requestRunTransition(e,n){const s=n??new Date().toISOString();return await this.ensureDir(),this.withMutationLock(()=>this.transitionRun(e,s))}async requestLifecycleAction(e,n={},s){const o=s??new Date().toISOString();return await this.ensureDir(),this.withMutationLock(async()=>{const c=(await this.readState(o)).runControl;if(!c)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");const a=Date.parse(o).toString(36),i=n.operationId??`op-${e}-${a}`,d=n.idempotencyKey??`idem-${e}-${a}`,h=c.receipts.find(p=>p.operationId===i||p.idempotencyKey===d);if(h){if(h.operationId===i&&h.idempotencyKey===d)return h;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}const u=_e(c.status,e);if(!u)throw new Error(N.has(c.status)?"YOLOOP_RUN_TERMINAL":"YOLOOP_RUN_TRANSITION_INVALID");return this.transitionRun(Ie({current:c,action:e,target:u,operationId:i,idempotencyKey:d,occurredAt:o}),o)})}async applyOperation(e,n){switch(e.type){case"acquire_lock":await this.withMutationLock(()=>this.acquireLock(e.lock,n));return;case"release_lock":await this.withMutationLock(()=>this.releaseLock(e));return;case"write_state":await this.withMutationLock(async()=>{const s=await this.readState(n),{runControl:o,...r}=e.state;await this.writeState({...r,...s.runControl?{version:"1.2",runControl:s.runControl}:{}})});return;case"advance_epic":await this.advanceEpic(e.nextEpicIndex,e.nextEpicId,n);return;case"no_op":this.logger.info({event:"yoloop_noop"},"yoloop server sent no-op");return;case"reserve_continuation_authority":await this.reserveContinuationAuthority(e.authority,n);return;case"acknowledge_continuation_authority":await this.acknowledgeContinuationAuthority(e.idempotencyKey,e.receipt,n);return;case"repair_continuation_authority":await this.repairContinuationAuthority(e.beforeHash,e.authority,e.receipt,n);return;case"switch_continuation_policy":await this.switchContinuationPolicy(e.policy,e.session,n);return;case"transition_run":await this.requestRunTransition(e,n);return;case"start_successor_run":await this.withMutationLock(()=>this.startSuccessorRun(e,n));return;case"commit_successor_transition":await this.withMutationLock(()=>this.commitSuccessorTransition(e,n));return;case"rollover_session":await this.withMutationLock(()=>this.rolloverSession(e,n));return;case"replan_continuation_session":await this.withMutationLock(()=>this.replanContinuationSession(e,n));return;case"reconcile_cross_epic_continuation":await P(this.projectRoot,e);return;case"recovery_attempted":await this.appendRecoveryAttempt(e.attempt,n);return;case"recovery_resolved":await this.resolveRecoveryAttempt(e.attempt_id,e.outcome,n);return;default:{const s=e;throw this.logger.error({event:"yoloop_unknown_op",op:s},"unknown yoloop operation rejected"),new Error("YOLOOP_STATE_OPERATION_UNSUPPORTED")}}}readCommonAuthorityIfPresent(){try{const e=k(this.projectRoot).operational;if(e.activeJournal!==null)throw new Error("epic-operational-recovery-required");return e}catch(e){if(e instanceof Error&&["git-lifecycle-runtime-missing","epic-operational-store-missing"].includes(e.message)||e instanceof Error&&e.message==="git-lifecycle-common-dir-invalid"&&!z(S(this.projectRoot,".git")))return null;throw e}}async applyCommonOperation(e,n){if(e.type==="no_op")return;if(e.type==="reconcile_cross_epic_continuation"){await P(this.projectRoot,e);return}const{document:s,operational:o}=k(this.projectRoot),r=Object.keys(o.identities);if(r.length!==1)throw new Error("epic-operational-identity-mismatch");const c=r[0];await Q(this.projectRoot,{identityHash:c,expectedRevision:s.revision,expectedWriterEpoch:o.writerEpoch},a=>{const i=a.yoloop.state;let d=a.yoloop.lock,h=i;switch(e.type){case"acquire_lock":{if(d&&d.sessionId!==e.lock.sessionId)throw new Error("YOLOOP_LOCK_HELD");d={...e.lock,pid:process.pid,hostname:w(),ownerKind:"client",leaseId:e.lock.leaseId??j(e.lock.sessionId,n),leaseState:"active",leaseAcquiredAt:e.lock.leaseAcquiredAt??n,leaseDurationMs:e.lock.leaseDurationMs??D,leaseExpiresAt:e.lock.leaseExpiresAt??e.lock.ttlExpiresAt};break}case"release_lock":{if(e.expectedSessionId&&d?.sessionId!==e.expectedSessionId)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");if(e.expectedLeaseId&&d?.leaseId!==e.expectedLeaseId)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");if(e.expectedRunId&&i.runControl?.runId!==e.expectedRunId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");d=null;break}case"write_state":{const{runControl:u,...p}=e.state;h={...p,...i.runControl?{version:"1.2",runControl:i.runControl}:{}};break}case"advance_epic":{if(!i.currentSession)return;h={...i,currentSession:{...i.currentSession,currentEpicIndex:e.nextEpicIndex}};break}case"reserve_continuation_authority":{const u=i.pendingContinuationAuthorities??[],p=String(e.authority.idempotencyKey??""),_=u.find(I=>I.idempotencyKey===p);if(_){if(x(_)!==x(e.authority))throw new Error("YOLOOP_CONTINUATION_AUTHORITY_CONFLICT");return}if(!p||u.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED");h={...i,pendingContinuationAuthorities:[...u,e.authority]};break}case"acknowledge_continuation_authority":{const u=i.pendingContinuationAuthorities??[],p=u.find(I=>I.idempotencyKey===e.idempotencyKey);if(!p)throw new Error("YOLOOP_CONTINUATION_RECEIPT_ORPHAN");const _=i.continuationReceipts??[];if(_.length>=64)throw new Error("YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED");h={...i,pendingContinuationAuthorities:u.filter(I=>I!==p),continuationReceipts:[..._,e.receipt]};break}case"repair_continuation_authority":{const u=i.pendingContinuationAuthorities??[];if(u.length!==1||f("p208-legacy-authority-before-v1",u[0])!==e.beforeHash)throw new Error("YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH");const p=i.continuationRecoveryReceipts??[];if(p.length>=64)throw new Error("YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED");h={...i,pendingContinuationAuthorities:[e.authority],continuationRecoveryReceipts:[...p,e.receipt]};break}case"switch_continuation_policy":{if(i.currentSession||(i.pendingContinuationAuthorities?.length??0)>0)throw new Error("YOLOOP_POLICY_SWITCH_ACTIVE");h={...i,continuationPolicy:e.policy,continuationSession:e.session};break}case"transition_run":case"start_successor_run":{const u=i.runControl;if(!u||u.revision!==e.expectedRevision||u.latestSnapshotHash!==e.expectedSnapshotHash)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");h={...i,version:"1.2",runControl:e.next};break}case"commit_successor_transition":h={...e.state,version:"1.2",runControl:e.successor.next};break;case"rollover_session":h=e.next,d=null;break;case"replan_continuation_session":{const u=i.pendingContinuationAuthorities??[];if(f("p222-yoloop-replan-session-v1",i.continuationSession??null)!==e.expectedSessionHash||f("p222-yoloop-replan-pending-v1",u)!==e.expectedPendingHash||u.length>1||u.some(_=>_.delegated!==!1))throw new Error("YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT");const p=i.continuationReplanReceipts??[];if(p.length>=64)throw new Error("YOLOOP_CONTINUATION_RECEIPT_CAPACITY_EXCEEDED");h={...i,continuationSession:e.nextSession,pendingContinuationAuthorities:[],continuationReplanReceipts:[...p,e.receipt]};break}case"recovery_attempted":h={...i,recovery_history:m([...i.recovery_history??[],e.attempt])};break;case"recovery_resolved":{const u=[...i.recovery_history??[]],p=u.findIndex(_=>_.recovery_step_id_tokenized===e.attempt_id);p>=0&&(u[p]={...u[p],outcome:e.outcome,finished_at:n}),h={...i,recovery_history:u};break}default:{const u=e;throw new Error(`YOLOOP_STATE_OPERATION_UNSUPPORTED:${String(u)}`)}}a.yoloop={...a.yoloop,lock:d,state:h}})}async reserveContinuationAuthority(e,n){const s=await this.readState(n),o=s.pendingContinuationAuthorities??[],r=typeof e.idempotencyKey=="string"?e.idempotencyKey:"";if(!r)throw new Error("YOLOOP_CONTINUATION_AUTHORITY_INVALID");const c=o.find(a=>a.idempotencyKey===r);if(c){if(l(c)===l(e))return;throw new Error("YOLOOP_CONTINUATION_AUTHORITY_CONFLICT")}if(o.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED");await this.writeState({...s,pendingContinuationAuthorities:[...o,e]})}async acknowledgeContinuationAuthority(e,n,s){const o=await this.readState(s),r=o.pendingContinuationAuthorities??[],c=r.findIndex(h=>h.idempotencyKey===e);if(c<0)throw new Error("YOLOOP_CONTINUATION_RECEIPT_ORPHAN");const a=r[c];if(n.idempotencyKey!==e||n.sessionId!==a.sessionId||JSON.stringify(n.storyIds)!==JSON.stringify(a.storyIds))throw new Error("YOLOOP_CONTINUATION_RECEIPT_MISMATCH");const i=r.filter((h,u)=>u!==c),d=o.continuationReceipts??[];await this.writeState({...o,pendingContinuationAuthorities:i,continuationReceipts:[...d.slice(-63),n]})}async repairContinuationAuthority(e,n,s,o){const r=await this.readState(o),c=r.pendingContinuationAuthorities??[],a=r.continuationRecoveryReceipts??[],i=typeof s.idempotencyKey=="string"?s.idempotencyKey:"",d=a.find(p=>p.idempotencyKey===i);if(d){if(l(d)===l(s))return;throw new Error("YOLOOP_CONTINUATION_RECOVERY_RECEIPT_CONFLICT")}if(c.length!==1||y("p208-legacy-authority-before-v1",c[0])!==e)throw new Error("YOLOOP_CONTINUATION_RECOVERY_BEFORE_MISMATCH");const h=y("p208-legacy-authority-after-v1",n);if(s.beforeHash!==e||s.afterHash!==h)throw new Error("YOLOOP_CONTINUATION_RECOVERY_HASH_MISMATCH");const u={...s};if(delete u.idempotencyKey,!i||y("p208-legacy-authority-recovery-receipt-v1",u)!==i)throw new Error("YOLOOP_CONTINUATION_RECOVERY_RECEIPT_INVALID");await this.writeState({...r,pendingContinuationAuthorities:[n],continuationRecoveryReceipts:[...a.slice(-63),s]})}async switchContinuationPolicy(e,n,s){const o=await this.readState(s);if(o.currentSession||(o.pendingContinuationAuthorities?.length??0)>0)throw new Error("YOLOOP_POLICY_SWITCH_ACTIVE");try{await O.copyFile(this.statePath,`${this.statePath}.policy-backup`)}catch(r){if(r.code!=="ENOENT")throw r}await this.writeState({...o,continuationPolicy:e,continuationSession:n})}async replanContinuationSession(e,n){const{type:s,payloadHash:o,receipt:r,...c}=e;if(ie(c)!==e.payloadHash||!K(e.nextSession))throw new Error("YOLOOP_CONTINUATION_REPLAN_PAYLOAD_INVALID");const a={schemaVersion:"YoloopContinuationReplanReceipt/1.0",operationId:e.operationId,idempotencyKey:e.idempotencyKey,runId:e.expectedRunId,previousSessionHash:e.expectedSessionHash,nextSessionHash:y("p222-yoloop-replan-session-v1",e.nextSession),retiredAuthorityHash:e.expectedPendingHash,retiredAuthorityCount:e.expectedPendingHash===y("p222-yoloop-replan-pending-v1",[])?0:1,payloadHash:e.payloadHash,outcome:"graph-replanned",publicSafe:!0};if(l(e.receipt)!==l(a))throw new Error("YOLOOP_CONTINUATION_REPLAN_RECEIPT_INVALID");const i=await this.readState(n);if(await this.readLock()||i.currentSession!==null)throw new Error("YOLOOP_CONTINUATION_REPLAN_ACTIVE");const d=i.runControl;if(!d||d.runId!==e.expectedRunId||!["success","exhausted"].includes(d.status)||d.revision!==e.expectedRevision||d.latestSnapshotHash!==e.expectedSnapshotHash||i.continuationPolicy!=="p205-finite-resume-v2")throw new Error("YOLOOP_CONTINUATION_REPLAN_RUN_CONFLICT");const h=i.continuationReplanReceipts??[],u=h.find(I=>I.idempotencyKey===e.idempotencyKey||I.operationId===e.operationId);if(u){if(l(u)===l(a)&&l(i.continuationSession)===l(e.nextSession)&&(i.pendingContinuationAuthorities?.length??0)===0)return;throw new Error("YOLOOP_CONTINUATION_REPLAN_REPLAY_CONFLICT")}const p=i.continuationSession,_=i.pendingContinuationAuthorities??[];if(!K(p)||y("p222-yoloop-replan-session-v1",p)!==e.expectedSessionHash||y("p222-yoloop-replan-pending-v1",_)!==e.expectedPendingHash||_.length>1||_.some(I=>I.delegated!==!1||I.sessionId!==p.sessionId||!I.certificate||I.certificate.sessionId!==p.sessionId))throw new Error("YOLOOP_CONTINUATION_REPLAN_AUTHORITY_CONFLICT");await this.writeState({...i,continuationSession:e.nextSession,pendingContinuationAuthorities:[],continuationReplanReceipts:[...h.slice(-63),a]})}async appendRecoveryAttempt(e,n){const s=await this.readState(n),o=s.recovery_history??[];if(o.some(i=>i.attempted_at===e.attempted_at&&i.story_id===e.story_id)){this.logger.warn({event:"recovery_attempted_duplicate",story_id:e.story_id},"duplicate recovery_attempted op; skipping");return}const c=m([...o,e]),a={...s,recovery_history:c};await this.writeState(a),this.logger.info({event:"recovery_attempted_persisted",story_id:e.story_id,history_size:c.length},"recovery attempt persisted")}async resolveRecoveryAttempt(e,n,s){const o=await this.readState(s),r=o.recovery_history??[],c=r.findIndex(h=>h.recovery_step_id_tokenized===e);if(c===-1){this.logger.warn({event:"recovery_resolve_orphan",attempt_id:e},"recovery_resolved op had no matching attempt; skipping");return}const a=r[c];if(a.outcome===n&&a.finished_at){this.logger.info({event:"recovery_resolved_duplicate",attempt_id:e,outcome:n},"duplicate recovery_resolved op; skipping");return}const i=r.slice();i[c]={...a,outcome:n,finished_at:s};const d={...o,recovery_history:i};await this.writeState(d),this.logger.info({event:"recovery_resolved_persisted",attempt_id:e,outcome:n},"recovery attempt resolved")}async acquireLock(e,n){const s={...e,pid:process.pid,hostname:w(),ownerKind:"client",startedAt:e.startedAt||n,leaseId:e.leaseId??j(e.sessionId,n),leaseState:"active",leaseAcquiredAt:e.leaseAcquiredAt??n,leaseDurationMs:e.leaseDurationMs??D,leaseExpiresAt:e.leaseExpiresAt??e.ttlExpiresAt},o=JSON.stringify(s,null,2);try{const r=await O.open(this.lockPath,"wx");try{await r.writeFile(o,"utf8")}finally{await r.close()}this.trySetFilePerms(this.lockPath),this.logger.info({event:"yoloop_lock_acquired",pid:s.pid,sessionId:s.sessionId},"yoloop lock acquired");return}catch(r){const c=r;if(c.code!=="EEXIST")throw this.logger.error({event:"yoloop_lock_acquire_error",err:c.message},"failed to acquire yoloop lock"),r;const a=await this.readLock();if(a&&this.isSameClientSessionLock(a,s)){this.logger.info({event:"yoloop_lock_acquire_duplicate",sessionId:s.sessionId},"duplicate acquire_lock for current yoloop session; treating as no-op");return}if(!await this.maybeRecoverOrphanLock(n))throw this.logger.warn({event:"yoloop_lock_held"},"yoloop lock held by another live process"),r;const d=await O.open(this.lockPath,"wx");try{await d.writeFile(o,"utf8")}finally{await d.close()}this.trySetFilePerms(this.lockPath),this.logger.info({event:"yoloop_lock_acquired_after_recovery",pid:s.pid},"yoloop lock acquired after orphan recovery")}}async releaseLock(e){const n=await this.readState(new Date().toISOString()),s=await this.readLock();if(n.runControl){if(!e.expectedRunId||!e.expectedSessionId||!e.expectedLeaseId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(e.expectedRunId!==n.runControl.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(!s||e.expectedSessionId!==s.sessionId||e.expectedLeaseId!==s.leaseId)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}try{await O.unlink(this.lockPath),this.logger.info({event:"yoloop_lock_released"},"yoloop lock released")}catch(o){const r=o;if(r.code==="ENOENT"){this.logger.info({event:"yoloop_lock_release_noop"},"yoloop lock release noop (file absent)");return}this.logger.warn({event:"yoloop_lock_release_error",err:r.message},"unexpected error releasing yoloop lock")}}async readLock(){try{const e=await O.readFile(this.lockPath,"utf8"),n=JSON.parse(e),s=n.currentEpicId??n.epicId??"";return{pid:n.pid??0,hostname:n.hostname??"",...n.ownerKind?{ownerKind:n.ownerKind}:{},...n.ownerPidState?{ownerPidState:n.ownerPidState}:{},...typeof n.leaseId=="string"?{leaseId:n.leaseId}:{},...n.leaseState?{leaseState:n.leaseState}:{},...typeof n.leaseAcquiredAt=="string"?{leaseAcquiredAt:n.leaseAcquiredAt}:{},...typeof n.leaseDurationMs=="number"?{leaseDurationMs:n.leaseDurationMs}:{},...typeof n.leaseExpiresAt=="string"?{leaseExpiresAt:n.leaseExpiresAt}:{},startedAt:n.startedAt??"",sessionId:n.sessionId??"",mode:n.mode??"epic-id",inputPath:n.inputPath??"",epicQueue:n.epicQueue??(s?[s]:[]),currentEpicIndex:n.currentEpicIndex??0,currentEpicId:s,ttlExpiresAt:n.ttlExpiresAt??"",epicId:n.epicId??s}}catch{return null}}async readState(e){try{const n=await O.readFile(this.statePath,"utf8"),s=JSON.parse(n);return fe(s,e)}catch(n){const s=n;return s.code!=="ENOENT"&&this.logger.warn({event:"yoloop_state_read_error",err:s.message},"yoloop state file unreadable; using defaults"),R(e)}}async writeState(e){const n=`${this.statePath}.tmp.${process.pid}.${Date.now()}`,s=JSON.stringify(e,null,2);try{const o=await O.open(n,"w",384);try{await o.writeFile(s,"utf8"),await o.sync()}finally{await o.close()}this.trySetFilePerms(n),await O.rename(n,this.statePath),await this.syncStateDirectory()}catch(o){const r=o;this.logger.error({event:"yoloop_state_write_error",err:r.message},"failed to write yoloop state");try{await O.unlink(n)}catch{}throw o}}async rolloverSession(e,n){C(e);const s=await this.readRolloverManifest();if(s){if(Oe(s.operation,e)){if(s.operation.payloadHash!==e.payloadHash)throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT");if(s.phase==="applied")return;await this.completePreparedSessionRollover(s.operation,n);return}else if(s.phase==="prepared")throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT")}const o=await this.readState(n),r=await this.readLock();V(e,o,r),await this.writeRolloverManifest({schemaVersion:"YoloopSessionRolloverManifest/1.0",phase:"prepared",operation:e}),await this.completePreparedSessionRollover(e,n)}async completePreparedSessionRollover(e,n){const s=await this.readState(n);let o=l(s)===l(e.next);if(!o){const c=await this.readLock();V(e,s,c),await this.writeState(e.next),o=!0}const r=await this.readLock();if(e.expectedSessionId===null||e.expectedLeaseId===null){if(e.expectedSessionId!==null||e.expectedLeaseId!==null||r)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}else if(r){if(r.sessionId!==e.expectedSessionId||r.leaseId!==e.expectedLeaseId)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");await O.unlink(this.lockPath),await this.syncStateDirectory()}else if(!o)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");await this.writeRolloverManifest({schemaVersion:"YoloopSessionRolloverManifest/1.0",phase:"applied",operation:e}),this.logger.info({event:"yoloop_session_rollover_applied",runId:e.runId,operationId:e.operationId,coolDownUntil:e.coolDownUntil},"yoloop per-run-cap session rollover applied")}async recoverSessionRolloverIfPresent(e){try{await O.access(this.rolloverManifestPath)}catch(n){if(n.code==="ENOENT")return;throw n}await this.ensureDir(),await this.withMutationLock(async()=>{const n=await this.readRolloverManifest();n?.phase==="prepared"&&await this.completePreparedSessionRollover(n.operation,e)})}async readRolloverManifest(){try{const e=JSON.parse(await O.readFile(this.rolloverManifestPath,"utf8"));if(e.schemaVersion!=="YoloopSessionRolloverManifest/1.0"||e.phase!=="prepared"&&e.phase!=="applied"||!e.operation||e.operation.type!=="rollover_session")throw new Error("YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT");return C(e.operation),e}catch(e){if(e.code==="ENOENT")return null;throw e.message==="YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT"?e:new Error("YOLOOP_SESSION_ROLLOVER_MANIFEST_CORRUPT")}}async writeRolloverManifest(e){const n=`${this.rolloverManifestPath}.tmp.${process.pid}.${Date.now()}`,s=await O.open(n,"w",384);try{await s.writeFile(JSON.stringify(e,null,2),"utf8"),await s.sync()}finally{await s.close()}this.trySetFilePerms(n),await O.rename(n,this.rolloverManifestPath),await this.syncStateDirectory()}async commitSuccessorTransition(e,n){E(e);const s=await this.readSuccessorTransitionManifest();if(s){if(ue(s.operation,e)){if(s.operation.payloadHash!==e.payloadHash)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT");if(s.phase==="applied")return;await this.completePreparedSuccessorTransition(s.operation,n);return}if(s.phase==="prepared")throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT")}const o=await this.readState(n);if(T(e,o)){await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"applied",operation:e});return}M(e,o),await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"prepared",operation:e}),await this.completePreparedSuccessorTransition(e,n)}async completePreparedSuccessorTransition(e,n){let s=await this.readState(n);if(!T(e,s)){l(s.runControl)===l(e.successor.next)||(await this.startSuccessorRun(e.successor,n),s=await this.readState(n));const r=pe(e,s);await this.writeState(r),s=await this.readState(n)}if(!T(e,s))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT");await this.writeSuccessorTransitionManifest({schemaVersion:"YoloopSuccessorTransitionManifest/1.0",phase:"applied",operation:e}),this.logger.info({event:"yoloop_successor_transition_applied",runId:e.successor.runId,operationId:e.operationId},"yoloop successor transition applied")}async recoverSuccessorTransitionIfPresent(e){try{await O.access(this.successorTransitionManifestPath)}catch(n){if(n.code==="ENOENT")return;throw n}await this.ensureDir(),await this.withMutationLock(async()=>{const n=await this.readSuccessorTransitionManifest();n?.phase==="prepared"&&await this.completePreparedSuccessorTransition(n.operation,e)})}async readSuccessorTransitionManifest(){try{const e=JSON.parse(await O.readFile(this.successorTransitionManifestPath,"utf8"));if(e.schemaVersion!=="YoloopSuccessorTransitionManifest/1.0"||e.phase!=="prepared"&&e.phase!=="applied"||!e.operation||e.operation.type!=="commit_successor_transition")throw new Error("YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT");return E(e.operation),e}catch(e){if(e.code==="ENOENT")return null;throw e.message==="YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT"?e:new Error("YOLOOP_SUCCESSOR_TRANSITION_MANIFEST_CORRUPT")}}async writeSuccessorTransitionManifest(e){const n=`${this.successorTransitionManifestPath}.tmp.${process.pid}.${Date.now()}`,s=await O.open(n,"w",384);try{await s.writeFile(JSON.stringify(e,null,2),"utf8"),await s.sync()}finally{await s.close()}this.trySetFilePerms(n),await O.rename(n,this.successorTransitionManifestPath),await this.syncStateDirectory()}async advanceEpic(e,n,s){const o=await this.readState(s);if(!o.currentSession){this.logger.warn({event:"yoloop_advance_no_session",nextEpicId:n},"advance_epic with no active session; skipping");return}const r={...o,currentSession:{...o.currentSession,currentEpicIndex:e}};await this.writeState(r),this.logger.info({event:"yoloop_epic_advance",nextEpicId:n,nextIndex:e},"yoloop advanced to next epic")}async ensureDir(){try{await O.mkdir(this.stateDir,{recursive:!0}),this.trySetDirPerms(this.stateDir)}catch(e){const n=e;throw this.logger.error({event:"yoloop_mkdir_error",err:n.message,path:this.stateDir},"failed to ensure .neocortex/ directory"),e}}async syncStateDirectory(){try{const e=await O.open(this.stateDir,"r");try{await e.sync()}finally{await e.close()}}catch(e){const n=e.code;if(n!=="EINVAL"&&n!=="EISDIR"&&n!=="ENOTSUP"&&n!=="EPERM")throw e}}async withMutationLock(e){let n=null;for(let s=0;s<oe;s+=1)try{n=await O.open(this.mutationLockPath,"wx",384),await n.writeFile(JSON.stringify({pid:process.pid,acquiredAt:new Date().toISOString()})),await n.sync();break}catch(o){if(o.code!=="EEXIST")throw o;try{const r=await O.stat(this.mutationLockPath);if(Date.now()-r.mtimeMs>se){await O.unlink(this.mutationLockPath);continue}}catch{continue}await new Promise(r=>setTimeout(r,5))}if(!n)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");try{return await e()}finally{await n.close().catch(()=>{}),await O.unlink(this.mutationLockPath).catch(()=>{})}}async transitionRun(e,n){v(e);const s=await this.readState(n),o=s.runControl,r=o?.receipts.find(d=>d.operationId===e.operationId||d.idempotencyKey===e.idempotencyKey);if(r){if(r.operationId===e.operationId&&r.idempotencyKey===e.idempotencyKey&&r.payloadHash===e.payloadHash)return r;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}if(o&&o.runId!==e.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if((o?.revision??0)!==e.expectedRevision)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if((o?.latestSnapshotHash??null)!==e.expectedSnapshotHash)throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT");L(o,e);const i=e.next.receipts.at(-1);if(!i)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");return await this.writeState({...s,version:"1.2",runControl:e.next}),this.logger.info({event:"yoloop_run_transition_committed",runId:e.runId,operationId:e.operationId,revision:i.revision,status:i.status},"yoloop run transition committed"),i}async startSuccessorRun(e,n){b(e);const s=await this.readState(n),o=s.runControl,r={schemaVersion:"YoloopRunPredecessor/1.0",runId:e.previousRunId,revision:e.expectedRevision,snapshotHash:e.expectedSnapshotHash,successorOperationId:e.operationId,publicSafe:!0},c=o?.receipts.find(d=>d.operationId===e.operationId||d.idempotencyKey===e.idempotencyKey);if(c){if(c.operationId===e.operationId&&c.idempotencyKey===e.idempotencyKey&&e.next.receipts.at(-1)?.payloadHash===c.payloadHash&&l(o?.predecessor)===l(r))return c;throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}if(!o||o.runId!==e.previousRunId||!["exhausted","success"].includes(o.status)||o.revision!==e.expectedRevision||o.latestSnapshotHash!==e.expectedSnapshotHash||s.currentSession!==null)throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT");const a=e.next.receipts.at(-1);if(!a||l(e.next.predecessor)!==l(r))throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT");const i={type:"transition_run",runId:e.runId,operationId:e.operationId,idempotencyKey:e.idempotencyKey,expectedRevision:0,expectedSnapshotHash:null,payloadHash:a.payloadHash,next:e.next};return v(i),L(void 0,i),await this.writeState({...s,version:"1.2",runControl:e.next}),a}trySetFilePerms(e){try{G(e)}catch{}}trySetDirPerms(e){try{Z(e)}catch{}}async prepareLockForSnapshot(e,n){if(!e)return null;if(e.ownerKind!=="client")return e;const s=B(e,n),o={...e,leaseState:s};if(s==="expired")return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"lease_expired"},"expired client-owned yoloop lock lease recovered before snapshot echo"),null;if(!o.pid||o.pid<=0)return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"invalid_pid"},"invalid PID in client-owned yoloop lock; recovered"),null;if(o.hostname&&o.hostname!==w())return{...o,ownerPidState:"unknown"};try{return process.kill(o.pid,0),{...o,ownerPidState:"live"}}catch{return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_client_lock_recovered",reason:"dead_pid"},"stale client-owned yoloop lock recovered before snapshot echo"),null}}async maybeRecoverOrphanLock(e){const n=await this.readLock();if(!n)return!0;if(B(n,e)==="expired")return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"lease_expired"},"expired yoloop lock lease recovered"),!0;if(!n.pid||n.pid<=0)return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"invalid_pid"},"invalid PID in lock file; recovered"),!0;if(n.hostname&&n.hostname!==w())return this.logger.warn({event:"yoloop_lock_remote_host",ownerKind:n.ownerKind??"server",ownerPidState:"unknown"},"lock held by a remote host; honoring"),!1;try{return process.kill(n.pid,0),!1}catch{return await this.deleteLockBestEffort(),this.logger.warn({event:"yoloop_orphan_lock_recovered",reason:"dead_pid"},"orphan yoloop lock recovered"),!0}}async deleteLockBestEffort(){try{await O.unlink(this.lockPath)}catch{}}isSameClientSessionLock(e,n){return e.ownerKind==="client"&&e.sessionId===n.sessionId&&e.pid===process.pid&&e.hostname===w()}async cleanupOrphanTempFiles(){try{const e=W(this.statePath),n=await O.readdir(e),s=[`${Y}.tmp.`,`${H}.tmp.`,`${U}.tmp.`],o=n.filter(r=>s.some(c=>r.startsWith(c)));for(const r of o)try{await O.unlink(S(e,r))}catch{}}catch{}}}function l(t){return t===null||typeof t!="object"?JSON.stringify(t):Array.isArray(t)?`[${t.map(l).join(",")}]`:`{${Object.entries(t).filter(([,e])=>e!==void 0).sort(([e],[n])=>e.localeCompare(n)).map(([e,n])=>`${JSON.stringify(e)}:${l(n)}`).join(",")}}`}function y(t,e){return X("sha256").update(`${t}\0${l(e)}`).digest("hex")}function ie(t){return y("p222-yoloop-continuation-replan-v1",t)}function K(t){if(!t||typeof t!="object"||Array.isArray(t))return!1;const{sessionId:e,...n}=t;return typeof e=="string"&&/^[a-f0-9]{64}$/.test(e)&&e===y("p205-yoloop-session-v1",n)}function ae(t){return y("p9390812898200217364-yoloop-session-v1",t)}function ce(t){return y("p9390812898200217364-yoloop-rollover-v1",t)}function de(t){return y("p9390812898200217364-yoloop-successor-run-v1",t)}function le(t){return y("p9046281030721413419-yoloop-successor-transition-v1",t)}function ue(t,e){return t.operationId===e.operationId&&t.idempotencyKey===e.idempotencyKey}function E(t){const e=/^[A-Za-z0-9._:-]{1,128}$/;b(t.successor);const n={operationId:t.operationId,idempotencyKey:t.idempotencyKey,successor:t.successor,state:t.state,...t.acknowledgement?{acknowledgement:t.acknowledgement}:{},...t.reservation?{reservation:t.reservation}:{}},s=t.acknowledgement?.idempotencyKey,o=t.acknowledgement?.receipt.idempotencyKey,r=t.reservation?.authority.idempotencyKey;if(t.successor.type!=="start_successor_run"||!e.test(t.operationId)||!e.test(t.idempotencyKey)||t.operationId!==t.successor.operationId||t.idempotencyKey!==t.successor.idempotencyKey||!/^[a-f0-9]{64}$/.test(t.payloadHash)||t.payloadHash!==le(n)||Buffer.byteLength(l(n),"utf8")>1048576||t.acknowledgement!==void 0&&(t.acknowledgement.type!=="acknowledge_continuation_authority"||typeof s!="string"||!e.test(s)||o!==s)||t.reservation!==void 0&&(t.reservation.type!=="reserve_continuation_authority"||typeof r!="string"||!e.test(r))||s!==void 0&&r===s)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_CONFLICT")}function M(t,e){if(E(t),!(l(e.runControl)===l(t.successor.next))){const s=e.runControl;if(!s||s.runId!==t.successor.previousRunId||!["exhausted","success"].includes(s.status)||s.revision!==t.successor.expectedRevision||s.latestSnapshotHash!==t.successor.expectedSnapshotHash||e.currentSession!==null)throw new Error("YOLOOP_SUCCESSOR_TRANSITION_STATE_CONFLICT")}he(t,e)}function he(t,e){const n=e.pendingContinuationAuthorities??[],s=e.continuationReceipts??[],o=t.acknowledgement;if(o){const d=n.find(p=>p.idempotencyKey===o.idempotencyKey);if(!d||o.receipt.sessionId!==d.sessionId||l(o.receipt.storyIds)!==l(d.storyIds))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");const h=n.filter(p=>p.idempotencyKey!==o.idempotencyKey),u=[...s.slice(-63),o.receipt];if(l(t.state.pendingContinuationAuthorities??[])!==l(h)||l(t.state.continuationReceipts??[])!==l(u))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT")}else if(l(t.state.pendingContinuationAuthorities??[])!==l(n)||l(t.state.continuationReceipts??[])!==l(s))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");const r=t.reservation?.authority;if(!r)return;const c=String(r.idempotencyKey??""),a=t.state.pendingContinuationAuthorities??[],i=a.find(d=>d.idempotencyKey===c);if(i&&l(i)!==l(r))throw new Error("YOLOOP_SUCCESSOR_TRANSITION_AUTHORITY_CONFLICT");if(!i&&a.length>=64)throw new Error("YOLOOP_PENDING_AUTHORITY_CAPACITY_EXCEEDED")}function pe(t,e){M(t,e);const n=t.reservation?.authority,s=[...t.state.pendingContinuationAuthorities??[]];return n&&!s.some(o=>o.idempotencyKey===n.idempotencyKey)&&s.push(n),{...t.state,version:"1.2",runControl:t.successor.next,...n||t.state.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:s}:{}}}function T(t,e){const n=t.reservation?.authority,s=[...t.state.pendingContinuationAuthorities??[]];n&&!s.some(r=>r.idempotencyKey===n.idempotencyKey)&&s.push(n);const o={...t.state,version:"1.2",runControl:t.successor.next,...n||t.state.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:s}:{}};return l(e)===l(o)}function Oe(t,e){return t.operationId===e.operationId&&t.idempotencyKey===e.idempotencyKey}function C(t){const e=/^[A-Za-z0-9._:-]{1,128}$/,n={runId:t.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,expectedSessionId:t.expectedSessionId,expectedSessionFingerprint:t.expectedSessionFingerprint,expectedLeaseId:t.expectedLeaseId,coolDownUntil:t.coolDownUntil,reason:t.reason,exhaustedRun:t.exhaustedRun,next:t.next};if(!e.test(t.runId)||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.expectedSessionFingerprint)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<0||!Number.isFinite(Date.parse(t.coolDownUntil))||!["YOLOOP_PER_RUN_CAP_REACHED","YOLOOP_SESSION_TIMEOUT"].includes(t.reason)||t.payloadHash!==ce(n)||t.receipt.schemaVersion!=="YoloopSessionRolloverReceipt/1.0"||t.receipt.runId!==t.runId||t.receipt.operationId!==t.operationId||t.receipt.idempotencyKey!==t.idempotencyKey||t.receipt.payloadHash!==t.payloadHash||t.receipt.coolDownUntil!==t.coolDownUntil||t.receipt.revision!==(t.exhaustedRun?.revision??0)||t.receipt.snapshotHash!==(t.exhaustedRun?.latestSnapshotHash??null)||t.receipt.status!=="exhausted"||t.receipt.publicSafe!==!0)throw new Error("YOLOOP_SESSION_ROLLOVER_CONFLICT");if(t.expectedSessionId===null!=(t.expectedLeaseId===null)||t.expectedSessionId!==null&&!e.test(t.expectedSessionId)||t.expectedLeaseId!==null&&!e.test(t.expectedLeaseId))throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}function b(t){const e=/^[A-Za-z0-9._:-]{1,128}$/,n={runId:t.runId,previousRunId:t.previousRunId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,next:t.next};if(!e.test(t.runId)||!e.test(t.previousRunId)||t.runId===t.previousRunId||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<1||t.payloadHash!==de(n))throw new Error("YOLOOP_RUN_SUCCESSOR_CONFLICT")}function V(t,e,n){C(t);const s=e.currentSession;if(!s||ae(s)!==t.expectedSessionFingerprint)throw new Error("YOLOOP_SESSION_ROLLOVER_SESSION_CONFLICT");const o=e.runControl;if((o?.revision??0)!==t.expectedRevision||(o?.latestSnapshotHash??null)!==t.expectedSnapshotHash||o&&o.runId!==t.runId)throw new Error("YOLOOP_RUN_REVISION_CONFLICT");if(t.expectedSessionId===null||t.expectedLeaseId===null){if(n)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT")}else if(!n||n.sessionId!==t.expectedSessionId||n.leaseId!==t.expectedLeaseId)throw new Error("YOLOOP_SESSION_ROLLOVER_LOCK_CONFLICT");const r=t.exhaustedRun;if(o)if(o.status==="exhausted"){if(l(r)!==l(o))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}else{if(!r||r.status!=="exhausted")throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT");const a=r.receipts.at(-1);if(!a)throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT");const i={type:"transition_run",runId:o.runId,operationId:a.operationId,idempotencyKey:a.idempotencyKey,expectedRevision:o.revision,expectedSnapshotHash:o.latestSnapshotHash,payloadHash:a.payloadHash,next:r};v(i),L(o,i)}else{const a=r?.receipts[0],i=r?.receipts[1];if(r&&(r.runId!==t.runId||r.status!=="exhausted"||r.revision!==2||r.events.length!==2||r.progressSnapshots.length!==4||r.receipts.length!==2||a?.runId!==t.runId||a.revision!==1||a.status!=="created"||i?.runId!==t.runId||i.revision!==2||i.status!=="exhausted"||i.operationId!==`${t.operationId}:run`||i.idempotencyKey!==`${t.idempotencyKey}:run`||r.latestSnapshotHash!==i.snapshotHash||r.predecessor!==void 0))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}const c={...e,version:"1.2",coolDownUntil:t.coolDownUntil,currentSession:null,...r?{runControl:r}:{}};if(l(c)!==l(t.next))throw new Error("YOLOOP_SESSION_ROLLOVER_STATE_CONFLICT")}function $(t){const{receipts:e,...n}=t.next;return y("p220-yoloop-run-transition-payload-v1",{runId:t.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.expectedRevision,expectedSnapshotHash:t.expectedSnapshotHash,next:n})}const N=new Set(["success","partial","blocked","failure","exhausted","cancelled"]),ye={created:["running","cancelling","exhausted"],running:["pausing","cancelling","success","partial","blocked","failure","exhausted"],pausing:["paused","cancelling","failure","exhausted"],paused:["resuming","cancelling","exhausted"],resuming:["running","cancelling","failure","exhausted"],cancelling:["cancelled","failure","exhausted"]};function _e(t,e){return e==="pause"?t==="running"?"pausing":null:e==="resume"?t==="paused"?"resuming":null:N.has(t)||t==="cancelling"?null:"cancelling"}function Ie(t){const e=t.current.revision+1,n=t.action==="cancel"?["YOLOOP_RUN_CANCEL_REQUESTED"]:[],s={schemaVersion:"YoloopOperationalBudgetSummary/1.0",limits:t.current.operationalBudget.limits,usage:t.current.usage,exhaustedDimension:null},o={schemaVersion:"ProgressSnapshot/1.0",runId:t.current.runId,sequence:t.current.progressSnapshots.length+1,revision:e,operationId:t.operationId,boundary:t.action,phase:"before",status:t.current.status,occurredAt:t.occurredAt,parentSnapshotHash:t.current.latestSnapshotHash,budget:s,reasonCodes:n,publicSafe:!0},r=y("p220-yoloop-progress-snapshot-id-v1",{runId:o.runId,operationId:o.operationId,sequence:o.sequence,phase:o.phase}),c={...o,snapshotId:r,snapshotHash:y("p220-yoloop-progress-snapshot-v1",{...o,snapshotId:r})},a={...o,sequence:c.sequence+1,phase:"after",status:t.target,parentSnapshotHash:c.snapshotHash},i=y("p220-yoloop-progress-snapshot-id-v1",{runId:a.runId,operationId:a.operationId,sequence:a.sequence,phase:a.phase}),d={...a,snapshotId:i,snapshotHash:y("p220-yoloop-progress-snapshot-v1",{...a,snapshotId:i})},h={schemaVersion:"YoloopRunEvent/1.0",runId:t.current.runId,operationId:t.operationId,kind:"intent",sequence:t.current.events.length+1,revision:e,occurredAt:t.occurredAt,fromStatus:t.current.status,toStatus:t.target,reasonCodes:n,budgetDimension:null,parentEventHash:t.current.events.at(-1)?.eventHash??null,publicSafe:!0},u=y("p220-yoloop-run-event-id-v1",{runId:h.runId,operationId:h.operationId,sequence:h.sequence}),p={...h,eventId:u,eventHash:y("p220-yoloop-run-event-v1",{...h,eventId:u})},_={...t.current,revision:e,status:t.target,events:[...t.current.events,p],progressSnapshots:[...t.current.progressSnapshots,c,d],latestSnapshotHash:d.snapshotHash,receipts:[...t.current.receipts]},I={runId:t.current.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,expectedRevision:t.current.revision,expectedSnapshotHash:t.current.latestSnapshotHash,next:_},g=$(I),J={schemaVersion:"YoloopRunTransitionReceipt/1.0",runId:t.current.runId,operationId:t.operationId,idempotencyKey:t.idempotencyKey,payloadHash:g,revision:e,snapshotHash:d.snapshotHash,status:t.target};return{type:"transition_run",...I,payloadHash:g,next:{..._,receipts:[..._.receipts,J]}}}function v(t){const e=/^[A-Za-z0-9._:-]{1,128}$/;if(!e.test(t.runId)||!e.test(t.operationId)||!e.test(t.idempotencyKey)||!/^[a-f0-9]{64}$/.test(t.payloadHash)||!Number.isSafeInteger(t.expectedRevision)||t.expectedRevision<0)throw new Error("YOLOOP_RUN_OPERATION_CONFLICT");if(t.payloadHash!==$(t))throw new Error("YOLOOP_RUN_OPERATION_CONFLICT")}function L(t,e){const n=e.next,s=t?.events??[],o=t?.progressSnapshots??[],r=t?.receipts??[];if(n.schemaVersion!=="YoloopRunControl/1.0"||n.runId!==e.runId||n.revision!==e.expectedRevision+1||n.events.length!==s.length+1||n.progressSnapshots.length!==o.length+2||n.receipts.length!==r.length+1||l(n.events.slice(0,s.length))!==l(s)||l(n.progressSnapshots.slice(0,o.length))!==l(o)||l(n.receipts.slice(0,r.length))!==l(r))throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(t){if(N.has(t.status))throw new Error("YOLOOP_RUN_TERMINAL");const h=n.progressSnapshots.at(-2)?.boundary,u=t.status===n.status&&(h==="budget"||h==="retry");if(!(ye[t.status]??[]).includes(n.status)&&!u)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(t.startedAt!==n.startedAt||t.deadlineAt!==n.deadlineAt)throw new Error("YOLOOP_RUN_TRANSITION_INVALID");if(l(t.predecessor)!==l(n.predecessor))throw new Error("YOLOOP_RUN_TRANSITION_INVALID")}else if(n.status!=="created")throw new Error("YOLOOP_RUN_TRANSITION_INVALID");const c=n.progressSnapshots.at(-2),a=n.progressSnapshots.at(-1),i=n.events.at(-1),d=n.receipts.at(-1);if(!c||!a||!i||!d||c.phase!=="before"||a.phase!=="after"||c.operationId!==e.operationId||a.operationId!==e.operationId||i.operationId!==e.operationId||c.runId!==e.runId||a.runId!==e.runId||i.runId!==e.runId||c.revision!==n.revision||a.revision!==n.revision||i.revision!==n.revision||c.parentSnapshotHash!==(t?.latestSnapshotHash??null)||a.parentSnapshotHash!==c.snapshotHash||n.latestSnapshotHash!==a.snapshotHash||d.schemaVersion!=="YoloopRunTransitionReceipt/1.0"||d.runId!==e.runId||d.operationId!==e.operationId||d.idempotencyKey!==e.idempotencyKey||d.payloadHash!==e.payloadHash||d.revision!==n.revision||d.snapshotHash!==n.latestSnapshotHash||d.status!==n.status||Date.parse(n.deadlineAt)<=Date.parse(n.startedAt))throw new Error("YOLOOP_RUN_TRANSITION_INVALID");q(c),q(a),Se(i,s.at(-1)?.eventHash??null)}function q(t){const{snapshotHash:e,...n}=t;if(e!==y("p220-yoloop-progress-snapshot-v1",n))throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT")}function Se(t,e){const{eventHash:n,...s}=t;if(t.parentEventHash!==e||n!==y("p220-yoloop-run-event-v1",s))throw new Error("YOLOOP_RUN_SNAPSHOT_CONFLICT")}function j(t,e){return`lease-${we(`${t}:${e}`)}`}function we(t){let e=2166136261;for(let n=0;n<t.length;n+=1)e^=t.charCodeAt(n),e=Math.imul(e,16777619)>>>0;return e.toString(36).padStart(7,"0")}function B(t,e){const n=t.leaseExpiresAt??t.ttlExpiresAt,s=Date.parse(n),o=Date.parse(e);return!Number.isFinite(s)||!Number.isFinite(o)?"unknown":s<=o?"expired":"active"}function fe(t,e){const n=R(e);return{...t,version:t.version==="1.2"?"1.2":"1.1",lastResetAt:t.lastResetAt??n.lastResetAt,storiesRunToday:t.storiesRunToday??0,iterationsToday:t.iterationsToday??0,iterationsTodayDate:t.iterationsTodayDate??n.iterationsTodayDate,tokensUsedToday:t.tokensUsedToday??0,lastRunAt:t.lastRunAt??null,coolDownUntil:t.coolDownUntil??null,totalRunsAllTime:t.totalRunsAllTime??0,totalStoriesAllTime:t.totalStoriesAllTime??0,currentSession:t.currentSession??null,...t.runControl!==void 0?{runControl:t.runControl}:{},...t.continuationPolicy!==void 0?{continuationPolicy:t.continuationPolicy}:{},...t.continuationSession!==void 0?{continuationSession:t.continuationSession}:{},...t.pendingContinuationAuthorities!==void 0?{pendingContinuationAuthorities:t.pendingContinuationAuthorities}:{},...t.continuationReceipts!==void 0?{continuationReceipts:t.continuationReceipts}:{},...t.continuationRecoveryReceipts!==void 0?{continuationRecoveryReceipts:t.continuationRecoveryReceipts}:{},...t.continuationReplanReceipts!==void 0?{continuationReplanReceipts:t.continuationReplanReceipts}:{},recovery_history:Array.isArray(t.recovery_history)?m(t.recovery_history):[]}}function m(t){return t.length<=A?t:t.slice(t.length-A)}export{F as YOLOOP_PERSISTENCE_CAPABILITIES,Ae as YoloopClientStateStore,ie as buildYoloopContinuationReplanPayloadHash,$ as buildYoloopRunTransitionPayloadHash,ce as buildYoloopSessionRolloverPayloadHash,de as buildYoloopSuccessorRunPayloadHash,le as buildYoloopSuccessorTransitionPayloadHash,m as capRecoveryHistory,R as createDefaultCounters,ge as createDefaultState,ae as fingerprintYoloopSession};