astrocode-workflow 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/dist/index.js +6 -0
  2. package/dist/shared/metrics.d.ts +66 -0
  3. package/dist/shared/metrics.js +112 -0
  4. package/dist/src/agents/commands.d.ts +9 -0
  5. package/dist/src/agents/commands.js +121 -0
  6. package/dist/src/agents/prompts.d.ts +3 -0
  7. package/dist/src/agents/prompts.js +232 -0
  8. package/dist/src/agents/registry.d.ts +6 -0
  9. package/dist/src/agents/registry.js +242 -0
  10. package/dist/src/agents/types.d.ts +14 -0
  11. package/dist/src/agents/types.js +8 -0
  12. package/dist/src/astro/workflow-runner.d.ts +11 -0
  13. package/dist/src/astro/workflow-runner.js +14 -0
  14. package/dist/src/config/config-handler.d.ts +4 -0
  15. package/dist/src/config/config-handler.js +46 -0
  16. package/dist/src/config/defaults.d.ts +3 -0
  17. package/dist/src/config/defaults.js +3 -0
  18. package/dist/src/config/loader.d.ts +11 -0
  19. package/dist/src/config/loader.js +82 -0
  20. package/dist/src/config/schema.d.ts +195 -0
  21. package/dist/src/config/schema.js +224 -0
  22. package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
  23. package/dist/src/hooks/continuation-enforcer.js +190 -0
  24. package/dist/src/hooks/inject-provider.d.ts +27 -0
  25. package/dist/src/hooks/inject-provider.js +189 -0
  26. package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
  27. package/dist/src/hooks/tool-output-truncator.js +57 -0
  28. package/dist/src/index.d.ts +3 -0
  29. package/dist/src/index.js +307 -0
  30. package/dist/src/shared/deep-merge.d.ts +8 -0
  31. package/dist/src/shared/deep-merge.js +25 -0
  32. package/dist/src/shared/hash.d.ts +1 -0
  33. package/dist/src/shared/hash.js +4 -0
  34. package/dist/src/shared/log.d.ts +7 -0
  35. package/dist/src/shared/log.js +24 -0
  36. package/dist/src/shared/metrics.d.ts +66 -0
  37. package/dist/src/shared/metrics.js +112 -0
  38. package/dist/src/shared/model-tuning.d.ts +9 -0
  39. package/dist/src/shared/model-tuning.js +28 -0
  40. package/dist/src/shared/paths.d.ts +19 -0
  41. package/dist/src/shared/paths.js +64 -0
  42. package/dist/src/shared/text.d.ts +4 -0
  43. package/dist/src/shared/text.js +19 -0
  44. package/dist/src/shared/time.d.ts +1 -0
  45. package/dist/src/shared/time.js +3 -0
  46. package/dist/src/state/adapters/index.d.ts +41 -0
  47. package/dist/src/state/adapters/index.js +115 -0
  48. package/dist/src/state/db.d.ts +16 -0
  49. package/dist/src/state/db.js +225 -0
  50. package/dist/src/state/ids.d.ts +8 -0
  51. package/dist/src/state/ids.js +25 -0
  52. package/dist/src/state/repo-lock.d.ts +67 -0
  53. package/dist/src/state/repo-lock.js +580 -0
  54. package/dist/src/state/schema.d.ts +2 -0
  55. package/dist/src/state/schema.js +258 -0
  56. package/dist/src/state/types.d.ts +71 -0
  57. package/dist/src/state/types.js +1 -0
  58. package/dist/src/state/workflow-repo-lock.d.ts +23 -0
  59. package/dist/src/state/workflow-repo-lock.js +83 -0
  60. package/dist/src/tools/artifacts.d.ts +18 -0
  61. package/dist/src/tools/artifacts.js +71 -0
  62. package/dist/src/tools/health.d.ts +8 -0
  63. package/dist/src/tools/health.js +88 -0
  64. package/dist/src/tools/index.d.ts +20 -0
  65. package/dist/src/tools/index.js +94 -0
  66. package/dist/src/tools/init.d.ts +17 -0
  67. package/dist/src/tools/init.js +96 -0
  68. package/dist/src/tools/injects.d.ts +53 -0
  69. package/dist/src/tools/injects.js +325 -0
  70. package/dist/src/tools/lock.d.ts +4 -0
  71. package/dist/src/tools/lock.js +78 -0
  72. package/dist/src/tools/metrics.d.ts +7 -0
  73. package/dist/src/tools/metrics.js +61 -0
  74. package/dist/src/tools/repair.d.ts +8 -0
  75. package/dist/src/tools/repair.js +26 -0
  76. package/dist/src/tools/reset.d.ts +8 -0
  77. package/dist/src/tools/reset.js +92 -0
  78. package/dist/src/tools/run.d.ts +13 -0
  79. package/dist/src/tools/run.js +54 -0
  80. package/dist/src/tools/spec.d.ts +12 -0
  81. package/dist/src/tools/spec.js +44 -0
  82. package/dist/src/tools/stage.d.ts +23 -0
  83. package/dist/src/tools/stage.js +371 -0
  84. package/dist/src/tools/status.d.ts +8 -0
  85. package/dist/src/tools/status.js +125 -0
  86. package/dist/src/tools/story.d.ts +23 -0
  87. package/dist/src/tools/story.js +85 -0
  88. package/dist/src/tools/workflow.d.ts +13 -0
  89. package/dist/src/tools/workflow.js +345 -0
  90. package/dist/src/ui/inject.d.ts +12 -0
  91. package/dist/src/ui/inject.js +107 -0
  92. package/dist/src/ui/toasts.d.ts +13 -0
  93. package/dist/src/ui/toasts.js +39 -0
  94. package/dist/src/workflow/artifacts.d.ts +24 -0
  95. package/dist/src/workflow/artifacts.js +45 -0
  96. package/dist/src/workflow/baton.d.ts +72 -0
  97. package/dist/src/workflow/baton.js +166 -0
  98. package/dist/src/workflow/context.d.ts +20 -0
  99. package/dist/src/workflow/context.js +113 -0
  100. package/dist/src/workflow/directives.d.ts +39 -0
  101. package/dist/src/workflow/directives.js +137 -0
  102. package/dist/src/workflow/repair.d.ts +8 -0
  103. package/dist/src/workflow/repair.js +99 -0
  104. package/dist/src/workflow/state-machine.d.ts +86 -0
  105. package/dist/src/workflow/state-machine.js +216 -0
  106. package/dist/src/workflow/story-helpers.d.ts +9 -0
  107. package/dist/src/workflow/story-helpers.js +13 -0
  108. package/dist/state/db.d.ts +1 -0
  109. package/dist/state/db.js +9 -0
  110. package/dist/state/repo-lock.d.ts +3 -0
  111. package/dist/state/repo-lock.js +29 -0
  112. package/dist/test/integration/db-transactions.test.d.ts +1 -0
  113. package/dist/test/integration/db-transactions.test.js +126 -0
  114. package/dist/test/integration/injection-metrics.test.d.ts +1 -0
  115. package/dist/test/integration/injection-metrics.test.js +129 -0
  116. package/dist/tools/health.d.ts +8 -0
  117. package/dist/tools/health.js +119 -0
  118. package/dist/tools/index.js +9 -0
  119. package/dist/tools/metrics.d.ts +7 -0
  120. package/dist/tools/metrics.js +61 -0
  121. package/dist/tools/reset.d.ts +8 -0
  122. package/dist/tools/reset.js +92 -0
  123. package/dist/tools/workflow.js +178 -168
  124. package/dist/ui/inject.js +21 -9
  125. package/package.json +6 -4
  126. package/src/astro/workflow-runner.ts +16 -0
  127. package/src/config/schema.ts +1 -0
  128. package/src/hooks/inject-provider.ts +94 -14
  129. package/src/index.ts +7 -0
  130. package/src/shared/metrics.ts +148 -0
  131. package/src/state/db.ts +10 -1
  132. package/src/state/schema.ts +8 -1
  133. package/src/tools/health.ts +99 -0
  134. package/src/tools/index.ts +12 -3
  135. package/src/tools/init.ts +7 -6
  136. package/src/tools/metrics.ts +71 -0
  137. package/src/tools/repair.ts +8 -4
  138. package/src/tools/reset.ts +100 -0
  139. package/src/tools/stage.ts +1 -0
  140. package/src/tools/status.ts +2 -1
  141. package/src/tools/story.ts +1 -0
  142. package/src/tools/workflow.ts +2 -0
  143. package/src/ui/inject.ts +21 -9
  144. package/src/workflow/repair.ts +2 -2
@@ -0,0 +1,580 @@
1
+ // src/state/repo-lock.ts
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ const LOCK_VERSION = 2;
6
+ // Process-stable identifier for this Node process instance.
7
+ const PROCESS_INSTANCE_ID = crypto.randomUUID();
8
+ // Hard guardrails against garbage/corruption.
9
+ const MAX_LOCK_BYTES = 64 * 1024; // 64KB; lock file should be tiny.
10
+ // How many times we’ll attempt "atomic-ish replace" before giving up.
11
+ const ATOMIC_REPLACE_RETRIES = 3;
12
+ function nowISO() {
13
+ return new Date().toISOString();
14
+ }
15
+ function sleep(ms) {
16
+ return new Promise((r) => setTimeout(r, ms));
17
+ }
18
+ /**
19
+ * PID existence check:
20
+ * - EPERM => process exists but we can't signal it (treat as alive)
21
+ * - ESRCH => process does not exist (dead)
22
+ */
23
+ function isPidAlive(pid) {
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch (err) {
29
+ const code = err?.code;
30
+ if (code === "EPERM")
31
+ return true;
32
+ if (code === "ESRCH")
33
+ return false;
34
+ // Unknown: conservative = don't evict.
35
+ return true;
36
+ }
37
+ }
38
+ function parseISOToMs(iso) {
39
+ const t = Date.parse(iso);
40
+ if (Number.isNaN(t))
41
+ return null;
42
+ return t;
43
+ }
44
+ function isStaleByAge(existing, staleMs) {
45
+ const updatedMs = parseISOToMs(existing.updated_at);
46
+ if (updatedMs === null)
47
+ return true;
48
+ return Date.now() - updatedMs > staleMs;
49
+ }
50
+ function safeUnlink(p) {
51
+ try {
52
+ fs.unlinkSync(p);
53
+ }
54
+ catch {
55
+ // ignore
56
+ }
57
+ }
58
+ /**
59
+ * Reads & validates lock file defensively.
60
+ * Supports both v2 JSON format and legacy PID-only format for compatibility.
61
+ * Returns null on any parse/validation failure.
62
+ */
63
+ function readLock(lockPath) {
64
+ try {
65
+ const st = fs.statSync(lockPath);
66
+ if (!st.isFile())
67
+ return null;
68
+ if (st.size <= 0 || st.size > MAX_LOCK_BYTES)
69
+ return null;
70
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
71
+ // Try v2 JSON first
72
+ try {
73
+ const parsed = JSON.parse(raw);
74
+ if (parsed && typeof parsed === "object" && parsed.v === LOCK_VERSION) {
75
+ if (typeof parsed.pid !== "number")
76
+ return null;
77
+ if (typeof parsed.created_at !== "string")
78
+ return null;
79
+ if (typeof parsed.updated_at !== "string")
80
+ return null;
81
+ if (typeof parsed.repo_root !== "string")
82
+ return null;
83
+ if (typeof parsed.instance_id !== "string")
84
+ return null;
85
+ if (typeof parsed.lease_id !== "string")
86
+ return null;
87
+ if (parsed.session_id !== undefined && typeof parsed.session_id !== "string")
88
+ return null;
89
+ if (parsed.owner !== undefined && typeof parsed.owner !== "string")
90
+ return null;
91
+ return parsed;
92
+ }
93
+ }
94
+ catch {
95
+ // Not JSON, try legacy format
96
+ }
97
+ // Legacy format: just PID as number string
98
+ const legacyPid = parseInt(raw, 10);
99
+ if (Number.isNaN(legacyPid) || legacyPid <= 0)
100
+ return null;
101
+ // Convert legacy to v2 format
102
+ const now = nowISO();
103
+ const leaseId = crypto.randomUUID();
104
+ return {
105
+ v: LOCK_VERSION,
106
+ pid: legacyPid,
107
+ created_at: now, // Approximate
108
+ updated_at: now,
109
+ repo_root: "", // Unknown, will be filled by caller
110
+ instance_id: PROCESS_INSTANCE_ID, // Assume same instance
111
+ session_id: undefined,
112
+ lease_id: leaseId,
113
+ owner: "legacy-lock",
114
+ };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ /**
121
+ * Best-effort directory fsync:
122
+ * Helps durability on crash for some filesystems (mostly POSIX).
123
+ * On platforms where opening a directory fails, we ignore.
124
+ */
125
+ function fsyncDirBestEffort(dirPath) {
126
+ try {
127
+ const fd = fs.openSync(dirPath, "r");
128
+ try {
129
+ fs.fsyncSync(fd);
130
+ }
131
+ finally {
132
+ fs.closeSync(fd);
133
+ }
134
+ }
135
+ catch {
136
+ // ignore (not portable)
137
+ }
138
+ }
139
+ /**
140
+ * "Atomic-ish" replace:
141
+ * - Write temp file
142
+ * - Try rename over target (POSIX generally atomic)
143
+ * - Windows can fail if target exists/locked; fallback to unlink+rename (not atomic, but best-effort)
144
+ * - Best-effort directory fsync after rename
145
+ */
146
+ function writeLockAtomicish(lockPath, lock) {
147
+ const dir = path.dirname(lockPath);
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ const tmp = `${lockPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
150
+ const body = JSON.stringify(lock); // compact JSON to reduce IO
151
+ fs.writeFileSync(tmp, body, "utf8");
152
+ let lastErr = null;
153
+ for (let i = 0; i < ATOMIC_REPLACE_RETRIES; i++) {
154
+ try {
155
+ fs.renameSync(tmp, lockPath);
156
+ fsyncDirBestEffort(dir);
157
+ return;
158
+ }
159
+ catch (err) {
160
+ lastErr = err;
161
+ const code = err?.code;
162
+ // Common Windows-ish cases where rename over existing fails.
163
+ if (code === "EEXIST" || code === "EPERM" || code === "ENOTEMPTY") {
164
+ safeUnlink(lockPath);
165
+ continue;
166
+ }
167
+ // If tmp vanished somehow, stop.
168
+ if (code === "ENOENT")
169
+ break;
170
+ continue;
171
+ }
172
+ }
173
+ safeUnlink(tmp);
174
+ if (lastErr)
175
+ throw lastErr;
176
+ throw new Error(`Failed to replace lock file: ${lockPath}`);
177
+ }
178
+ /**
179
+ * Atomic "create if not exists" using exclusive open.
180
+ */
181
+ function tryCreateExclusiveFile(filePath, contentsUtf8) {
182
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
183
+ try {
184
+ const fd = fs.openSync(filePath, "wx");
185
+ try {
186
+ fs.writeFileSync(fd, contentsUtf8, "utf8");
187
+ fs.fsyncSync(fd);
188
+ }
189
+ finally {
190
+ fs.closeSync(fd);
191
+ }
192
+ fsyncDirBestEffort(path.dirname(filePath));
193
+ return true;
194
+ }
195
+ catch (err) {
196
+ if (err?.code === "EEXIST")
197
+ return false;
198
+ throw err;
199
+ }
200
+ }
201
+ function tryCreateRepoLockExclusive(lockPath, lock) {
202
+ return tryCreateExclusiveFile(lockPath, JSON.stringify(lock));
203
+ }
204
+ const ACTIVE_LOCKS = new Map();
205
+ function cacheKey(lockPath, sessionId) {
206
+ return `${lockPath}::${sessionId ?? ""}`;
207
+ }
208
+ /**
209
+ * Heartbeat loop:
210
+ * - setTimeout (not setInterval) to avoid backlog drift under load
211
+ * - Minimizes writes by enforcing minWriteMs
212
+ * - ABA-safe: only refreshes if lock matches our lease_id and process identity
213
+ * - Avoids unnecessary writes if lock already has a recent updated_at
214
+ */
215
+ function startHeartbeat(opts) {
216
+ let stopped = false;
217
+ let lastWriteAt = 0;
218
+ let timer = null;
219
+ const tick = () => {
220
+ if (stopped)
221
+ return;
222
+ const now = Date.now();
223
+ const shouldAttempt = now - lastWriteAt >= opts.minWriteMs;
224
+ if (shouldAttempt) {
225
+ try {
226
+ const existing = readLock(opts.lockPath);
227
+ if (existing &&
228
+ existing.lease_id === opts.leaseId &&
229
+ existing.pid === process.pid &&
230
+ existing.instance_id === PROCESS_INSTANCE_ID) {
231
+ const updatedMs = parseISOToMs(existing.updated_at);
232
+ const isFresh = updatedMs !== null && now - updatedMs < opts.minWriteMs;
233
+ if (!isFresh) {
234
+ writeLockAtomicish(opts.lockPath, {
235
+ ...existing,
236
+ updated_at: nowISO(),
237
+ repo_root: opts.repoRoot,
238
+ session_id: opts.sessionId ?? existing.session_id,
239
+ owner: opts.owner ?? existing.owner,
240
+ });
241
+ lastWriteAt = now;
242
+ }
243
+ else {
244
+ lastWriteAt = now;
245
+ }
246
+ }
247
+ }
248
+ catch (err) {
249
+ // Heartbeat write failed - don't propagate, just reschedule
250
+ // Lock will become stale if heartbeat continues failing
251
+ // eslint-disable-next-line no-console
252
+ console.warn("[Astrocode] Heartbeat write error:", err);
253
+ }
254
+ }
255
+ timer = setTimeout(tick, opts.heartbeatMs);
256
+ timer.unref?.();
257
+ };
258
+ tick();
259
+ return () => {
260
+ stopped = true;
261
+ if (timer)
262
+ clearTimeout(timer);
263
+ };
264
+ }
265
+ /**
266
+ * Shutdown cleanup:
267
+ * Best-effort release on normal termination signals.
268
+ */
269
+ let EXIT_HOOK_INSTALLED = false;
270
+ function installExitHookOnce() {
271
+ if (EXIT_HOOK_INSTALLED)
272
+ return;
273
+ EXIT_HOOK_INSTALLED = true;
274
+ const cleanup = () => {
275
+ for (const [key, h] of ACTIVE_LOCKS.entries()) {
276
+ try {
277
+ ACTIVE_LOCKS.delete(key);
278
+ h.heartbeatStop();
279
+ h.releaseOnce();
280
+ }
281
+ catch {
282
+ // ignore
283
+ }
284
+ }
285
+ };
286
+ process.once("exit", cleanup);
287
+ process.once("SIGINT", () => {
288
+ cleanup();
289
+ process.exit(130);
290
+ });
291
+ process.once("SIGTERM", () => {
292
+ cleanup();
293
+ process.exit(143);
294
+ });
295
+ process.once("uncaughtException", (err) => {
296
+ // eslint-disable-next-line no-console
297
+ console.error("[Astrocode] Uncaught Exception, cleaning up locks:", err);
298
+ cleanup();
299
+ process.exit(1);
300
+ });
301
+ process.once("unhandledRejection", (reason) => {
302
+ // eslint-disable-next-line no-console
303
+ console.error("[Astrocode] Unhandled Rejection, cleaning up locks:", reason);
304
+ cleanup();
305
+ process.exit(1);
306
+ });
307
+ }
308
+ /**
309
+ * Acquire a repo-scoped lock with:
310
+ * - ✅ process-local caching + refcount (efficient repeated tool calls)
311
+ * - ✅ heartbeat lease + stale recovery
312
+ * - ✅ atomic create (`wx`) + portable replace fallback
313
+ * - ✅ dead PID eviction + stale eviction
314
+ * - ✅ no live takeover (even same session) to avoid concurrency stomps
315
+ * - ✅ ABA-safe release via lease_id fencing
316
+ * - ✅ exponential backoff + jitter to reduce FS churn
317
+ */
318
+ export async function acquireRepoLock(opts) {
319
+ installExitHookOnce();
320
+ const { lockPath, repoRoot, sessionId, owner } = opts;
321
+ const retryMs = opts.retryMs ?? 8000;
322
+ const pollBaseMs = opts.pollMs ?? 20;
323
+ const pollMaxMs = opts.pollMaxMs ?? 250;
324
+ const heartbeatMs = opts.heartbeatMs ?? 200;
325
+ const minWriteMs = opts.minWriteMs ?? 800;
326
+ // Ensure stale is comfortably above minWriteMs to prevent false-stale under load.
327
+ const staleMs = Math.max(opts.staleMs ?? 2 * 60 * 1000, minWriteMs * 8);
328
+ // ✅ Fast path: reuse cached handle in the same process/session.
329
+ const key = cacheKey(lockPath, sessionId);
330
+ const cached = ACTIVE_LOCKS.get(key);
331
+ if (cached) {
332
+ cached.refCount += 1;
333
+ return {
334
+ release: () => {
335
+ cached.refCount -= 1;
336
+ if (cached.refCount <= 0) {
337
+ ACTIVE_LOCKS.delete(key);
338
+ cached.heartbeatStop();
339
+ cached.releaseOnce();
340
+ }
341
+ },
342
+ };
343
+ }
344
+ const myPid = process.pid;
345
+ const startedAt = Date.now();
346
+ let pollMs = pollBaseMs;
347
+ while (true) {
348
+ const existing = readLock(lockPath);
349
+ // No lock (or unreadable/invalid) -> try create.
350
+ if (!existing) {
351
+ const now = nowISO();
352
+ const leaseId = crypto.randomUUID();
353
+ const candidate = {
354
+ v: LOCK_VERSION,
355
+ pid: myPid,
356
+ created_at: now,
357
+ updated_at: now,
358
+ repo_root: repoRoot,
359
+ instance_id: PROCESS_INSTANCE_ID,
360
+ session_id: sessionId,
361
+ lease_id: leaseId,
362
+ owner,
363
+ };
364
+ const created = tryCreateRepoLockExclusive(lockPath, candidate);
365
+ if (created) {
366
+ const heartbeatStop = startHeartbeat({
367
+ lockPath,
368
+ repoRoot,
369
+ sessionId,
370
+ owner,
371
+ leaseId,
372
+ heartbeatMs,
373
+ minWriteMs,
374
+ });
375
+ const releaseOnce = () => {
376
+ const cur = readLock(lockPath);
377
+ if (!cur)
378
+ return;
379
+ // ABA-safe
380
+ if (cur.lease_id !== leaseId)
381
+ return;
382
+ // Strict identity: only exact process instance can delete.
383
+ if (cur.pid !== myPid)
384
+ return;
385
+ if (cur.instance_id !== PROCESS_INSTANCE_ID)
386
+ return;
387
+ safeUnlink(lockPath);
388
+ fsyncDirBestEffort(path.dirname(lockPath));
389
+ };
390
+ const handle = {
391
+ key,
392
+ lockPath,
393
+ sessionId,
394
+ leaseId,
395
+ refCount: 1,
396
+ heartbeatStop,
397
+ releaseOnce,
398
+ };
399
+ ACTIVE_LOCKS.set(key, handle);
400
+ return {
401
+ release: () => {
402
+ const h = ACTIVE_LOCKS.get(key);
403
+ if (!h)
404
+ return;
405
+ h.refCount -= 1;
406
+ if (h.refCount <= 0) {
407
+ ACTIVE_LOCKS.delete(key);
408
+ h.heartbeatStop();
409
+ h.releaseOnce();
410
+ }
411
+ },
412
+ };
413
+ }
414
+ // Race lost; reset backoff and loop.
415
+ pollMs = pollBaseMs;
416
+ continue;
417
+ }
418
+ // Re-entrant by SAME PROCESS IDENTITY (pid+instance), or legacy lock with same PID.
419
+ if (existing.pid === myPid && (existing.instance_id === PROCESS_INSTANCE_ID || existing.owner === "legacy-lock")) {
420
+ const leaseId = crypto.randomUUID();
421
+ writeLockAtomicish(lockPath, {
422
+ ...existing,
423
+ v: LOCK_VERSION,
424
+ updated_at: nowISO(),
425
+ repo_root: repoRoot,
426
+ instance_id: PROCESS_INSTANCE_ID, // Upgrade legacy
427
+ session_id: sessionId ?? existing.session_id,
428
+ owner: owner ?? existing.owner,
429
+ lease_id: leaseId,
430
+ });
431
+ const heartbeatStop = startHeartbeat({
432
+ lockPath,
433
+ repoRoot,
434
+ sessionId: sessionId ?? existing.session_id,
435
+ owner: owner ?? existing.owner,
436
+ leaseId,
437
+ heartbeatMs,
438
+ minWriteMs,
439
+ });
440
+ const releaseOnce = () => {
441
+ const cur = readLock(lockPath);
442
+ if (!cur)
443
+ return;
444
+ if (cur.lease_id !== leaseId)
445
+ return;
446
+ if (cur.pid !== myPid)
447
+ return;
448
+ if (cur.instance_id !== PROCESS_INSTANCE_ID)
449
+ return;
450
+ safeUnlink(lockPath);
451
+ fsyncDirBestEffort(path.dirname(lockPath));
452
+ };
453
+ const handle = {
454
+ key,
455
+ lockPath,
456
+ sessionId,
457
+ leaseId,
458
+ refCount: 1,
459
+ heartbeatStop,
460
+ releaseOnce,
461
+ };
462
+ ACTIVE_LOCKS.set(key, handle);
463
+ return {
464
+ release: () => {
465
+ const h = ACTIVE_LOCKS.get(key);
466
+ if (!h)
467
+ return;
468
+ h.refCount -= 1;
469
+ if (h.refCount <= 0) {
470
+ ACTIVE_LOCKS.delete(key);
471
+ h.heartbeatStop();
472
+ h.releaseOnce();
473
+ }
474
+ },
475
+ };
476
+ }
477
+ // 🚫 No live takeover (even same session).
478
+ // We only evict dead/stale locks.
479
+ const pidAlive = isPidAlive(existing.pid);
480
+ const staleByAge = isStaleByAge(existing, staleMs);
481
+ if (!pidAlive || staleByAge) {
482
+ safeUnlink(lockPath);
483
+ fsyncDirBestEffort(path.dirname(lockPath));
484
+ pollMs = pollBaseMs;
485
+ continue;
486
+ }
487
+ // Alive and not us -> bounded wait with exponential backoff + jitter.
488
+ if (Date.now() - startedAt > retryMs) {
489
+ const ownerBits = [
490
+ `pid=${existing.pid}`,
491
+ existing.session_id ? `session=${existing.session_id}` : null,
492
+ existing.owner ? `owner=${existing.owner}` : null,
493
+ `updated_at=${existing.updated_at}`,
494
+ sessionId && existing.session_id === sessionId ? `(same-session waiting)` : null,
495
+ ]
496
+ .filter(Boolean)
497
+ .join(" ");
498
+ throw new Error(`Astrocode lock is already held (${lockPath}). ${ownerBits}. ` +
499
+ `Close other opencode processes or wait.`);
500
+ }
501
+ const jitter = Math.floor(Math.random() * Math.min(12, pollMs));
502
+ await sleep(pollMs + jitter);
503
+ pollMs = Math.min(pollMaxMs, Math.floor(pollMs * 1.35));
504
+ }
505
+ }
506
+ /**
507
+ * Helper wrapper: always releases lock.
508
+ */
509
+ export async function withRepoLock(opts) {
510
+ const handle = await acquireRepoLock({
511
+ lockPath: opts.lockPath,
512
+ repoRoot: opts.repoRoot,
513
+ sessionId: opts.sessionId,
514
+ owner: opts.owner,
515
+ });
516
+ try {
517
+ return await opts.fn();
518
+ }
519
+ finally {
520
+ handle.release();
521
+ }
522
+ }
523
+ /**
524
+ * Get lock file status and diagnostics.
525
+ * Returns detailed information about the current lock state.
526
+ */
527
+ export function getLockStatus(lockPath, staleMs = 30_000) {
528
+ const existing = readLock(lockPath);
529
+ if (!existing) {
530
+ return {
531
+ exists: false,
532
+ path: lockPath,
533
+ };
534
+ }
535
+ const updatedMs = parseISOToMs(existing.updated_at);
536
+ const ageMs = updatedMs !== null ? Date.now() - updatedMs : undefined;
537
+ const pidAlive = isPidAlive(existing.pid);
538
+ const isStale = isStaleByAge(existing, staleMs);
539
+ return {
540
+ exists: true,
541
+ path: lockPath,
542
+ pid: existing.pid,
543
+ pidAlive,
544
+ instanceId: existing.instance_id,
545
+ sessionId: existing.session_id,
546
+ owner: existing.owner,
547
+ leaseId: existing.lease_id,
548
+ createdAt: existing.created_at,
549
+ updatedAt: existing.updated_at,
550
+ ageMs,
551
+ isStale,
552
+ repoRoot: existing.repo_root,
553
+ version: existing.v,
554
+ };
555
+ }
556
+ /**
557
+ * Attempt to remove a lock file if it's safe to do so.
558
+ * Only removes locks with dead PIDs or stale timestamps.
559
+ * Returns true if lock was removed, false if lock is still held.
560
+ */
561
+ export function tryRemoveStaleLock(lockPath, staleMs = 30_000) {
562
+ const existing = readLock(lockPath);
563
+ if (!existing) {
564
+ return { removed: false, reason: "No lock file found" };
565
+ }
566
+ const pidAlive = isPidAlive(existing.pid);
567
+ const isStale = isStaleByAge(existing, staleMs);
568
+ if (!pidAlive) {
569
+ safeUnlink(lockPath);
570
+ fsyncDirBestEffort(path.dirname(lockPath));
571
+ return { removed: true, reason: `Dead PID ${existing.pid}` };
572
+ }
573
+ if (isStale) {
574
+ safeUnlink(lockPath);
575
+ fsyncDirBestEffort(path.dirname(lockPath));
576
+ const ageSeconds = Math.floor((Date.now() - (parseISOToMs(existing.updated_at) ?? 0)) / 1000);
577
+ return { removed: true, reason: `Stale lock (${ageSeconds}s old, threshold ${staleMs / 1000}s)` };
578
+ }
579
+ return { removed: false, reason: `Lock is active (PID ${existing.pid} alive, age ${Math.floor((Date.now() - (parseISOToMs(existing.updated_at) ?? 0)) / 1000)}s)` };
580
+ }
@@ -0,0 +1,2 @@
1
+ export declare const SCHEMA_VERSION = 3;
2
+ export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\n\n-- CONSTRAINT: Only one running run at a time (partial unique index)\n-- This provides database-level safety when using advisory locks\nCREATE UNIQUE INDEX IF NOT EXISTS idx_single_running_run ON runs(status) WHERE status = 'running';\n\n-- CONSTRAINT: Only one run can lock a story at a time (partial unique index)\nCREATE UNIQUE INDEX IF NOT EXISTS idx_single_story_lock ON stories(in_progress) WHERE in_progress = 1;\nCREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";