codex-workflow-v2 2.0.0-beta.13.8 → 2.0.0-beta.14

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 (281) hide show
  1. package/README.md +27 -388
  2. package/dist/reviewer-runtime-build.json +96 -32
  3. package/dist/src/alpha6/adoption.d.ts +2 -0
  4. package/dist/src/alpha6/adoption.js +22 -0
  5. package/dist/src/alpha6/adoption.js.map +1 -1
  6. package/dist/src/alpha6/captured-check-evidence.d.ts +51 -0
  7. package/dist/src/alpha6/captured-check-evidence.js +152 -0
  8. package/dist/src/alpha6/captured-check-evidence.js.map +1 -0
  9. package/dist/src/alpha6/component-owner.d.ts +15 -1
  10. package/dist/src/alpha6/component-owner.js +44 -2
  11. package/dist/src/alpha6/component-owner.js.map +1 -1
  12. package/dist/src/alpha6/corrective-decision-boundary.d.ts +4 -0
  13. package/dist/src/alpha6/corrective-decision-boundary.js +47 -0
  14. package/dist/src/alpha6/corrective-decision-boundary.js.map +1 -0
  15. package/dist/src/alpha6/downstream-proof.d.ts +1 -25
  16. package/dist/src/alpha6/downstream-proof.js +0 -171
  17. package/dist/src/alpha6/downstream-proof.js.map +1 -1
  18. package/dist/src/alpha6/failed-step-planning-recovery.d.ts +10 -0
  19. package/dist/src/alpha6/failed-step-planning-recovery.js +93 -0
  20. package/dist/src/alpha6/failed-step-planning-recovery.js.map +1 -0
  21. package/dist/src/alpha6/literal-test-invocation.d.ts +2 -0
  22. package/dist/src/alpha6/literal-test-invocation.js +106 -0
  23. package/dist/src/alpha6/literal-test-invocation.js.map +1 -0
  24. package/dist/src/alpha6/milestone.d.ts +2 -0
  25. package/dist/src/alpha6/milestone.js +10 -1
  26. package/dist/src/alpha6/milestone.js.map +1 -1
  27. package/dist/src/alpha6/navigation-dirty-carryover.d.ts +11 -0
  28. package/dist/src/alpha6/navigation-dirty-carryover.js +130 -0
  29. package/dist/src/alpha6/navigation-dirty-carryover.js.map +1 -0
  30. package/dist/src/alpha6/plan-integrity.js +7 -5
  31. package/dist/src/alpha6/plan-integrity.js.map +1 -1
  32. package/dist/src/alpha6/plan-risk.d.ts +2 -0
  33. package/dist/src/alpha6/plan-risk.js +10 -3
  34. package/dist/src/alpha6/plan-risk.js.map +1 -1
  35. package/dist/src/alpha6/remediation.d.ts +6 -21
  36. package/dist/src/alpha6/remediation.js +161 -476
  37. package/dist/src/alpha6/remediation.js.map +1 -1
  38. package/dist/src/alpha6/root-cause-replan-carryover.d.ts +4 -1
  39. package/dist/src/alpha6/root-cause-replan-carryover.js +29 -5
  40. package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -1
  41. package/dist/src/beta1/project-transaction.d.ts +3 -1
  42. package/dist/src/beta1/project-transaction.js +6 -2
  43. package/dist/src/beta1/project-transaction.js.map +1 -1
  44. package/dist/src/checks/runner.d.ts +26 -0
  45. package/dist/src/checks/runner.js +223 -0
  46. package/dist/src/checks/runner.js.map +1 -0
  47. package/dist/src/checks/task-sync.d.ts +10 -0
  48. package/dist/src/checks/task-sync.js +41 -0
  49. package/dist/src/checks/task-sync.js.map +1 -0
  50. package/dist/src/checks/worker.d.ts +1 -0
  51. package/dist/src/checks/worker.js +154 -0
  52. package/dist/src/checks/worker.js.map +1 -0
  53. package/dist/src/cli-actions.d.ts +2 -2
  54. package/dist/src/cli-actions.js +3 -15
  55. package/dist/src/cli-actions.js.map +1 -1
  56. package/dist/src/cli.js +14 -98
  57. package/dist/src/cli.js.map +1 -1
  58. package/dist/src/contracts.d.ts +35 -32
  59. package/dist/src/dependency-provenance.d.ts +2 -2
  60. package/dist/src/dependency-provenance.js +12 -69
  61. package/dist/src/dependency-provenance.js.map +1 -1
  62. package/dist/src/domain/base-sync-conflict.d.ts +13 -0
  63. package/dist/src/domain/base-sync-conflict.js +32 -0
  64. package/dist/src/domain/base-sync-conflict.js.map +1 -0
  65. package/dist/src/domain/step-start-admission.d.ts +4 -0
  66. package/dist/src/domain/step-start-admission.js +9 -0
  67. package/dist/src/domain/step-start-admission.js.map +1 -0
  68. package/dist/src/domain/virgin-registration.d.ts +3 -0
  69. package/dist/src/domain/virgin-registration.js +65 -0
  70. package/dist/src/domain/virgin-registration.js.map +1 -0
  71. package/dist/src/errors.d.ts +1 -1
  72. package/dist/src/errors.js.map +1 -1
  73. package/dist/src/gateway-handshake.js +0 -8
  74. package/dist/src/gateway-handshake.js.map +1 -1
  75. package/dist/src/git.d.ts +3 -1
  76. package/dist/src/git.js +41 -14
  77. package/dist/src/git.js.map +1 -1
  78. package/dist/src/graph.js +25 -3
  79. package/dist/src/graph.js.map +1 -1
  80. package/dist/src/index.d.ts +1 -0
  81. package/dist/src/lifecycle/corrective-replan.js +3 -3
  82. package/dist/src/lifecycle/corrective-replan.js.map +1 -1
  83. package/dist/src/navigation-actions.d.ts +9 -0
  84. package/dist/src/navigation-actions.js +72 -0
  85. package/dist/src/navigation-actions.js.map +1 -0
  86. package/dist/src/navigation-update-artifact.d.ts +5 -0
  87. package/dist/src/navigation-update-artifact.js +227 -0
  88. package/dist/src/navigation-update-artifact.js.map +1 -0
  89. package/dist/src/navigation-update.d.ts +39 -0
  90. package/dist/src/navigation-update.js +82 -0
  91. package/dist/src/navigation-update.js.map +1 -0
  92. package/dist/src/observation.js +52 -29
  93. package/dist/src/observation.js.map +1 -1
  94. package/dist/src/observed-routes.js +5 -6
  95. package/dist/src/observed-routes.js.map +1 -1
  96. package/dist/src/pending-review-update.d.ts +0 -13
  97. package/dist/src/pending-review-update.js +1 -6
  98. package/dist/src/pending-review-update.js.map +1 -1
  99. package/dist/src/reviewer.d.ts +1 -1
  100. package/dist/src/reviewer.js +20 -17
  101. package/dist/src/reviewer.js.map +1 -1
  102. package/dist/src/runtime.d.ts +3 -0
  103. package/dist/src/runtime.js +6 -0
  104. package/dist/src/runtime.js.map +1 -0
  105. package/dist/src/state/corrective-replan-executor.d.ts +10 -0
  106. package/dist/src/state/corrective-replan-executor.js +37 -1
  107. package/dist/src/state/corrective-replan-executor.js.map +1 -1
  108. package/dist/src/state/corrective-replan-public-schema.js +15 -2
  109. package/dist/src/state/corrective-replan-public-schema.js.map +1 -1
  110. package/dist/src/state/corrective-replan-public.js +6 -5
  111. package/dist/src/state/corrective-replan-public.js.map +1 -1
  112. package/dist/src/state/corrective-replan-transaction.d.ts +1 -0
  113. package/dist/src/state/corrective-replan-transaction.js +12 -11
  114. package/dist/src/state/corrective-replan-transaction.js.map +1 -1
  115. package/dist/src/state/corrective-yield-executor.js +2 -2
  116. package/dist/src/state/corrective-yield-executor.js.map +1 -1
  117. package/dist/src/state/corrective-yield-transaction.d.ts +1 -0
  118. package/dist/src/state/corrective-yield-transaction.js +11 -10
  119. package/dist/src/state/corrective-yield-transaction.js.map +1 -1
  120. package/dist/src/state/lock.d.ts +4 -0
  121. package/dist/src/state/lock.js +21 -0
  122. package/dist/src/state/lock.js.map +1 -1
  123. package/dist/src/version.d.ts +1 -1
  124. package/dist/src/version.js +1 -1
  125. package/dist/src/version.js.map +1 -1
  126. package/dist/src/workflow-blocker-route.d.ts +8 -0
  127. package/dist/src/workflow-blocker-route.js +106 -0
  128. package/dist/src/workflow-blocker-route.js.map +1 -0
  129. package/dist/src/workflow.d.ts +48 -63
  130. package/dist/src/workflow.js +698 -1470
  131. package/dist/src/workflow.js.map +1 -1
  132. package/docs/autonomy-guardrails.md +26 -304
  133. package/docs/decisions.md +14 -112
  134. package/docs/development-flow.md +35 -238
  135. package/docs/project-memory.md +31 -50
  136. package/docs/release-app-evidence.md +190 -0
  137. package/docs/release.md +95 -363
  138. package/docs/updating-existing-project.md +19 -717
  139. package/package.json +11 -13
  140. package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +2 -2
  141. package/plugins/codex-workflow-gateway/references/chat-dispatch.md +73 -197
  142. package/plugins/codex-workflow-gateway/references/codebase-memory-routing.md +57 -0
  143. package/plugins/codex-workflow-gateway/references/protocol.md +42 -445
  144. package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +7 -1
  145. package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +10 -8
  146. package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +23 -5
  147. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +66 -740
  148. package/references/state-machine.md +4 -4
  149. package/roles/technical-planner.md +1 -1
  150. package/schemas/corrective-decision-event.schema.json +3 -1
  151. package/schemas/project-knowledge-map.schema.json +3 -1
  152. package/schemas/remediation-event.schema.json +10 -1
  153. package/schemas/task.schema.json +46 -2
  154. package/schemas/transition-payloads.schema.json +16 -1
  155. package/src/alpha6/adoption.ts +1123 -0
  156. package/src/alpha6/captured-check-evidence.ts +132 -0
  157. package/src/alpha6/check-support-anchor.ts +128 -0
  158. package/src/alpha6/component-owner.ts +168 -0
  159. package/src/alpha6/corrective-decision-boundary.ts +39 -0
  160. package/src/alpha6/downstream-proof.ts +520 -0
  161. package/src/alpha6/failed-step-planning-recovery.ts +88 -0
  162. package/src/alpha6/handoff.ts +1443 -0
  163. package/src/alpha6/journal.ts +473 -0
  164. package/src/alpha6/literal-test-invocation.ts +84 -0
  165. package/src/alpha6/mechanical-feasibility.ts +488 -0
  166. package/src/alpha6/milestone.ts +2192 -0
  167. package/src/alpha6/navigation-dirty-carryover.ts +154 -0
  168. package/src/alpha6/npm-check-contract.ts +47 -0
  169. package/src/alpha6/plan-integrity.ts +298 -0
  170. package/src/alpha6/plan-risk.ts +1480 -0
  171. package/src/alpha6/preexecution-replan.ts +187 -0
  172. package/src/alpha6/remediation-cause.ts +98 -0
  173. package/src/alpha6/remediation.ts +2438 -0
  174. package/src/alpha6/review.ts +1198 -0
  175. package/src/alpha6/root-cause-replan-carryover.ts +491 -0
  176. package/src/alpha6/store-sidecars.ts +335 -0
  177. package/src/alpha7/autonomy.ts +411 -0
  178. package/src/alpha7/corrective-recovery.ts +1332 -0
  179. package/src/artifacts.ts +130 -0
  180. package/src/beta1/project-transaction.ts +355 -0
  181. package/src/change-explanation.ts +153 -0
  182. package/src/checks/runner.ts +245 -0
  183. package/src/checks/task-sync.ts +41 -0
  184. package/src/checks/worker.ts +149 -0
  185. package/src/cli-actions.ts +107 -0
  186. package/src/cli.ts +1457 -0
  187. package/src/contracts.ts +1494 -0
  188. package/src/credential-output.ts +89 -0
  189. package/src/credential-transport.ts +215 -0
  190. package/src/delegation.ts +190 -0
  191. package/src/dependency-provenance.ts +472 -0
  192. package/src/diagnostics.ts +93 -0
  193. package/src/domain/base-sync-conflict.ts +35 -0
  194. package/src/domain/completed-step-carryover.ts +76 -0
  195. package/src/domain/discovery.ts +27 -0
  196. package/src/domain/plan-semantics.ts +58 -0
  197. package/src/domain/step-start-admission.ts +10 -0
  198. package/src/domain/validation.ts +177 -0
  199. package/src/domain/virgin-registration.ts +46 -0
  200. package/src/errors.ts +24 -0
  201. package/src/fs-utils.ts +61 -0
  202. package/src/gateway-handshake.ts +95 -0
  203. package/src/git.ts +183 -0
  204. package/src/graph.ts +342 -0
  205. package/src/historical-step-provenance.ts +136 -0
  206. package/src/index.ts +23 -0
  207. package/src/lifecycle/canonical-hash.ts +28 -0
  208. package/src/lifecycle/catalog.ts +202 -0
  209. package/src/lifecycle/compiler-inspection.ts +29 -0
  210. package/src/lifecycle/core-static-readiness.ts +132 -0
  211. package/src/lifecycle/corrective-replan-authority.ts +136 -0
  212. package/src/lifecycle/corrective-replan-binding-manifest.ts +51 -0
  213. package/src/lifecycle/corrective-replan-credential-core.ts +408 -0
  214. package/src/lifecycle/corrective-replan-credential-schema.ts +54 -0
  215. package/src/lifecycle/corrective-replan-credentials.ts +48 -0
  216. package/src/lifecycle/corrective-replan.ts +843 -0
  217. package/src/lifecycle/evaluator.ts +48 -0
  218. package/src/lifecycle/fingerprint.ts +488 -0
  219. package/src/lifecycle/immutable.ts +8 -0
  220. package/src/lifecycle/implementation-table.ts +118 -0
  221. package/src/lifecycle/index.ts +8 -0
  222. package/src/lifecycle/schema-artifact.ts +263 -0
  223. package/src/lifecycle/semantic-registry.ts +572 -0
  224. package/src/lifecycle/types.ts +838 -0
  225. package/src/memory.ts +273 -0
  226. package/src/migration.ts +161 -0
  227. package/src/navigation-actions.ts +70 -0
  228. package/src/navigation-update-artifact.ts +198 -0
  229. package/src/navigation-update.ts +121 -0
  230. package/src/observation.ts +225 -0
  231. package/src/observed-routes.ts +660 -0
  232. package/src/operational-contract.ts +125 -0
  233. package/src/pending-review-update.ts +175 -0
  234. package/src/repository.ts +99 -0
  235. package/src/reviewer.ts +1879 -0
  236. package/src/runtime.ts +6 -0
  237. package/src/state/corrective-replan-executor.ts +818 -0
  238. package/src/state/corrective-replan-public-schema.ts +83 -0
  239. package/src/state/corrective-replan-public.ts +908 -0
  240. package/src/state/corrective-replan-transaction.ts +949 -0
  241. package/src/state/corrective-yield-executor.ts +327 -0
  242. package/src/state/corrective-yield-transaction.ts +730 -0
  243. package/src/state/lock.ts +902 -0
  244. package/src/state/store.ts +567 -0
  245. package/src/transition-core.ts +330 -0
  246. package/src/ulid.ts +24 -0
  247. package/src/version.ts +2 -0
  248. package/src/workflow-blocker-route.ts +109 -0
  249. package/src/workflow.ts +10172 -0
  250. package/docs/alpha7.1-implementation-brief.md +0 -268
  251. package/docs/alpha7.2-corrective-context-refresh-brief.md +0 -484
  252. package/docs/alpha7.2.1-remediation-recovery-brief.md +0 -86
  253. package/docs/beta1-stabilization-brief.md +0 -165
  254. package/docs/beta11-plan-integrity-recovery-brief.md +0 -38
  255. package/docs/beta13.2-signal-review-recovery.md +0 -38
  256. package/docs/beta2-initial-assembly-navigation-brief.md +0 -616
  257. package/docs/change-model.md +0 -118
  258. package/docs/delegated-approval.md +0 -254
  259. package/docs/lifecycle/state-machine-stabilization.md +0 -641
  260. package/docs/pdf/README.md +0 -24
  261. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  262. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  263. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  264. package/docs/pdf/requirements.txt +0 -1
  265. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +0 -478
  266. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +0 -508
  267. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +0 -778
  268. package/docs/pending-review-update.md +0 -15
  269. package/docs/problem-briefs/01-pre-implementation-integrity.md +0 -482
  270. package/docs/problem-briefs/02-minimal-step-integrity.md +0 -411
  271. package/docs/problem-briefs/03-minimal-agent-context-integrity.md +0 -358
  272. package/docs/problem-briefs/04-task-dependency-and-structural-replacement-integrity.md +0 -573
  273. package/docs/problem-briefs/BRIEF-TEMPLATE.md +0 -56
  274. package/docs/problem-briefs/README.md +0 -120
  275. package/docs/problem-briefs/evidence/p01-mechanical-feasibility-corpus.md +0 -90
  276. package/docs/problem-briefs/evidence/signal-v4-pre-m3-replay.md +0 -246
  277. package/docs/split-required-recovery.md +0 -47
  278. package/docs/stable-release-defect-register.md +0 -730
  279. package/docs/validation-report.md +0 -182
  280. package/scripts/generate-pdf-docs.py +0 -524
  281. package/scripts/run-pdf-docs.mjs +0 -62
@@ -0,0 +1,902 @@
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ fstatSync,
5
+ lstatSync,
6
+ openSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ type Stats,
12
+ } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { createHash, randomUUID } from 'node:crypto';
16
+ import {
17
+ STATE_SCHEMA_VERSION,
18
+ type PublicCoreTaskOperation,
19
+ type PublicWriterLease,
20
+ type WriterLease,
21
+ } from '../contracts.js';
22
+ import { WorkflowError } from '../errors.js';
23
+ import { ensureDirectory, writeJsonAtomic } from '../fs-utils.js';
24
+
25
+ const DEFAULT_LEASE_MS = 30 * 60 * 1000;
26
+ const LOCK_ENTITY_PATTERN = /^(TASK|MS)-[0-9A-HJKMNP-TV-Z]{26}$/;
27
+ const LOCK_FILE_PATTERN = /^(TASK|MS)-[0-9A-HJKMNP-TV-Z]{26}\.json$/;
28
+
29
+ interface LeaseFileSnapshot {
30
+ lease: WriterLease;
31
+ identity: Pick<Stats, 'dev' | 'ino'>;
32
+ content: string;
33
+ }
34
+
35
+ export interface CoreTaskOperationMutex {
36
+ schemaVersion: typeof STATE_SCHEMA_VERSION;
37
+ kind: 'core-task-operation-mutex';
38
+ entityId: string;
39
+ operationId: string;
40
+ transitionId: string;
41
+ mutexId: string;
42
+ nonceSha256: string;
43
+ owner: 'workflow-core';
44
+ acquiredAt: string;
45
+ expiresAt: string;
46
+ }
47
+
48
+ export interface CoreTaskOperationHandle {
49
+ readonly __coreTaskOperationHandle?: never;
50
+ }
51
+
52
+ export interface WriterLeaseHandle {
53
+ readonly __writerLeaseHandle?: never;
54
+ }
55
+
56
+ interface CoreHandleRecord {
57
+ locksRoot: string;
58
+ trustedRoot?: string;
59
+ mutex: CoreTaskOperationMutex;
60
+ identity: Pick<Stats, 'dev' | 'ino'>;
61
+ descriptor: number;
62
+ }
63
+
64
+ interface LockFileSnapshot {
65
+ record: WriterLease | CoreTaskOperationMutex;
66
+ identity: Pick<Stats, 'dev' | 'ino'>;
67
+ content: string;
68
+ }
69
+
70
+ const coreHandles = new WeakMap<object, CoreHandleRecord>();
71
+ const writerHandles = new WeakMap<object, {
72
+ locksRoot: string;
73
+ trustedRoot?: string;
74
+ snapshot: LeaseFileSnapshot;
75
+ }>();
76
+
77
+ export class WriterLockManager {
78
+ constructor(
79
+ private readonly locksRoot: string,
80
+ private readonly now: () => Date = () => new Date(),
81
+ private readonly trustedRoot?: string,
82
+ ) {
83
+ assertLockRootPath(locksRoot, trustedRoot);
84
+ ensureDirectory(locksRoot);
85
+ assertLockRootPath(locksRoot, trustedRoot);
86
+ }
87
+
88
+ acquire(entityId: string, owner: string, leaseMs = DEFAULT_LEASE_MS): WriterLease {
89
+ const file = this.file(entityId);
90
+ assertLockRootPath(this.locksRoot, this.trustedRoot);
91
+ const timestamp = this.now();
92
+ const lease: WriterLease = {
93
+ schemaVersion: STATE_SCHEMA_VERSION,
94
+ entityId,
95
+ token: randomUUID(),
96
+ owner,
97
+ pid: process.pid,
98
+ hostname: os.hostname(),
99
+ acquiredAt: timestamp.toISOString(),
100
+ heartbeatAt: timestamp.toISOString(),
101
+ expiresAt: new Date(timestamp.getTime() + leaseMs).toISOString(),
102
+ };
103
+ let descriptor: number | null = null;
104
+ try {
105
+ descriptor = openSync(
106
+ file,
107
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
108
+ 0o600,
109
+ );
110
+ writeFileSync(descriptor, `${JSON.stringify(lease, null, 2)}\n`, 'utf8');
111
+ closeSync(descriptor);
112
+ descriptor = null;
113
+ return lease;
114
+ } catch (error) {
115
+ if (descriptor !== null) closeSync(descriptor);
116
+ if (lstatIfPresent(file)) {
117
+ const snapshot = readLockSnapshot(this.locksRoot, entityId, this.trustedRoot);
118
+ if (isCoreTaskOperationMutex(snapshot.record)) {
119
+ throw new WorkflowError('LOCKED', `${entityId} already has a Core Task operation`, {
120
+ owner: snapshot.record.owner,
121
+ operationId: snapshot.record.operationId,
122
+ transitionId: snapshot.record.transitionId,
123
+ expiresAt: snapshot.record.expiresAt,
124
+ stale: lockExpiry(snapshot.record, file) <= timestamp.getTime(),
125
+ });
126
+ }
127
+ const current = snapshot.record;
128
+ throw new WorkflowError('LOCKED', `${entityId} already has a writer`, {
129
+ owner: current.owner,
130
+ pid: current.pid,
131
+ hostname: current.hostname,
132
+ heartbeatAt: current.heartbeatAt,
133
+ expiresAt: current.expiresAt,
134
+ stale: leaseExpiry(current, file) <= timestamp.getTime(),
135
+ });
136
+ }
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ heartbeat(entityId: string, token: string, leaseMs = DEFAULT_LEASE_MS): WriterLease {
142
+ const timestamp = this.now();
143
+ const current = this.requireConsumableToken(entityId, token, timestamp);
144
+ const updated: WriterLease = {
145
+ ...current.lease,
146
+ heartbeatAt: timestamp.toISOString(),
147
+ expiresAt: new Date(timestamp.getTime() + leaseMs).toISOString(),
148
+ };
149
+ assertSameLeaseIdentity(this.file(entityId), current.identity);
150
+ writeJsonAtomic(this.file(entityId), updated);
151
+ return updated;
152
+ }
153
+
154
+ replaceCredential(entityId: string, owner: string, leaseMs = DEFAULT_LEASE_MS): WriterLease {
155
+ const timestamp = this.now();
156
+ const current = readLeaseSnapshotOrNull(this.locksRoot, entityId, this.trustedRoot);
157
+ if (!current) throw new WorkflowError('NOT_FOUND', `No writer lock for ${entityId}`);
158
+ if (current.lease.owner !== owner) {
159
+ throw new WorkflowError('TRANSITION_BLOCKED', `Only writer ${current.lease.owner} may replace the credential for ${entityId}.`, {
160
+ entityId,
161
+ owner: current.lease.owner,
162
+ actor: owner,
163
+ });
164
+ }
165
+ if (leaseExpiry(current.lease, this.file(entityId)) <= timestamp.getTime()) {
166
+ throw new WorkflowError('TRANSITION_BLOCKED', `Writer lock has expired for ${entityId}; explicit stale-lock repair is required.`, {
167
+ entityId,
168
+ expiresAt: current.lease.expiresAt,
169
+ requiredAction: 'locks repair',
170
+ });
171
+ }
172
+ const replacement: WriterLease = {
173
+ ...current.lease,
174
+ token: randomUUID(),
175
+ heartbeatAt: timestamp.toISOString(),
176
+ expiresAt: new Date(timestamp.getTime() + leaseMs).toISOString(),
177
+ };
178
+ assertSameLeaseIdentity(this.file(entityId), current.identity);
179
+ writeJsonAtomic(this.file(entityId), replacement);
180
+ return replacement;
181
+ }
182
+
183
+ replaceOrRecoverCredential(entityId: string, owner: string, leaseMs = DEFAULT_LEASE_MS): WriterLease {
184
+ const timestamp = this.now();
185
+ const current = readLeaseSnapshotOrNull(this.locksRoot, entityId, this.trustedRoot);
186
+ if (!current) return this.acquire(entityId, owner, leaseMs);
187
+ if (current.lease.owner !== owner) {
188
+ throw new WorkflowError('TRANSITION_BLOCKED', `Only writer ${current.lease.owner} may recover the credential for ${entityId}.`, {
189
+ entityId,
190
+ owner: current.lease.owner,
191
+ actor: owner,
192
+ });
193
+ }
194
+ const replacement: WriterLease = {
195
+ ...current.lease,
196
+ token: randomUUID(),
197
+ heartbeatAt: timestamp.toISOString(),
198
+ expiresAt: new Date(timestamp.getTime() + leaseMs).toISOString(),
199
+ };
200
+ // Recovery is exact-entity and exact-owner. Atomic replacement preserves the
201
+ // lock-file identity check and avoids a repository-wide delete/reacquire gap.
202
+ assertSameLeaseIdentity(this.file(entityId), current.identity);
203
+ writeJsonAtomic(this.file(entityId), replacement);
204
+ return replacement;
205
+ }
206
+
207
+
208
+ release(entityId: string, token: string): void {
209
+ const current = this.requireConsumableToken(entityId, token, this.now());
210
+ const file = this.file(entityId);
211
+ assertSameLeaseIdentity(file, current.identity);
212
+ unlinkSync(file);
213
+ }
214
+
215
+ prove(entityId: string, token: string): WriterLeaseHandle {
216
+ const snapshot = this.requireConsumableToken(entityId, token, this.now());
217
+ const handle = Object.freeze(Object.create(null)) as WriterLeaseHandle;
218
+ writerHandles.set(handle as object, {
219
+ locksRoot: path.resolve(this.locksRoot),
220
+ ...(this.trustedRoot === undefined ? {} : { trustedRoot: path.resolve(this.trustedRoot) }),
221
+ snapshot,
222
+ });
223
+ return handle;
224
+ }
225
+
226
+ proofBinding(handle: WriterLeaseHandle): {
227
+ entityId: string;
228
+ owner: string;
229
+ expiresAt: string;
230
+ tokenSha256: string;
231
+ contentSha256: string;
232
+ } {
233
+ const snapshot = assertWriterLeaseHandle(this.locksRoot, this.trustedRoot, handle, this.now());
234
+ return Object.freeze({
235
+ entityId: snapshot.lease.entityId,
236
+ owner: snapshot.lease.owner,
237
+ expiresAt: snapshot.lease.expiresAt,
238
+ tokenSha256: createHash('sha256').update(snapshot.lease.token).digest('hex'),
239
+ contentSha256: createHash('sha256').update(snapshot.content).digest('hex'),
240
+ });
241
+ }
242
+
243
+ inspect(entityId: string): WriterLease | null {
244
+ return inspectWriterLease(this.locksRoot, entityId, this.trustedRoot);
245
+ }
246
+
247
+ list(): WriterLease[] {
248
+ return listWriterLeases(this.locksRoot, this.trustedRoot);
249
+ }
250
+
251
+ repairStale(entityId: string): WriterLease {
252
+ const file = this.file(entityId);
253
+ const current = readLeaseSnapshotOrNull(this.locksRoot, entityId, this.trustedRoot);
254
+ if (!current) throw new WorkflowError('NOT_FOUND', `No writer lock for ${entityId}`);
255
+ const expiresAt = leaseExpiry(current.lease, file);
256
+ if (expiresAt > this.now().getTime()) {
257
+ throw new WorkflowError('TRANSITION_BLOCKED', `Writer lock is not stale for ${entityId}`, {
258
+ expiresAt: current.lease.expiresAt,
259
+ });
260
+ }
261
+ assertSameLeaseIdentity(file, current.identity);
262
+ unlinkSync(file);
263
+ return current.lease;
264
+ }
265
+
266
+ private requireConsumableToken(entityId: string, token: string, now: Date): LeaseFileSnapshot {
267
+ const current = readLeaseSnapshotOrNull(this.locksRoot, entityId, this.trustedRoot);
268
+ if (!current) throw new WorkflowError('NOT_FOUND', `No writer lock for ${entityId}`);
269
+ if (current.lease.token !== token) {
270
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Writer token does not own ${entityId}`);
271
+ }
272
+ if (leaseExpiry(current.lease, this.file(entityId)) <= now.getTime()) {
273
+ throw new WorkflowError('TRANSITION_BLOCKED', `Writer lock has expired for ${entityId}; explicit stale-lock repair is required.`, {
274
+ entityId,
275
+ expiresAt: current.lease.expiresAt,
276
+ requiredAction: 'locks repair',
277
+ });
278
+ }
279
+ return current;
280
+ }
281
+
282
+ private file(entityId: string): string {
283
+ assertLockEntity(entityId);
284
+ return path.join(this.locksRoot, `${entityId}.json`);
285
+ }
286
+ }
287
+
288
+ export function listWriterLeases(locksRoot: string, trustedRoot?: string): WriterLease[] {
289
+ if (!lstatIfPresent(locksRoot)) return [];
290
+ assertLockRootPath(locksRoot, trustedRoot);
291
+ return readdirSync(locksRoot, { withFileTypes: true })
292
+ .filter((entry) => LOCK_FILE_PATTERN.test(entry.name))
293
+ .flatMap((entry) => {
294
+ const snapshot = readLockSnapshot(
295
+ locksRoot,
296
+ entry.name.slice(0, -'.json'.length),
297
+ trustedRoot,
298
+ );
299
+ return isCoreTaskOperationMutex(snapshot.record) ? [] : [snapshot.record];
300
+ })
301
+ .sort((left, right) => left.entityId.localeCompare(right.entityId));
302
+ }
303
+
304
+ export function inspectWriterLease(
305
+ locksRoot: string,
306
+ entityId: string,
307
+ trustedRoot?: string,
308
+ ): WriterLease | null {
309
+ assertLockEntity(entityId);
310
+ if (!lstatIfPresent(locksRoot)) return null;
311
+ const file = path.join(locksRoot, `${entityId}.json`);
312
+ if (!lstatIfPresent(file)) return null;
313
+ const record = readLockSnapshot(locksRoot, entityId, trustedRoot).record;
314
+ return isCoreTaskOperationMutex(record) ? null : record;
315
+ }
316
+
317
+ export function listCoreTaskOperationMutexes(
318
+ locksRoot: string,
319
+ trustedRoot?: string,
320
+ ): CoreTaskOperationMutex[] {
321
+ if (!lstatIfPresent(locksRoot)) return [];
322
+ assertLockRootPath(locksRoot, trustedRoot);
323
+ return readdirSync(locksRoot, { withFileTypes: true })
324
+ .filter((entry) => LOCK_FILE_PATTERN.test(entry.name))
325
+ .flatMap((entry) => {
326
+ const record = readLockSnapshot(
327
+ locksRoot,
328
+ entry.name.slice(0, -'.json'.length),
329
+ trustedRoot,
330
+ ).record;
331
+ return isCoreTaskOperationMutex(record) ? [record] : [];
332
+ })
333
+ .sort((left, right) => left.entityId.localeCompare(right.entityId));
334
+ }
335
+
336
+ export function toPublicWriterLease(lease: WriterLease, now: Date): PublicWriterLease {
337
+ const expiresAt = leaseExpiry(lease, '<writer-lease>');
338
+ return {
339
+ schemaVersion: lease.schemaVersion,
340
+ entityId: lease.entityId,
341
+ owner: lease.owner,
342
+ pid: lease.pid,
343
+ hostname: lease.hostname,
344
+ acquiredAt: lease.acquiredAt,
345
+ heartbeatAt: lease.heartbeatAt,
346
+ expiresAt: lease.expiresAt,
347
+ stale: expiresAt <= now.getTime(),
348
+ };
349
+ }
350
+
351
+ function readLeaseSnapshotOrNull(
352
+ locksRoot: string,
353
+ entityId: string,
354
+ trustedRoot?: string,
355
+ ): LeaseFileSnapshot | null {
356
+ assertLockEntity(entityId);
357
+ if (!lstatIfPresent(locksRoot)) return null;
358
+ assertLockRootPath(locksRoot, trustedRoot);
359
+ const file = path.join(locksRoot, `${entityId}.json`);
360
+ if (!lstatIfPresent(file)) return null;
361
+ return readLeaseSnapshot(locksRoot, entityId, trustedRoot);
362
+ }
363
+
364
+ function readLeaseSnapshot(
365
+ locksRoot: string,
366
+ entityId: string,
367
+ trustedRoot?: string,
368
+ ): LeaseFileSnapshot {
369
+ const snapshot = readLockSnapshot(locksRoot, entityId, trustedRoot);
370
+ if (isCoreTaskOperationMutex(snapshot.record)) {
371
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Writer token does not own ${entityId}`);
372
+ }
373
+ return { lease: snapshot.record, identity: snapshot.identity, content: snapshot.content };
374
+ }
375
+
376
+ function readLockSnapshot(
377
+ locksRoot: string,
378
+ entityId: string,
379
+ trustedRoot?: string,
380
+ ): LockFileSnapshot {
381
+ assertLockEntity(entityId);
382
+ assertLockRootPath(locksRoot, trustedRoot);
383
+ const file = path.join(locksRoot, `${entityId}.json`);
384
+ const boundary = trustedBoundary(locksRoot, trustedRoot);
385
+ assertNoSymlinkBelowBoundary(file, boundary, 'Writer lease path');
386
+ const before = lstatIfPresent(file);
387
+ if (!before) throw new WorkflowError('NOT_FOUND', `No writer lock for ${entityId}`);
388
+ if (before.isSymbolicLink() || !before.isFile()) {
389
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease must be a regular file: ${file}`);
390
+ }
391
+ let descriptor: number | null = null;
392
+ try {
393
+ descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
394
+ const opened = fstatSync(descriptor);
395
+ if (!sameIdentity(before, opened) || !opened.isFile()) {
396
+ throw new WorkflowError('STATE_CONFLICT', `Writer lease changed while opening: ${file}`);
397
+ }
398
+ const content = readFileSync(descriptor, 'utf8');
399
+ let record: WriterLease | CoreTaskOperationMutex;
400
+ try {
401
+ record = JSON.parse(content) as WriterLease | CoreTaskOperationMutex;
402
+ } catch (error) {
403
+ throw new WorkflowError('STATE_CORRUPT', `Cannot read JSON state: ${file}`, {
404
+ cause: error instanceof Error ? error.message : String(error),
405
+ });
406
+ }
407
+ validateLockRecord(record, entityId, file);
408
+ const after = lstatIfPresent(file);
409
+ if (!after || !sameIdentity(opened, after)) {
410
+ throw new WorkflowError('STATE_CONFLICT', `Writer lease changed while reading: ${file}`);
411
+ }
412
+ return { record, identity: { dev: opened.dev, ino: opened.ino }, content };
413
+ } finally {
414
+ if (descriptor !== null) closeSync(descriptor);
415
+ }
416
+ }
417
+
418
+ export class CoreTaskOperationLockManager {
419
+ constructor(
420
+ private readonly locksRoot: string,
421
+ private readonly now: () => Date = () => new Date(),
422
+ private readonly trustedRoot?: string,
423
+ ) {
424
+ assertLockRootPath(locksRoot, trustedRoot);
425
+ ensureDirectory(locksRoot);
426
+ assertLockRootPath(locksRoot, trustedRoot);
427
+ }
428
+
429
+ acquire(input: {
430
+ entityId: string;
431
+ operationId: string;
432
+ transitionId: string;
433
+ leaseMs?: number;
434
+ }): CoreTaskOperationHandle {
435
+ assertLockEntity(input.entityId);
436
+ if (!input.entityId.startsWith('TASK-')) {
437
+ throw new WorkflowError('INVALID_ARGUMENT', 'Core corrective-replan operations require a Task entity.');
438
+ }
439
+ if (!/^[a-f0-9]{64}$/.test(input.operationId)) {
440
+ throw new WorkflowError('INVALID_ARGUMENT', 'Core Task operation id must be lowercase SHA-256.');
441
+ }
442
+ if (!isSupportedCoreTaskTransition(input.transitionId)) {
443
+ throw new WorkflowError('INVALID_ARGUMENT', `Unsupported Core Task transition: ${input.transitionId}`);
444
+ }
445
+ const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS;
446
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > DEFAULT_LEASE_MS) {
447
+ throw new WorkflowError('INVALID_ARGUMENT', 'Core Task operation lease must be between 1 ms and 30 minutes.');
448
+ }
449
+ const timestamp = this.now();
450
+ const nonce = randomUUID();
451
+ const mutex: CoreTaskOperationMutex = {
452
+ schemaVersion: STATE_SCHEMA_VERSION,
453
+ kind: 'core-task-operation-mutex',
454
+ entityId: input.entityId,
455
+ operationId: input.operationId,
456
+ transitionId: input.transitionId,
457
+ mutexId: randomUUID(),
458
+ nonceSha256: createHash('sha256').update(nonce).digest('hex'),
459
+ owner: 'workflow-core',
460
+ acquiredAt: timestamp.toISOString(),
461
+ expiresAt: new Date(timestamp.getTime() + leaseMs).toISOString(),
462
+ };
463
+ const file = path.join(this.locksRoot, `${input.entityId}.json`);
464
+ let descriptor: number | null = null;
465
+ try {
466
+ descriptor = openSync(file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
467
+ writeFileSync(descriptor, `${JSON.stringify(mutex, null, 2)}\n`, 'utf8');
468
+ closeSync(descriptor);
469
+ descriptor = null;
470
+ } catch (error) {
471
+ if (descriptor !== null) closeSync(descriptor);
472
+ if (lstatIfPresent(file)) {
473
+ const current = readLockSnapshot(this.locksRoot, input.entityId, this.trustedRoot).record;
474
+ throw new WorkflowError('LOCKED', `${input.entityId} already has a Task operation owner`, {
475
+ owner: isCoreTaskOperationMutex(current) ? current.owner : current.owner,
476
+ ...(isCoreTaskOperationMutex(current) ? {
477
+ operationId: current.operationId,
478
+ transitionId: current.transitionId,
479
+ } : {}),
480
+ expiresAt: current.expiresAt,
481
+ stale: lockExpiry(current, file) <= timestamp.getTime(),
482
+ });
483
+ }
484
+ throw error;
485
+ }
486
+ return issueCoreHandle(this.locksRoot, this.trustedRoot, mutex);
487
+ }
488
+
489
+ repairExpiredOrphan(entityId: string, assertNoJournal: (mutex: Readonly<CoreTaskOperationMutex>) => void): PublicCoreTaskOperation & { kind: 'core-task-operation'; repaired: true } {
490
+ assertLockEntity(entityId);
491
+ const file = path.join(this.locksRoot, `${entityId}.json`);
492
+ const snapshot = readLockSnapshot(this.locksRoot, entityId, this.trustedRoot);
493
+ if (!isCoreTaskOperationMutex(snapshot.record)) {
494
+ throw new WorkflowError('LOCKED', 'Orphan Core repair requires an exact Core mutex.');
495
+ }
496
+ if (lockExpiry(snapshot.record, file) > this.now().getTime()) {
497
+ throw new WorkflowError('LOCKED', 'An active Core operation cannot be repaired.');
498
+ }
499
+ assertNoJournal(snapshot.record);
500
+ assertSameLeaseIdentity(file, snapshot.identity);
501
+ const current = readLockSnapshot(this.locksRoot, entityId, this.trustedRoot);
502
+ if (!isCoreTaskOperationMutex(current.record) || current.record.mutexId !== snapshot.record.mutexId
503
+ || current.content !== snapshot.content) {
504
+ throw new WorkflowError('STATE_CONFLICT', 'Core mutex changed during orphan repair.');
505
+ }
506
+ assertSameLeaseIdentity(file, current.identity);
507
+ unlinkSync(file);
508
+ return { ...toPublicCoreTaskOperation(snapshot.record, this.now()), kind: 'core-task-operation', repaired: true };
509
+ }
510
+
511
+ acquireForRecovery(input: {
512
+ entityId: string;
513
+ operationId: string;
514
+ transitionId: string;
515
+ leaseMs?: number;
516
+ }): CoreTaskOperationHandle {
517
+ assertLockEntity(input.entityId);
518
+ if (!input.entityId.startsWith('TASK-')
519
+ || !/^[a-f0-9]{64}$/.test(input.operationId)
520
+ || !isSupportedCoreTaskTransition(input.transitionId)) {
521
+ throw new WorkflowError('INVALID_ARGUMENT', 'Invalid Core Task recovery operation identity.');
522
+ }
523
+ const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS;
524
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > DEFAULT_LEASE_MS) {
525
+ throw new WorkflowError('INVALID_ARGUMENT', 'Core Task recovery lease must be between 1 ms and 30 minutes.');
526
+ }
527
+ const file = path.join(this.locksRoot, `${input.entityId}.json`);
528
+ if (!lstatIfPresent(file)) return this.acquire(input);
529
+ const now = this.now();
530
+ const snapshot = readLockSnapshot(this.locksRoot, input.entityId, this.trustedRoot);
531
+ if (!isCoreTaskOperationMutex(snapshot.record)
532
+ || snapshot.record.operationId !== input.operationId
533
+ || snapshot.record.transitionId !== input.transitionId) {
534
+ throw new WorkflowError('LOCKED', `${input.entityId} is owned by another Task operation.`, {
535
+ expiresAt: snapshot.record.expiresAt,
536
+ });
537
+ }
538
+ if (lockExpiry(snapshot.record, file) > now.getTime()) {
539
+ throw new WorkflowError('LOCKED', `${input.entityId} still has an active Core Task operation.`, {
540
+ operationId: snapshot.record.operationId,
541
+ expiresAt: snapshot.record.expiresAt,
542
+ });
543
+ }
544
+ assertSameLeaseIdentity(file, snapshot.identity);
545
+ unlinkSync(file);
546
+ return this.acquire(input);
547
+ }
548
+
549
+ transferFromWriter(
550
+ writerHandle: WriterLeaseHandle,
551
+ input: { entityId: string; operationId: string; transitionId: 'task.coordination.corrective-yield'; leaseMs?: number },
552
+ ): CoreTaskOperationHandle {
553
+ const writer = assertWriterLeaseHandle(this.locksRoot, this.trustedRoot, writerHandle, this.now());
554
+ if (writer.lease.entityId !== input.entityId) {
555
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Writer lease does not own ${input.entityId}`);
556
+ }
557
+ const mutex = this.materializeMutex(input);
558
+ const file = path.join(this.locksRoot, `${input.entityId}.json`);
559
+ assertSameLeaseIdentity(file, writer.identity);
560
+ writeJsonAtomic(file, mutex);
561
+ writerHandles.delete(writerHandle as object);
562
+ return issueCoreHandle(this.locksRoot, this.trustedRoot, mutex);
563
+ }
564
+
565
+ acquireYieldRecovery(input: {
566
+ entityId: string;
567
+ operationId: string;
568
+ transitionId: 'task.coordination.corrective-yield';
569
+ expectedWriterContentSha256: string;
570
+ expectedWriterTokenSha256: string;
571
+ leaseMs?: number;
572
+ }): CoreTaskOperationHandle {
573
+ assertHash64(input.expectedWriterContentSha256, 'Expected writer lease content hash');
574
+ assertHash64(input.expectedWriterTokenSha256, 'Expected writer lease token hash');
575
+ const file = path.join(this.locksRoot, `${input.entityId}.json`);
576
+ if (!lstatIfPresent(file)) return this.acquire(input);
577
+ const snapshot = readLockSnapshot(this.locksRoot, input.entityId, this.trustedRoot);
578
+ if (isCoreTaskOperationMutex(snapshot.record)) return this.acquireForRecovery(input);
579
+ const now = this.now();
580
+ if (leaseExpiry(snapshot.record, file) > now.getTime()) {
581
+ throw new WorkflowError('LOCKED', `${input.entityId} still has an active writer lease.`);
582
+ }
583
+ if (createHash('sha256').update(snapshot.content).digest('hex') !== input.expectedWriterContentSha256
584
+ || createHash('sha256').update(snapshot.record.token).digest('hex') !== input.expectedWriterTokenSha256) {
585
+ throw new WorkflowError('STATE_CONFLICT', 'Stale writer lease does not match the corrective-yield intent.');
586
+ }
587
+ const mutex = this.materializeMutex(input);
588
+ assertSameLeaseIdentity(file, snapshot.identity);
589
+ writeJsonAtomic(file, mutex);
590
+ return issueCoreHandle(this.locksRoot, this.trustedRoot, mutex);
591
+ }
592
+
593
+ release(handle: CoreTaskOperationHandle): void {
594
+ const record = assertCoreTaskOperationHandle(this.locksRoot, this.trustedRoot, handle, this.now());
595
+ const file = path.join(this.locksRoot, `${record.entityId}.json`);
596
+ const snapshot = readLockSnapshot(this.locksRoot, record.entityId, this.trustedRoot);
597
+ if (!isCoreTaskOperationMutex(snapshot.record) || snapshot.record.mutexId !== record.mutexId) {
598
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Core Task operation does not own ${record.entityId}`);
599
+ }
600
+ assertSameLeaseIdentity(file, snapshot.identity);
601
+ unlinkSync(file);
602
+ closeSync(coreHandles.get(handle as object)!.descriptor);
603
+ coreHandles.delete(handle as object);
604
+ }
605
+
606
+ private materializeMutex(input: {
607
+ entityId: string;
608
+ operationId: string;
609
+ transitionId: string;
610
+ leaseMs?: number;
611
+ }): CoreTaskOperationMutex {
612
+ assertLockEntity(input.entityId);
613
+ if (!input.entityId.startsWith('TASK-') || !/^[a-f0-9]{64}$/.test(input.operationId)
614
+ || !isSupportedCoreTaskTransition(input.transitionId)) {
615
+ throw new WorkflowError('INVALID_ARGUMENT', 'Invalid Core Task operation identity.');
616
+ }
617
+ const leaseMs = input.leaseMs ?? DEFAULT_LEASE_MS;
618
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > DEFAULT_LEASE_MS) {
619
+ throw new WorkflowError('INVALID_ARGUMENT', 'Core Task operation lease must be between 1 ms and 30 minutes.');
620
+ }
621
+ const timestamp = this.now();
622
+ return {
623
+ schemaVersion: STATE_SCHEMA_VERSION,
624
+ kind: 'core-task-operation-mutex',
625
+ entityId: input.entityId,
626
+ operationId: input.operationId,
627
+ transitionId: input.transitionId,
628
+ mutexId: randomUUID(),
629
+ nonceSha256: createHash('sha256').update(randomUUID()).digest('hex'),
630
+ owner: 'workflow-core',
631
+ acquiredAt: timestamp.toISOString(),
632
+ expiresAt: new Date(timestamp.getTime() + leaseMs).toISOString(),
633
+ };
634
+ }
635
+
636
+ }
637
+
638
+ export function assertCoreTaskOperationHandle(
639
+ locksRoot: string,
640
+ trustedRoot: string | undefined,
641
+ handle: unknown,
642
+ now: Date,
643
+ expected?: { entityId?: string; operationId?: string; transitionId?: string },
644
+ ): CoreTaskOperationMutex {
645
+ if (!handle || typeof handle !== 'object') {
646
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'A genuine Core Task operation handle is required.');
647
+ }
648
+ const issued = coreHandles.get(handle);
649
+ if (!issued || issued.locksRoot !== path.resolve(locksRoot)
650
+ || (issued.trustedRoot ?? undefined) !== (trustedRoot === undefined ? undefined : path.resolve(trustedRoot))) {
651
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'A genuine Core Task operation handle is required.');
652
+ }
653
+ if (expected?.entityId !== undefined && issued.mutex.entityId !== expected.entityId) {
654
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Core Task operation does not own ${expected.entityId}`);
655
+ }
656
+ if (expected?.operationId !== undefined && issued.mutex.operationId !== expected.operationId) {
657
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'Core Task operation id mismatch.');
658
+ }
659
+ if (expected?.transitionId !== undefined && issued.mutex.transitionId !== expected.transitionId) {
660
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'Core Task operation transition mismatch.');
661
+ }
662
+ const snapshot = readLockSnapshot(locksRoot, issued.mutex.entityId, trustedRoot);
663
+ const anchored = fstatSync(issued.descriptor);
664
+ if (!isCoreTaskOperationMutex(snapshot.record)
665
+ || snapshot.record.mutexId !== issued.mutex.mutexId
666
+ || snapshot.record.nonceSha256 !== issued.mutex.nonceSha256
667
+ || !sameIdentity(snapshot.identity, issued.identity)
668
+ || !sameIdentity(snapshot.identity, anchored)) {
669
+ closeSync(issued.descriptor);
670
+ coreHandles.delete(handle);
671
+ throw new WorkflowError('LOCK_TOKEN_INVALID', `Core Task operation does not own ${issued.mutex.entityId}`);
672
+ }
673
+ if (lockExpiry(snapshot.record, path.join(locksRoot, `${issued.mutex.entityId}.json`)) <= now.getTime()) {
674
+ throw new WorkflowError('TRANSITION_BLOCKED', `Core Task operation has expired for ${issued.mutex.entityId}.`);
675
+ }
676
+ return snapshot.record;
677
+ }
678
+
679
+ export function inspectCoreTaskOperationMutex(
680
+ locksRoot: string,
681
+ entityId: string,
682
+ trustedRoot?: string,
683
+ ): CoreTaskOperationMutex | null {
684
+ assertLockEntity(entityId);
685
+ if (!lstatIfPresent(locksRoot)) return null;
686
+ const file = path.join(locksRoot, `${entityId}.json`);
687
+ if (!lstatIfPresent(file)) return null;
688
+ const record = readLockSnapshot(locksRoot, entityId, trustedRoot).record;
689
+ return isCoreTaskOperationMutex(record) ? record : null;
690
+ }
691
+
692
+ export function toPublicCoreTaskOperation(
693
+ mutex: CoreTaskOperationMutex,
694
+ now: Date,
695
+ ): PublicCoreTaskOperation {
696
+ return {
697
+ schemaVersion: mutex.schemaVersion,
698
+ entityId: mutex.entityId,
699
+ operationId: mutex.operationId,
700
+ transitionId: mutex.transitionId,
701
+ owner: mutex.owner,
702
+ acquiredAt: mutex.acquiredAt,
703
+ expiresAt: mutex.expiresAt,
704
+ stale: lockExpiry(mutex, '<core-task-operation>') <= now.getTime(),
705
+ };
706
+ }
707
+
708
+ function assertLockRootPath(locksRoot: string, trustedRoot: string | undefined): void {
709
+ const boundary = trustedBoundary(locksRoot, trustedRoot);
710
+ assertNoSymlinkBelowBoundary(locksRoot, boundary, 'Writer lock root');
711
+ const stat = lstatIfPresent(locksRoot);
712
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
713
+ throw new WorkflowError('STATE_CORRUPT', `Writer lock root must be a directory: ${locksRoot}`);
714
+ }
715
+ }
716
+
717
+ function assertNoSymlinkBelowBoundary(candidate: string, boundary: string, label: string): void {
718
+ const resolvedCandidate = path.resolve(candidate);
719
+ if (resolvedCandidate !== boundary && !resolvedCandidate.startsWith(`${boundary}${path.sep}`)) {
720
+ throw new WorkflowError('INVALID_ARGUMENT', `${label} is outside of the trusted root: ${candidate}`, {
721
+ trustedRoot: boundary,
722
+ });
723
+ }
724
+ let current = boundary;
725
+ for (const segment of path.relative(boundary, resolvedCandidate).split(path.sep).filter(Boolean)) {
726
+ current = path.join(current, segment);
727
+ const stat = lstatIfPresent(current);
728
+ if (!stat) return;
729
+ if (stat.isSymbolicLink()) {
730
+ throw new WorkflowError('STATE_CORRUPT', `${label} must not traverse a symlink: ${candidate}`, {
731
+ symlinkPath: current,
732
+ });
733
+ }
734
+ }
735
+ }
736
+
737
+ function trustedBoundary(locksRoot: string, trustedRoot: string | undefined): string {
738
+ const boundary = path.resolve(trustedRoot ?? path.dirname(locksRoot));
739
+ const stat = lstatIfPresent(boundary);
740
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
741
+ throw new WorkflowError('STATE_CORRUPT', `Writer lock trusted root must be an existing directory: ${boundary}`);
742
+ }
743
+ return boundary;
744
+ }
745
+
746
+ function assertSameLeaseIdentity(file: string, expected: Pick<Stats, 'dev' | 'ino'>): void {
747
+ const current = lstatIfPresent(file);
748
+ if (!current || current.isSymbolicLink() || !current.isFile() || !sameIdentity(expected, current)) {
749
+ throw new WorkflowError('STATE_CONFLICT', `Writer lease changed before mutation: ${file}`);
750
+ }
751
+ }
752
+
753
+ function leaseExpiry(lease: WriterLease, file: string): number {
754
+ if (typeof lease.expiresAt !== 'string') {
755
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease expiresAt must be a finite ISO timestamp: ${file}`);
756
+ }
757
+ const expiresAt = Date.parse(lease.expiresAt);
758
+ if (!Number.isFinite(expiresAt) || new Date(expiresAt).toISOString() !== lease.expiresAt) {
759
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease expiresAt must be a finite ISO timestamp: ${file}`, {
760
+ expiresAt: lease.expiresAt,
761
+ });
762
+ }
763
+ return expiresAt;
764
+ }
765
+
766
+ function lockExpiry(record: WriterLease | CoreTaskOperationMutex, file: string): number {
767
+ return leaseExpiry(record as WriterLease, file);
768
+ }
769
+
770
+ function isCoreTaskOperationMutex(record: WriterLease | CoreTaskOperationMutex): record is CoreTaskOperationMutex {
771
+ return (record as { kind?: unknown }).kind === 'core-task-operation-mutex';
772
+ }
773
+
774
+ function validateLockRecord(
775
+ record: WriterLease | CoreTaskOperationMutex,
776
+ expectedEntityId: string,
777
+ file: string,
778
+ ): void {
779
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
780
+ throw new WorkflowError('STATE_CORRUPT', `Task operation lock must be an object: ${file}`);
781
+ }
782
+ if (record.schemaVersion !== STATE_SCHEMA_VERSION || record.entityId !== expectedEntityId) {
783
+ throw new WorkflowError('STATE_CORRUPT', `Task operation lock identity mismatch: ${file}`);
784
+ }
785
+ if (isCoreTaskOperationMutex(record)) {
786
+ const keys = Object.keys(record).sort();
787
+ const expected = [
788
+ 'acquiredAt', 'entityId', 'expiresAt', 'kind', 'mutexId', 'nonceSha256', 'operationId',
789
+ 'owner', 'schemaVersion', 'transitionId',
790
+ ].sort();
791
+ if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
792
+ throw new WorkflowError('STATE_CORRUPT', `Core Task operation lock has unknown or missing fields: ${file}`);
793
+ }
794
+ if (record.owner !== 'workflow-core' || !isSupportedCoreTaskTransition(record.transitionId)) {
795
+ throw new WorkflowError('STATE_CORRUPT', `Core Task operation lock semantic identity mismatch: ${file}`);
796
+ }
797
+ assertHash64(record.operationId, 'Core Task operation id');
798
+ assertHash64(record.nonceSha256, 'Core Task operation nonce hash');
799
+ if (typeof record.mutexId !== 'string' || !/^[0-9a-f-]{36}$/.test(record.mutexId)) {
800
+ throw new WorkflowError('STATE_CORRUPT', `Core Task operation mutexId is invalid: ${file}`);
801
+ }
802
+ const acquiredAt = typeof record.acquiredAt === 'string' ? Date.parse(record.acquiredAt) : Number.NaN;
803
+ if (!Number.isFinite(acquiredAt) || new Date(acquiredAt).toISOString() !== record.acquiredAt) {
804
+ throw new WorkflowError('STATE_CORRUPT', `Core Task operation acquiredAt is invalid: ${file}`);
805
+ }
806
+ }
807
+ lockExpiry(record, file);
808
+ }
809
+
810
+ function assertWriterLeaseHandle(
811
+ locksRoot: string,
812
+ trustedRoot: string | undefined,
813
+ handle: unknown,
814
+ now: Date,
815
+ ): LeaseFileSnapshot {
816
+ if (!handle || typeof handle !== 'object') {
817
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'A genuine writer lease handle is required.');
818
+ }
819
+ const issued = writerHandles.get(handle);
820
+ if (!issued || issued.locksRoot !== path.resolve(locksRoot)
821
+ || (issued.trustedRoot ?? undefined) !== (trustedRoot === undefined ? undefined : path.resolve(trustedRoot))) {
822
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'A genuine writer lease handle is required.');
823
+ }
824
+ const current = readLeaseSnapshot(locksRoot, issued.snapshot.lease.entityId, trustedRoot);
825
+ if (!sameIdentity(current.identity, issued.snapshot.identity)
826
+ || current.lease.token !== issued.snapshot.lease.token
827
+ || current.content !== issued.snapshot.content) {
828
+ throw new WorkflowError('LOCK_TOKEN_INVALID', 'Writer lease changed after proof issuance.');
829
+ }
830
+ if (leaseExpiry(current.lease, path.join(locksRoot, `${current.lease.entityId}.json`)) <= now.getTime()) {
831
+ throw new WorkflowError('TRANSITION_BLOCKED', `Writer lease has expired for ${current.lease.entityId}.`);
832
+ }
833
+ return current;
834
+ }
835
+
836
+ function issueCoreHandle(
837
+ locksRoot: string,
838
+ trustedRoot: string | undefined,
839
+ mutex: CoreTaskOperationMutex,
840
+ ): CoreTaskOperationHandle {
841
+ const issued = readLockSnapshot(locksRoot, mutex.entityId, trustedRoot);
842
+ if (!isCoreTaskOperationMutex(issued.record) || issued.record.mutexId !== mutex.mutexId) {
843
+ throw new WorkflowError('STATE_CONFLICT', 'Core Task operation changed during lock transfer.');
844
+ }
845
+ const file = path.join(locksRoot, `${mutex.entityId}.json`);
846
+ let descriptor: number | null = null;
847
+ try {
848
+ descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
849
+ const anchored = fstatSync(descriptor);
850
+ if (!anchored.isFile() || !sameIdentity(issued.identity, anchored)) {
851
+ throw new WorkflowError('STATE_CONFLICT', 'Core Task operation changed while anchoring its handle.');
852
+ }
853
+ } catch (error) {
854
+ if (descriptor !== null) closeSync(descriptor);
855
+ throw error;
856
+ }
857
+ const handle = Object.freeze(Object.create(null)) as CoreTaskOperationHandle;
858
+ coreHandles.set(handle as object, {
859
+ locksRoot: path.resolve(locksRoot),
860
+ ...(trustedRoot === undefined ? {} : { trustedRoot: path.resolve(trustedRoot) }),
861
+ mutex,
862
+ identity: issued.identity,
863
+ descriptor,
864
+ });
865
+ return handle;
866
+ }
867
+
868
+ function isSupportedCoreTaskTransition(value: string): boolean {
869
+ return value === 'task.plan.corrective-replan' || value === 'task.coordination.corrective-yield';
870
+ }
871
+
872
+ function assertHash64(value: unknown, label: string): asserts value is string {
873
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
874
+ throw new WorkflowError('STATE_CORRUPT', `${label} must be a lowercase SHA-256 value.`);
875
+ }
876
+ }
877
+
878
+ function assertLockEntity(entityId: string): void {
879
+ if (!LOCK_ENTITY_PATTERN.test(entityId)) {
880
+ throw new WorkflowError('INVALID_ARGUMENT', `Invalid lock entity: ${entityId}`);
881
+ }
882
+ }
883
+
884
+ function sameIdentity(
885
+ left: Pick<Stats, 'dev' | 'ino'>,
886
+ right: Pick<Stats, 'dev' | 'ino'>,
887
+ ): boolean {
888
+ return left.dev === right.dev && left.ino === right.ino;
889
+ }
890
+
891
+ function lstatIfPresent(candidate: string): Stats | null {
892
+ try {
893
+ return lstatSync(candidate);
894
+ } catch (error) {
895
+ if (isNodeError(error) && error.code === 'ENOENT') return null;
896
+ throw error;
897
+ }
898
+ }
899
+
900
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
901
+ return error instanceof Error && 'code' in error;
902
+ }