jettypod 4.4.118 → 4.4.121

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 (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
package/lib/merge-lock.js CHANGED
@@ -1,18 +1,13 @@
1
1
  /**
2
2
  * Merge Lock - Coordination for Concurrent Instances
3
3
  *
4
- * This module provides lock acquisition and release for merge operations
5
- * to prevent simultaneous access to main branch across multiple Claude Code instances.
4
+ * Prevents simultaneous merge operations on main branch.
6
5
  *
7
- * Core principles:
8
- * - Database is coordination point (simple, reliable serialization)
9
- * - Polling mechanism with exponential backoff
10
- * - Automatic cleanup with release() function
11
- * - Queue position awareness for UX
12
- * - Process exit handlers for graceful cleanup on SIGINT/SIGTERM/SIGHUP
13
- * - Global error handlers (uncaughtException/unhandledRejection) prevent orphaned locks
14
- * - Heartbeat failure detection triggers lock release
15
- * - Hard timeout (5 min default) kills locks regardless of heartbeat - prevents zombie processes
6
+ * Stale detection uses PID liveness checks (process.kill(pid, 0)) instead of
7
+ * heartbeats. If the process that created the lock is dead, the lock is stale.
8
+ * A 5-minute absolute timeout is kept as a safety net for edge cases (PID reuse).
9
+ *
10
+ * No setInterval, no heartbeat, no timers that keep the event loop alive.
16
11
  */
17
12
 
18
13
  const os = require('os');
@@ -22,6 +17,38 @@ let activeLockCleanup = null;
22
17
  // Track if global error handlers are registered (only register once per process)
23
18
  let globalErrorHandlersRegistered = false;
24
19
 
20
+ // Default max lock age: 5 minutes (safety net for PID reuse edge cases)
21
+ const DEFAULT_MAX_LOCK_AGE_MS = 5 * 60 * 1000;
22
+
23
+ /**
24
+ * Check if a process is alive by sending signal 0
25
+ *
26
+ * @param {number} pid - Process ID to check
27
+ * @returns {boolean} true if process is alive
28
+ */
29
+ function isProcessAlive(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch (err) {
34
+ // ESRCH = no such process, EPERM = process exists but we can't signal it (still alive)
35
+ return err.code === 'EPERM';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Parse PID from locked_by string (format: "hostname-pid")
41
+ *
42
+ * @param {string} lockedBy - Instance identifier
43
+ * @returns {number|null} PID or null if unparseable
44
+ */
45
+ function parsePid(lockedBy) {
46
+ if (!lockedBy) return null;
47
+ const parts = lockedBy.split('-');
48
+ const pid = parseInt(parts[parts.length - 1], 10);
49
+ return isNaN(pid) ? null : pid;
50
+ }
51
+
25
52
  /**
26
53
  * Acquire merge lock for a work item
27
54
  *
@@ -43,10 +70,8 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
43
70
  throw new Error('Invalid work item ID: must be a number');
44
71
  }
45
72
 
46
- const maxWait = options.maxWait || 60000; // 1 minute default
47
- const pollInterval = options.pollInterval || 1500; // 1.5 seconds
48
- const staleThreshold = options.staleThreshold || 15000; // 15 seconds - heartbeat-based stale detection
49
- const heartbeatInterval = options.heartbeatInterval || 5000; // 5 seconds default
73
+ const maxWait = options.maxWait || 60000;
74
+ const pollInterval = options.pollInterval || 1500;
50
75
  const startTime = Date.now();
51
76
 
52
77
  // Generate instance identifier
@@ -54,56 +79,42 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
54
79
 
55
80
  let pollCount = 0;
56
81
  let lastStatusTime = 0;
57
- const statusInterval = 10000; // Show status every 10 seconds
82
+ const statusInterval = 10000;
58
83
 
59
84
  while (Date.now() - startTime < maxWait) {
60
- // Cleanup stale locks every 5 polls (every ~10 seconds with 2s poll interval)
61
- // This ensures orphaned locks get cleaned up during the wait
85
+ // Cleanup stale locks every 5 polls
62
86
  if (pollCount % 5 === 0) {
63
87
  try {
64
- const cleaned = await cleanupStaleLocks(db, staleThreshold);
88
+ const cleaned = await cleanupStaleLocks(db);
65
89
  if (cleaned > 0) {
66
90
  console.log(`🧹 Cleaned ${cleaned} stale merge lock(s)`);
67
91
  }
68
92
  } catch (cleanupErr) {
69
- // Log but don't fail - cleanup is best-effort
70
93
  console.warn(`Warning: Failed to cleanup stale locks: ${cleanupErr.message}`);
71
94
  }
72
95
  }
73
96
  pollCount++;
74
97
 
75
- // Check if lock exists
76
98
  const existingLock = await checkExistingLock(db);
77
99
 
78
100
  if (!existingLock) {
79
- // Try to acquire lock
80
101
  try {
81
102
  const lockId = await insertLock(db, workItemId, lockedBy);
82
-
83
- // Return lock handle with release function and heartbeat
84
- return createLockHandle(db, lockId, lockedBy, workItemId, { heartbeatInterval });
103
+ return createLockHandle(db, lockId, lockedBy, workItemId);
85
104
  } catch (err) {
86
- // Race condition - someone else got it first
87
- // Continue polling
105
+ // Race condition - someone else got it first, continue polling
88
106
  }
89
107
  } else {
90
- // Show progress when waiting for existing lock
91
108
  const now = Date.now();
92
109
  if (now - lastStatusTime >= statusInterval) {
93
110
  const waitedSeconds = Math.round((now - startTime) / 1000);
94
- const lockAge = await getLockAge(db, existingLock.id);
95
- const staleIn = Math.max(0, Math.round((staleThreshold - lockAge) / 1000));
96
-
97
- if (staleIn > 0) {
98
- console.log(`⏳ Waiting for merge lock... (${waitedSeconds}s elapsed, lock expires in ~${staleIn}s)`);
99
- } else {
100
- console.log(`⏳ Waiting for merge lock... (${waitedSeconds}s elapsed, cleaning up stale lock)`);
101
- }
111
+ const pid = parsePid(existingLock.locked_by);
112
+ const alive = pid ? isProcessAlive(pid) : 'unknown';
113
+ console.log(`⏳ Waiting for merge lock... (${waitedSeconds}s elapsed, holder PID ${pid || '?'} alive=${alive})`);
102
114
  lastStatusTime = now;
103
115
  }
104
116
  }
105
117
 
106
- // Wait before retrying
107
118
  await sleep(pollInterval);
108
119
  }
109
120
 
@@ -112,9 +123,6 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
112
123
 
113
124
  /**
114
125
  * Check if any merge lock currently exists
115
- *
116
- * @param {Object} db - SQLite database connection
117
- * @returns {Promise<Object|null>} Existing lock or null
118
126
  */
119
127
  function checkExistingLock(db) {
120
128
  return new Promise((resolve, reject) => {
@@ -125,34 +133,8 @@ function checkExistingLock(db) {
125
133
  });
126
134
  }
127
135
 
128
- /**
129
- * Get the age of a lock based on last heartbeat in milliseconds
130
- *
131
- * @param {Object} db - SQLite database connection
132
- * @param {number} lockId - Lock ID
133
- * @returns {Promise<number>} Age since last heartbeat in milliseconds
134
- */
135
- function getLockAge(db, lockId) {
136
- return new Promise((resolve, reject) => {
137
- db.get(
138
- `SELECT (julianday('now') - julianday(heartbeat_at)) * 86400000 as age_ms
139
- FROM merge_locks WHERE id = ?`,
140
- [lockId],
141
- (err, row) => {
142
- if (err) reject(err);
143
- else resolve(row ? row.age_ms : 0);
144
- }
145
- );
146
- });
147
- }
148
-
149
136
  /**
150
137
  * Insert lock record into database
151
- *
152
- * @param {Object} db - SQLite database connection
153
- * @param {number} workItemId - Work item ID
154
- * @param {string} lockedBy - Instance identifier
155
- * @returns {Promise<number>} Lock ID
156
138
  */
157
139
  function insertLock(db, workItemId, lockedBy) {
158
140
  return new Promise((resolve, reject) => {
@@ -168,76 +150,86 @@ function insertLock(db, workItemId, lockedBy) {
168
150
  });
169
151
  }
170
152
 
171
- // Default max lock age: 5 minutes (kills zombie processes with active heartbeats)
172
- const DEFAULT_MAX_LOCK_AGE_MS = 5 * 60 * 1000;
173
-
174
153
  /**
175
- * Clean up stale locks based on heartbeat timestamp OR absolute age
154
+ * Clean up stale locks using PID liveness checks + absolute age timeout.
176
155
  *
177
- * Locks are removed if EITHER condition is true:
178
- * 1. Heartbeat is stale (no heartbeat in staleThreshold ms) - catches dead processes
179
- * 2. Lock is too old (created more than maxLockAge ms ago) - catches zombie processes
180
- * with active event loops that keep heartbeating but never complete
156
+ * A lock is stale if:
157
+ * 1. The holding process is dead (PID check), OR
158
+ * 2. The lock is older than maxLockAge (safety net for PID reuse)
181
159
  *
182
160
  * @param {Object} db - SQLite database connection
183
- * @param {number|Object} staleThresholdOrOptions - Age threshold in milliseconds (default: 15000), or options object
184
- * @param {number} staleThresholdOrOptions.staleThreshold - Heartbeat stale threshold in ms (default: 15000)
185
- * @param {number} staleThresholdOrOptions.maxLockAge - Maximum lock age in ms regardless of heartbeat (default: 300000 / 5 min)
161
+ * @param {Object} options - Optional configuration
162
+ * @param {number} options.maxLockAge - Maximum lock age in ms (default: 5 min)
186
163
  * @returns {Promise<number>} Count of locks removed
187
164
  */
188
- function cleanupStaleLocks(db, staleThresholdOrOptions = 15000) {
165
+ function cleanupStaleLocks(db, options = {}) {
189
166
  if (!db) {
190
167
  return Promise.reject(new Error('Database connection required'));
191
168
  }
192
169
 
193
- // Support both number (old API) and options object (new API)
194
- let staleThresholdMs;
195
- let maxLockAgeMs;
196
- if (typeof staleThresholdOrOptions === 'object') {
197
- staleThresholdMs = staleThresholdOrOptions.staleThreshold || 15000;
198
- maxLockAgeMs = staleThresholdOrOptions.maxLockAge || DEFAULT_MAX_LOCK_AGE_MS;
199
- } else {
200
- staleThresholdMs = staleThresholdOrOptions;
201
- maxLockAgeMs = DEFAULT_MAX_LOCK_AGE_MS;
202
- }
170
+ const maxLockAgeMs = (typeof options === 'object' && options.maxLockAge) || DEFAULT_MAX_LOCK_AGE_MS;
203
171
 
204
172
  return new Promise((resolve, reject) => {
205
- db.run(
206
- `DELETE FROM merge_locks
207
- WHERE (julianday('now') - julianday(heartbeat_at)) * 86400000 > ?
208
- OR (julianday('now') - julianday(locked_at)) * 86400000 > ?`,
209
- [staleThresholdMs, maxLockAgeMs],
210
- function(err) {
211
- if (err) return reject(err);
212
- resolve(this.changes); // Return count of deleted locks
173
+ // First get all locks to check PID liveness
174
+ db.all('SELECT id, locked_by, locked_at FROM merge_locks', (err, rows) => {
175
+ if (err) return reject(err);
176
+ if (!rows || rows.length === 0) return resolve(0);
177
+
178
+ const staleIds = [];
179
+
180
+ for (const row of rows) {
181
+ const pid = parsePid(row.locked_by);
182
+
183
+ // Stale if: PID is dead, PID is unparseable, or lock is too old
184
+ if (!pid || !isProcessAlive(pid)) {
185
+ staleIds.push(row.id);
186
+ }
213
187
  }
214
- );
188
+
189
+ // Also delete any locks older than maxLockAge regardless of PID
190
+ // (safety net for PID reuse - extremely unlikely but possible)
191
+ const deleteByAge = new Promise((res, rej) => {
192
+ db.run(
193
+ `DELETE FROM merge_locks
194
+ WHERE (julianday('now') - julianday(locked_at)) * 86400000 > ?`,
195
+ [maxLockAgeMs],
196
+ function(err) {
197
+ if (err) return rej(err);
198
+ res(this.changes);
199
+ }
200
+ );
201
+ });
202
+
203
+ // Delete PID-dead locks
204
+ const deleteByPid = staleIds.length > 0
205
+ ? new Promise((res, rej) => {
206
+ const placeholders = staleIds.map(() => '?').join(',');
207
+ db.run(
208
+ `DELETE FROM merge_locks WHERE id IN (${placeholders})`,
209
+ staleIds,
210
+ function(err) {
211
+ if (err) return rej(err);
212
+ res(this.changes);
213
+ }
214
+ );
215
+ })
216
+ : Promise.resolve(0);
217
+
218
+ Promise.all([deleteByPid, deleteByAge])
219
+ .then(([pidClean, ageClean]) => resolve(Math.max(pidClean, ageClean)))
220
+ .catch(reject);
221
+ });
215
222
  });
216
223
  }
217
224
 
218
225
  /**
219
- * Create lock handle object with release function and heartbeat
220
- *
221
- * @param {Object} db - SQLite database connection
222
- * @param {number} lockId - Lock ID
223
- * @param {string} lockedBy - Instance identifier
224
- * @param {number} workItemId - Work item ID
225
- * @param {Object} options - Optional configuration
226
- * @param {number} options.heartbeatInterval - Heartbeat interval in ms (default: 5000)
227
- * @param {number} options.maxHeartbeatFailures - Max consecutive failures before releasing (default: 2)
228
- * @returns {Object} Lock handle with release() and stopHeartbeat()
226
+ * Create lock handle with release function and signal handlers.
227
+ * No heartbeat, no setInterval — nothing that keeps the event loop alive.
229
228
  */
230
- function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
231
- const heartbeatInterval = options.heartbeatInterval || 5000;
232
- const maxHeartbeatFailures = options.maxHeartbeatFailures || 2;
233
-
234
- // Track if lock has been released (prevents double-release)
229
+ function createLockHandle(db, lockId, lockedBy, workItemId) {
235
230
  let released = false;
236
- // Track if we're in the middle of an exit handler (prevents re-entrancy)
237
231
  let exitInProgress = false;
238
232
 
239
- // Release lock with callback - used by signal handlers
240
- // Waits for DELETE to complete (with timeout) before allowing exit
241
233
  const releaseLockWithCallback = (callback) => {
242
234
  if (released) {
243
235
  callback();
@@ -250,7 +242,6 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
250
242
  return;
251
243
  }
252
244
 
253
- // Set a timeout to ensure we exit even if DELETE hangs
254
245
  const timeoutId = setTimeout(() => {
255
246
  callback();
256
247
  }, 500);
@@ -261,10 +252,7 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
261
252
  });
262
253
  };
263
254
 
264
- // Process exit handlers for graceful cleanup
265
- // CRITICAL: Wait for DELETE to complete before exiting
266
255
  const exitHandler = (signal) => {
267
- // Prevent re-entrancy if multiple signals arrive
268
256
  if (exitInProgress) return;
269
257
  exitInProgress = true;
270
258
 
@@ -272,7 +260,6 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
272
260
  removeExitHandlers();
273
261
 
274
262
  releaseLockWithCallback(() => {
275
- // Re-emit the signal after cleanup so process exits normally
276
263
  process.kill(process.pid, signal);
277
264
  });
278
265
  };
@@ -286,7 +273,6 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
286
273
  });
287
274
  };
288
275
 
289
- // Handler for uncaughtException/unhandledRejection
290
276
  const errorExitHandler = (errType) => (err) => {
291
277
  if (exitInProgress) return;
292
278
  exitInProgress = true;
@@ -300,67 +286,39 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
300
286
  });
301
287
  };
302
288
 
303
- // Register exit handlers
304
289
  const registerExitHandlers = () => {
305
290
  process.on('SIGINT', exitHandler);
306
291
  process.on('SIGTERM', exitHandler);
307
292
  process.on('SIGHUP', exitHandler);
308
293
  process.on('beforeExit', beforeExitHandler);
309
294
 
310
- // Register global error handlers only once per process
311
295
  if (!globalErrorHandlersRegistered) {
312
296
  globalErrorHandlersRegistered = true;
313
297
  process.on('uncaughtException', errorExitHandler('Uncaught Exception'));
314
298
  process.on('unhandledRejection', errorExitHandler('Unhandled Rejection'));
315
299
  }
316
300
 
317
- // Store cleanup function globally so it can be called from anywhere
318
301
  activeLockCleanup = () => releaseLockWithCallback(() => {});
319
302
  };
320
303
 
321
- // Remove exit handlers (called on normal release)
322
304
  const removeExitHandlers = () => {
323
305
  process.removeListener('SIGINT', exitHandler);
324
306
  process.removeListener('SIGTERM', exitHandler);
325
307
  process.removeListener('SIGHUP', exitHandler);
326
308
  process.removeListener('beforeExit', beforeExitHandler);
327
309
  activeLockCleanup = null;
328
- // Note: We don't remove global error handlers - they stay for the process lifetime
329
310
  };
330
311
 
331
- // Register handlers immediately
332
312
  registerExitHandlers();
333
313
 
334
- // Start heartbeat with failure detection
335
- const heartbeat = startHeartbeat(db, lockId, heartbeatInterval, {
336
- maxFailures: maxHeartbeatFailures,
337
- onMaxFailures: () => {
338
- console.error(`❌ Heartbeat failed ${maxHeartbeatFailures} consecutive times - releasing lock`);
339
- removeExitHandlers();
340
- releaseLockWithCallback(() => {});
341
- }
342
- });
343
-
344
314
  return {
345
315
  id: lockId,
346
316
  locked_by: lockedBy,
347
317
  work_item_id: workItemId,
348
- stopHeartbeat: () => {
349
- heartbeat.stop();
350
- },
351
318
  release: async () => {
352
- // Prevent double-release
353
- if (released) {
354
- return;
355
- }
319
+ if (released) return;
356
320
 
357
- // Always stop heartbeat first
358
- heartbeat.stop();
359
-
360
- // Remove exit handlers since we're releasing normally
361
321
  removeExitHandlers();
362
-
363
- // Mark as released
364
322
  released = true;
365
323
 
366
324
  try {
@@ -368,11 +326,9 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
368
326
  throw new Error('Database connection unavailable during release');
369
327
  }
370
328
 
371
- // Attempt to delete the lock
372
329
  await new Promise((resolve, reject) => {
373
330
  db.run('DELETE FROM merge_locks WHERE id = ?', [lockId], (err) => {
374
331
  if (err) {
375
- // Enhance error message for common issues
376
332
  if (err.message && err.message.includes('Database is closed')) {
377
333
  reject(new Error('Database connection unavailable during release'));
378
334
  } else {
@@ -383,120 +339,22 @@ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
383
339
  }
384
340
  });
385
341
  });
386
-
387
- // Verify lock was actually deleted (orphan detection)
388
- const lockStillExists = await new Promise((resolve, reject) => {
389
- db.get('SELECT id FROM merge_locks WHERE id = ?', [lockId], (err, row) => {
390
- if (err) {
391
- // If we can't verify, assume success (soft failure)
392
- console.warn(`Warning: Could not verify lock ${lockId} was released: ${err.message}`);
393
- resolve(false);
394
- } else {
395
- resolve(!!row);
396
- }
397
- });
398
- });
399
-
400
- if (lockStillExists) {
401
- // Hard failure: lock persists after DELETE (orphan detected)
402
- throw new Error(
403
- `Failed to release merge lock ${lockId} - orphan lock detected.\n` +
404
- `The lock still exists in the database after attempted deletion.\n\n` +
405
- `Manual cleanup required:\n` +
406
- ` jettypod work merge --release-lock`
407
- );
408
- }
409
342
  } catch (err) {
410
- // Log error but don't crash - lock may have already been cleaned up
411
- // or database may be unavailable
412
343
  console.error(`Failed to release lock ${lockId}:`, err.message);
413
- throw err; // Re-throw so caller knows release failed
344
+ throw err;
414
345
  }
415
346
  }
416
347
  };
417
348
  }
418
349
 
419
-
420
- /**
421
- * Sleep for specified milliseconds
422
- *
423
- * @param {number} ms - Milliseconds to sleep
424
- * @returns {Promise<void>}
425
- */
426
350
  function sleep(ms) {
427
351
  return new Promise(resolve => setTimeout(resolve, ms));
428
352
  }
429
353
 
430
- /**
431
- * Update heartbeat timestamp for a lock
432
- *
433
- * @param {Object} db - SQLite database connection
434
- * @param {number} lockId - Lock ID to update
435
- * @returns {Promise<void>}
436
- */
437
- function updateHeartbeat(db, lockId) {
438
- return new Promise((resolve, reject) => {
439
- db.run(
440
- `UPDATE merge_locks SET heartbeat_at = datetime('now') WHERE id = ?`,
441
- [lockId],
442
- (err) => {
443
- if (err) reject(err);
444
- else resolve();
445
- }
446
- );
447
- });
448
- }
449
-
450
- /**
451
- * Start heartbeat interval for a lock
452
- *
453
- * @param {Object} db - SQLite database connection
454
- * @param {number} lockId - Lock ID to heartbeat
455
- * @param {number} intervalMs - Heartbeat interval in milliseconds (default: 5000)
456
- * @param {Object} options - Optional configuration
457
- * @param {number} options.maxFailures - Max consecutive failures before calling onMaxFailures (default: 2)
458
- * @param {Function} options.onMaxFailures - Callback when max failures reached
459
- * @returns {Object} Object with stop() function to clear the interval
460
- */
461
- function startHeartbeat(db, lockId, intervalMs = 5000, options = {}) {
462
- const maxFailures = options.maxFailures || 2;
463
- const onMaxFailures = options.onMaxFailures || null;
464
-
465
- let consecutiveFailures = 0;
466
- let stopped = false;
467
-
468
- const intervalId = setInterval(async () => {
469
- if (stopped) return;
470
-
471
- try {
472
- await updateHeartbeat(db, lockId);
473
- // Reset failure count on success
474
- consecutiveFailures = 0;
475
- } catch (err) {
476
- consecutiveFailures++;
477
- console.warn(`Warning: Heartbeat update failed for lock ${lockId} (${consecutiveFailures}/${maxFailures}): ${err.message}`);
478
-
479
- // Trigger callback if max failures reached
480
- if (consecutiveFailures >= maxFailures && onMaxFailures && !stopped) {
481
- stopped = true;
482
- clearInterval(intervalId);
483
- onMaxFailures();
484
- }
485
- }
486
- }, intervalMs);
487
-
488
- return {
489
- stop: () => {
490
- stopped = true;
491
- clearInterval(intervalId);
492
- }
493
- };
494
- }
495
-
496
354
  module.exports = {
497
355
  acquireMergeLock,
498
356
  cleanupStaleLocks,
499
- startHeartbeat,
500
- updateHeartbeat,
357
+ isProcessAlive,
358
+ parsePid,
501
359
  DEFAULT_MAX_LOCK_AGE_MS
502
360
  };
@@ -13,7 +13,9 @@ module.exports = {
13
13
  async up(db) {
14
14
  return new Promise((resolve, reject) => {
15
15
  db.run(`ALTER TABLE work_items ADD COLUMN plan_at_creation TEXT DEFAULT NULL`, (err) => {
16
- if (err) return reject(err);
16
+ // Ignore "duplicate column" — column may already exist if a previous
17
+ // run succeeded at ALTER TABLE but crashed before recording the migration.
18
+ if (err && !err.message.includes('duplicate column')) return reject(err);
17
19
  // Backfill existing work items — assume free plan for all existing items
18
20
  db.run(`UPDATE work_items SET plan_at_creation = 'free' WHERE plan_at_creation IS NULL`, (err2) => {
19
21
  if (err2) return reject(err2);