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.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- 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
|
-
*
|
|
5
|
-
* to prevent simultaneous access to main branch across multiple Claude Code instances.
|
|
4
|
+
* Prevents simultaneous merge operations on main branch.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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;
|
|
47
|
-
const pollInterval = options.pollInterval || 1500;
|
|
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;
|
|
82
|
+
const statusInterval = 10000;
|
|
58
83
|
|
|
59
84
|
while (Date.now() - startTime < maxWait) {
|
|
60
|
-
// Cleanup stale locks every 5 polls
|
|
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
|
|
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
|
|
95
|
-
const
|
|
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
|
|
154
|
+
* Clean up stale locks using PID liveness checks + absolute age timeout.
|
|
176
155
|
*
|
|
177
|
-
*
|
|
178
|
-
* 1.
|
|
179
|
-
* 2.
|
|
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 {
|
|
184
|
-
* @param {number}
|
|
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,
|
|
165
|
+
function cleanupStaleLocks(db, options = {}) {
|
|
189
166
|
if (!db) {
|
|
190
167
|
return Promise.reject(new Error('Database connection required'));
|
|
191
168
|
}
|
|
192
169
|
|
|
193
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
500
|
-
|
|
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
|
|
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);
|