@virtengine/openfleet 0.25.0

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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,924 @@
1
+ /**
2
+ * task-claims.mjs — Distributed task claiming with idempotency and conflict resolution.
3
+ *
4
+ * Provides:
5
+ * - Idempotent task claiming across multiple workstations
6
+ * - Deterministic duplicate claim resolution
7
+ * - Persistent claim tokens
8
+ * - Integration with presence.mjs for fleet coordination
9
+ * - Telegram/VK channel announcement support
10
+ *
11
+ * Architecture:
12
+ * - Claims are stored in .cache/openfleet/task-claims.json
13
+ * - Each claim has a unique token (UUID) for idempotency
14
+ * - Claims include instance_id, timestamp, and TTL
15
+ * - Duplicate claims are resolved by instance priority (from presence.mjs)
16
+ * - Stale claims are auto-swept based on TTL
17
+ *
18
+ * Usage:
19
+ * import { claimTask, releaseTask, listClaims } from './task-claims.mjs';
20
+ *
21
+ * const claim = await claimTask({
22
+ * taskId: 'abc123',
23
+ * instanceId: 'workstation-1',
24
+ * ttlMinutes: 60,
25
+ * });
26
+ *
27
+ * if (claim.success) {
28
+ * // Work on task
29
+ * await releaseTask({ taskId: 'abc123', claimToken: claim.token });
30
+ * }
31
+ */
32
+
33
+ import crypto from "node:crypto";
34
+ import { existsSync } from "node:fs";
35
+ import { mkdir, readFile, rename, writeFile, unlink } from "node:fs/promises";
36
+ import os from "node:os";
37
+ import { resolve } from "node:path";
38
+ import {
39
+ getPresenceState,
40
+ listActiveInstances,
41
+ selectCoordinator,
42
+ } from "./presence.mjs";
43
+ import {
44
+ claimTaskInSharedState,
45
+ renewSharedStateHeartbeat,
46
+ releaseSharedState,
47
+ } from "./shared-state-manager.mjs";
48
+
49
+ // ── Constants ────────────────────────────────────────────────────────────────
50
+
51
+ const CLAIMS_FILENAME = "task-claims.json";
52
+ const AUDIT_FILENAME = "task-claims-audit.jsonl";
53
+ const DEFAULT_TTL_MINUTES = 60;
54
+ const CACHE_DIR = ".cache/openfleet";
55
+ const DEFAULT_OWNER_STALE_TTL_MS = 10 * 60 * 1000;
56
+
57
+ // Shared state configuration from environment
58
+ const SHARED_STATE_ENABLED = process.env.SHARED_STATE_ENABLED !== "false"; // default true
59
+ const SHARED_STATE_HEARTBEAT_INTERVAL_MS = Number(process.env.SHARED_STATE_HEARTBEAT_INTERVAL_MS) || 60_000;
60
+ const SHARED_STATE_STALE_THRESHOLD_MS = Number(process.env.SHARED_STATE_STALE_THRESHOLD_MS) || 300_000;
61
+ const SHARED_STATE_MAX_RETRIES = Number(process.env.SHARED_STATE_MAX_RETRIES) || 3;
62
+
63
+ // ── State ────────────────────────────────────────────────────────────────────
64
+
65
+ const state = {
66
+ initialized: false,
67
+ repoRoot: null,
68
+ claimsPath: null,
69
+ auditPath: null,
70
+ };
71
+
72
+ // ── Initialization ───────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Initialize the task claims system.
76
+ *
77
+ * @param {object} opts
78
+ * @param {string} [opts.repoRoot] - Repository root path
79
+ * @returns {Promise<void>}
80
+ */
81
+ export async function initTaskClaims(opts = {}) {
82
+ state.repoRoot = opts.repoRoot || process.cwd();
83
+ const cacheDir = resolve(state.repoRoot, CACHE_DIR);
84
+ await mkdir(cacheDir, { recursive: true });
85
+ state.claimsPath = resolve(cacheDir, CLAIMS_FILENAME);
86
+ state.auditPath = resolve(cacheDir, AUDIT_FILENAME);
87
+ state.initialized = true;
88
+ }
89
+
90
+ function ensureInitialized() {
91
+ if (!state.initialized) {
92
+ throw new Error("task-claims not initialized. Call initTaskClaims() first.");
93
+ }
94
+ }
95
+
96
+ // ── Claim Registry I/O ───────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Load the claims registry from disk.
100
+ *
101
+ * @returns {Promise<object>} Registry object with claims map
102
+ */
103
+ async function loadClaimsRegistry() {
104
+ ensureInitialized();
105
+ if (!existsSync(state.claimsPath)) {
106
+ return { version: 1, claims: {}, updated_at: new Date().toISOString() };
107
+ }
108
+ try {
109
+ const raw = await readFile(state.claimsPath, "utf8");
110
+ const parsed = parseRegistryPayload(raw);
111
+ const data = parsed.data;
112
+ const registry = {
113
+ version: data.version || 1,
114
+ claims: data.claims || {},
115
+ updated_at: data.updated_at || new Date().toISOString(),
116
+ };
117
+ if (parsed.recovered) {
118
+ const detail = parsed.details.length ? parsed.details.join(", ") : "partial";
119
+ console.warn(`[task-claims] Recovered registry (${detail}); rewriting clean copy.`);
120
+ try {
121
+ await saveClaimsRegistry(registry);
122
+ } catch (rewriteErr) {
123
+ console.warn(
124
+ `[task-claims] Failed to rewrite recovered registry: ${rewriteErr.message}`,
125
+ );
126
+ }
127
+ }
128
+ return registry;
129
+ } catch (err) {
130
+ console.warn(`[task-claims] Failed to load registry: ${err.message}`);
131
+ try {
132
+ const suffix = new Date()
133
+ .toISOString()
134
+ .replace(/[:.]/g, "-");
135
+ const corruptPath = `${state.claimsPath}.corrupt-${suffix}`;
136
+ await rename(state.claimsPath, corruptPath);
137
+ console.warn(
138
+ `[task-claims] Corrupt registry moved to ${corruptPath}`,
139
+ );
140
+ } catch {
141
+ /* best effort */
142
+ }
143
+ return { version: 1, claims: {}, updated_at: new Date().toISOString() };
144
+ }
145
+ }
146
+
147
+ function parseRegistryPayload(raw) {
148
+ const trimmed = raw.trim();
149
+ if (!trimmed) {
150
+ throw new Error("registry file empty");
151
+ }
152
+ try {
153
+ return { data: JSON.parse(trimmed), recovered: false, details: [] };
154
+ } catch (err) {
155
+ const extraction = extractJsonObject(raw);
156
+ if (!extraction) throw err;
157
+ const { jsonText, leadingJunk, trailingJunk } = extraction;
158
+ const data = JSON.parse(jsonText);
159
+ const details = [];
160
+ if (leadingJunk) details.push("leading junk trimmed");
161
+ if (trailingJunk) details.push("trailing junk trimmed");
162
+ return { data, recovered: true, details };
163
+ }
164
+ }
165
+
166
+ function extractJsonObject(raw) {
167
+ const firstNonWhitespace = raw.search(/\S/);
168
+ if (firstNonWhitespace === -1) return null;
169
+ const startIndex = raw.indexOf("{", firstNonWhitespace);
170
+ if (startIndex === -1) return null;
171
+ let depth = 0;
172
+ let inString = false;
173
+ let escaped = false;
174
+ for (let i = startIndex; i < raw.length; i += 1) {
175
+ const ch = raw[i];
176
+ if (inString) {
177
+ if (escaped) {
178
+ escaped = false;
179
+ continue;
180
+ }
181
+ if (ch === "\\") {
182
+ escaped = true;
183
+ continue;
184
+ }
185
+ if (ch === "\"") {
186
+ inString = false;
187
+ }
188
+ continue;
189
+ }
190
+ if (ch === "\"") {
191
+ inString = true;
192
+ continue;
193
+ }
194
+ if (ch === "{") {
195
+ depth += 1;
196
+ continue;
197
+ }
198
+ if (ch === "}") {
199
+ depth -= 1;
200
+ if (depth === 0) {
201
+ const jsonText = raw.slice(startIndex, i + 1);
202
+ const trailing = raw.slice(i + 1);
203
+ return {
204
+ jsonText,
205
+ leadingJunk: startIndex > firstNonWhitespace,
206
+ trailingJunk: trailing.trim().length > 0,
207
+ };
208
+ }
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Save the claims registry to disk.
216
+ *
217
+ * @param {object} registry - Claims registry object
218
+ * @returns {Promise<void>}
219
+ */
220
+ async function saveClaimsRegistry(registry) {
221
+ ensureInitialized();
222
+ registry.updated_at = new Date().toISOString();
223
+ const payload = JSON.stringify(registry, null, 2);
224
+ const tmpPath = `${state.claimsPath}.tmp-${process.pid}-${Date.now()}`;
225
+ await writeFile(tmpPath, payload, "utf8");
226
+ try {
227
+ await rename(tmpPath, state.claimsPath);
228
+ } catch (err) {
229
+ // Windows can error if destination exists; fall back to direct write.
230
+ try {
231
+ await writeFile(state.claimsPath, payload, "utf8");
232
+ } finally {
233
+ try {
234
+ await unlink(tmpPath);
235
+ } catch {
236
+ /* best effort */
237
+ }
238
+ }
239
+ console.warn(
240
+ `[task-claims] Atomic rename failed (${err?.message || err}); fell back to direct write.`,
241
+ );
242
+ }
243
+ }
244
+
245
+ // ── Audit Log ────────────────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * Append an audit entry to the claims audit log.
249
+ *
250
+ * @param {object} entry - Audit entry
251
+ * @returns {Promise<void>}
252
+ */
253
+ async function appendAuditEntry(entry) {
254
+ ensureInitialized();
255
+ const line = JSON.stringify({
256
+ ...entry,
257
+ timestamp: entry.timestamp || new Date().toISOString(),
258
+ });
259
+ try {
260
+ await writeFile(state.auditPath, line + "\n", { flag: "a" });
261
+ } catch (err) {
262
+ console.warn(`[task-claims] Failed to write audit entry: ${err.message}`);
263
+ }
264
+ }
265
+
266
+ // ── Claim Token Generation ───────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Generate a unique claim token.
270
+ *
271
+ * @returns {string} UUID-based claim token
272
+ */
273
+ function generateClaimToken() {
274
+ return crypto.randomUUID();
275
+ }
276
+
277
+ function parseDuration(value, fallbackMs) {
278
+ const parsed = Number(value);
279
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackMs;
280
+ }
281
+
282
+ function isProcessAlive(pid) {
283
+ const n = Number(pid);
284
+ if (!Number.isFinite(n) || n <= 0) return false;
285
+ try {
286
+ process.kill(Math.floor(n), 0);
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ function shouldTreatClaimAsStale(claim, ownerStaleTtlMs) {
294
+ if (!claim || !claim.instance_id) {
295
+ return { stale: false, reason: null };
296
+ }
297
+
298
+ const activeInstances = listActiveInstances({ ttlMs: ownerStaleTtlMs });
299
+ if (Array.isArray(activeInstances) && activeInstances.length > 0) {
300
+ const ownerActive = activeInstances.some(
301
+ (entry) => String(entry?.instance_id || "") === String(claim.instance_id),
302
+ );
303
+ if (!ownerActive) {
304
+ return { stale: true, reason: "owner_stale" };
305
+ }
306
+ }
307
+
308
+ const claimHost = String(claim?.metadata?.host || "").trim();
309
+ const claimPid = Number(claim?.metadata?.pid);
310
+ const localHost = os.hostname();
311
+ if (
312
+ claimHost &&
313
+ localHost &&
314
+ claimHost.toLowerCase() === String(localHost).toLowerCase() &&
315
+ Number.isFinite(claimPid) &&
316
+ claimPid > 0 &&
317
+ !isProcessAlive(claimPid)
318
+ ) {
319
+ return { stale: true, reason: "owner_stale" };
320
+ }
321
+
322
+ return { stale: false, reason: null };
323
+ }
324
+
325
+ // ── Claim Expiry ─────────────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Check if a claim is expired.
329
+ *
330
+ * @param {object} claim - Claim object
331
+ * @param {Date} [now] - Current time (for testing)
332
+ * @returns {boolean} True if expired
333
+ */
334
+ function isClaimExpired(claim, now = new Date()) {
335
+ if (!claim || !claim.expires_at) return true;
336
+ const expiresAt = new Date(claim.expires_at);
337
+ return now >= expiresAt;
338
+ }
339
+
340
+ /**
341
+ * Sweep expired claims from the registry.
342
+ *
343
+ * @param {object} registry - Claims registry
344
+ * @param {Date} [now] - Current time (for testing)
345
+ * @returns {object} { registry, expiredCount }
346
+ */
347
+ function sweepExpiredClaims(registry, now = new Date()) {
348
+ let expiredCount = 0;
349
+ for (const [taskId, claim] of Object.entries(registry.claims)) {
350
+ if (isClaimExpired(claim, now)) {
351
+ delete registry.claims[taskId];
352
+ expiredCount++;
353
+ }
354
+ }
355
+ return { registry, expiredCount };
356
+ }
357
+
358
+ // ── Duplicate Claim Resolution ───────────────────────────────────────────────
359
+
360
+ /**
361
+ * Resolve a duplicate claim conflict deterministically.
362
+ *
363
+ * When two instances claim the same task, we resolve by:
364
+ * 1. Coordinator priority (coordinator always wins)
365
+ * 2. Coordinator priority number (lower wins)
366
+ * 3. Timestamp (earlier claim wins)
367
+ * 4. Instance ID (lexicographic comparison for determinism)
368
+ *
369
+ * @param {object} existingClaim - The existing claim
370
+ * @param {object} newClaim - The new claim attempting to claim
371
+ * @param {object} opts - Resolution options
372
+ * @param {number} [opts.ttlMs] - Presence TTL for coordinator selection
373
+ * @returns {object} { winner, loser, reason }
374
+ */
375
+ function resolveDuplicateClaim(existingClaim, newClaim, opts = {}) {
376
+ const { ttlMs = 5 * 60 * 1000 } = opts;
377
+ const nowMs = Date.now();
378
+
379
+ // Get coordinator from presence system
380
+ const coordinator = selectCoordinator({ nowMs, ttlMs });
381
+ const coordinatorId = coordinator?.instance_id;
382
+
383
+ // Rule 1: Coordinator always wins
384
+ if (coordinatorId) {
385
+ if (existingClaim.instance_id === coordinatorId && newClaim.instance_id !== coordinatorId) {
386
+ return {
387
+ winner: existingClaim,
388
+ loser: newClaim,
389
+ reason: "existing_is_coordinator",
390
+ };
391
+ }
392
+ if (newClaim.instance_id === coordinatorId && existingClaim.instance_id !== coordinatorId) {
393
+ return {
394
+ winner: newClaim,
395
+ loser: existingClaim,
396
+ reason: "new_is_coordinator",
397
+ };
398
+ }
399
+ }
400
+
401
+ // Rule 2: Lower coordinator priority wins (if both have priorities)
402
+ const existingPriority = existingClaim.coordinator_priority ?? 100;
403
+ const newPriority = newClaim.coordinator_priority ?? 100;
404
+ if (existingPriority !== newPriority) {
405
+ return existingPriority < newPriority
406
+ ? {
407
+ winner: existingClaim,
408
+ loser: newClaim,
409
+ reason: "existing_lower_priority",
410
+ }
411
+ : {
412
+ winner: newClaim,
413
+ loser: existingClaim,
414
+ reason: "new_lower_priority",
415
+ };
416
+ }
417
+
418
+ // Rule 3: Earlier timestamp wins
419
+ const existingTime = new Date(existingClaim.claimed_at).getTime();
420
+ const newTime = new Date(newClaim.claimed_at).getTime();
421
+ if (existingTime !== newTime) {
422
+ return existingTime < newTime
423
+ ? {
424
+ winner: existingClaim,
425
+ loser: newClaim,
426
+ reason: "existing_earlier",
427
+ }
428
+ : {
429
+ winner: newClaim,
430
+ loser: existingClaim,
431
+ reason: "new_earlier",
432
+ };
433
+ }
434
+
435
+ // Rule 4: Lexicographic instance ID comparison (deterministic tie-breaker)
436
+ const comparison = existingClaim.instance_id.localeCompare(newClaim.instance_id);
437
+ if (comparison < 0) {
438
+ return {
439
+ winner: existingClaim,
440
+ loser: newClaim,
441
+ reason: "existing_instance_id_lower",
442
+ };
443
+ } else if (comparison > 0) {
444
+ return {
445
+ winner: newClaim,
446
+ loser: existingClaim,
447
+ reason: "new_instance_id_lower",
448
+ };
449
+ }
450
+
451
+ // Should never reach here (same instance claiming twice)
452
+ return {
453
+ winner: existingClaim,
454
+ loser: newClaim,
455
+ reason: "same_instance",
456
+ };
457
+ }
458
+
459
+ // ── Core API ─────────────────────────────────────────────────────────────────
460
+
461
+ /**
462
+ * Claim a task for this instance.
463
+ *
464
+ * @param {object} opts
465
+ * @param {string} opts.taskId - Task ID to claim
466
+ * @param {string} [opts.instanceId] - Instance ID (defaults to presence state)
467
+ * @param {number} [opts.ttlMinutes] - Claim TTL in minutes
468
+ * @param {string} [opts.claimToken] - Idempotency token (auto-generated if not provided)
469
+ * @param {object} [opts.metadata] - Additional metadata
470
+ * @returns {Promise<object>} { success, token, claim?, error?, resolution? }
471
+ */
472
+ export async function claimTask(opts = {}) {
473
+ ensureInitialized();
474
+
475
+ const {
476
+ taskId,
477
+ instanceId = getPresenceState().instance_id,
478
+ ttlMinutes = DEFAULT_TTL_MINUTES,
479
+ claimToken = generateClaimToken(),
480
+ metadata = {},
481
+ ownerStaleTtlMs = parseDuration(
482
+ opts.ownerStaleTtlMs ?? process.env.TASK_CLAIM_OWNER_STALE_TTL_MS,
483
+ DEFAULT_OWNER_STALE_TTL_MS,
484
+ ),
485
+ } = opts;
486
+
487
+ if (!taskId) {
488
+ return { success: false, error: "taskId is required" };
489
+ }
490
+
491
+ if (!instanceId) {
492
+ return { success: false, error: "instanceId is required" };
493
+ }
494
+
495
+ const now = new Date();
496
+ const expiresAt = new Date(now.getTime() + ttlMinutes * 60 * 1000);
497
+
498
+ // Load registry and sweep expired claims
499
+ let registry = await loadClaimsRegistry();
500
+ const sweepResult = sweepExpiredClaims(registry, now);
501
+ registry = sweepResult.registry;
502
+
503
+ // Check for existing claim
504
+ const existingClaim = registry.claims[taskId];
505
+
506
+ // Build new claim
507
+ const presenceState = getPresenceState();
508
+ const claimMetadata = {
509
+ ...metadata,
510
+ host: metadata?.host || os.hostname(),
511
+ pid: Number.isFinite(Number(metadata?.pid))
512
+ ? Number(metadata.pid)
513
+ : process.pid,
514
+ };
515
+
516
+ const newClaim = {
517
+ task_id: taskId,
518
+ instance_id: instanceId,
519
+ claim_token: claimToken,
520
+ claimed_at: now.toISOString(),
521
+ expires_at: expiresAt.toISOString(),
522
+ ttl_minutes: ttlMinutes,
523
+ coordinator_priority: presenceState.coordinator_priority ?? 100,
524
+ metadata: claimMetadata,
525
+ };
526
+
527
+ // If no existing claim, grant immediately
528
+ if (!existingClaim) {
529
+ registry.claims[taskId] = newClaim;
530
+ await saveClaimsRegistry(registry);
531
+ await appendAuditEntry({
532
+ action: "claim",
533
+ task_id: taskId,
534
+ instance_id: instanceId,
535
+ claim_token: claimToken,
536
+ expires_at: expiresAt.toISOString(),
537
+ });
538
+
539
+ // Sync to shared state (non-blocking, log on failure)
540
+ if (SHARED_STATE_ENABLED) {
541
+ try {
542
+ const sharedResult = await claimTaskInSharedState(
543
+ taskId,
544
+ instanceId,
545
+ claimToken,
546
+ Math.floor(SHARED_STATE_STALE_THRESHOLD_MS / 1000),
547
+ state.repoRoot
548
+ );
549
+ if (!sharedResult.success) {
550
+ console.info(`[task-claims] Shared state claim warning for ${taskId}: ${sharedResult.reason}`);
551
+ } else {
552
+ console.info(`[task-claims] Shared state synced for ${taskId}`);
553
+ }
554
+ } catch (err) {
555
+ console.warn(`[task-claims] Shared state sync failed for ${taskId}: ${err.message}`);
556
+ }
557
+ }
558
+
559
+ return { success: true, token: claimToken, claim: newClaim };
560
+ }
561
+
562
+ // Idempotency: If existing claim has same token, return it
563
+ if (existingClaim.claim_token === claimToken) {
564
+ return { success: true, token: claimToken, claim: existingClaim, idempotent: true };
565
+ }
566
+
567
+ const staleCheck = shouldTreatClaimAsStale(existingClaim, ownerStaleTtlMs);
568
+ if (staleCheck.stale) {
569
+ registry.claims[taskId] = newClaim;
570
+ await saveClaimsRegistry(registry);
571
+ await appendAuditEntry({
572
+ action: "claim_override",
573
+ task_id: taskId,
574
+ instance_id: instanceId,
575
+ claim_token: claimToken,
576
+ expires_at: expiresAt.toISOString(),
577
+ previous_instance: existingClaim.instance_id,
578
+ previous_token: existingClaim.claim_token,
579
+ resolution_reason: staleCheck.reason,
580
+ });
581
+
582
+ // Sync to shared state after override
583
+ if (SHARED_STATE_ENABLED) {
584
+ try {
585
+ const sharedResult = await claimTaskInSharedState(
586
+ taskId,
587
+ instanceId,
588
+ claimToken,
589
+ Math.floor(SHARED_STATE_STALE_THRESHOLD_MS / 1000),
590
+ state.repoRoot
591
+ );
592
+ if (!sharedResult.success) {
593
+ console.info(`[task-claims] Shared state override warning for ${taskId}: ${sharedResult.reason}`);
594
+ } else {
595
+ console.info(`[task-claims] Shared state synced after override for ${taskId}`);
596
+ }
597
+ } catch (err) {
598
+ console.warn(`[task-claims] Shared state sync failed after override for ${taskId}: ${err.message}`);
599
+ }
600
+ }
601
+
602
+ return {
603
+ success: true,
604
+ token: claimToken,
605
+ claim: newClaim,
606
+ resolution: {
607
+ override: true,
608
+ reason: staleCheck.reason,
609
+ previous_instance: existingClaim.instance_id,
610
+ },
611
+ };
612
+ }
613
+
614
+ // Duplicate claim detected — resolve conflict
615
+ const resolution = resolveDuplicateClaim(existingClaim, newClaim);
616
+
617
+ if (resolution.winner === newClaim) {
618
+ // New claim wins — replace existing
619
+ registry.claims[taskId] = newClaim;
620
+ await saveClaimsRegistry(registry);
621
+ await appendAuditEntry({
622
+ action: "claim_override",
623
+ task_id: taskId,
624
+ instance_id: instanceId,
625
+ claim_token: claimToken,
626
+ expires_at: expiresAt.toISOString(),
627
+ previous_instance: existingClaim.instance_id,
628
+ previous_token: existingClaim.claim_token,
629
+ resolution_reason: resolution.reason,
630
+ });
631
+ return {
632
+ success: true,
633
+ token: claimToken,
634
+ claim: newClaim,
635
+ resolution: {
636
+ override: true,
637
+ reason: resolution.reason,
638
+ previous_instance: existingClaim.instance_id,
639
+ },
640
+ };
641
+ } else {
642
+ // Existing claim wins — reject new claim
643
+ await appendAuditEntry({
644
+ action: "claim_rejected",
645
+ task_id: taskId,
646
+ instance_id: instanceId,
647
+ claim_token: claimToken,
648
+ existing_instance: existingClaim.instance_id,
649
+ existing_token: existingClaim.claim_token,
650
+ resolution_reason: resolution.reason,
651
+ });
652
+ return {
653
+ success: false,
654
+ error: "task_already_claimed",
655
+ existing_instance: existingClaim.instance_id,
656
+ existing_claim: existingClaim,
657
+ resolution: {
658
+ override: false,
659
+ reason: resolution.reason,
660
+ },
661
+ };
662
+ }
663
+ }
664
+
665
+ /**
666
+ * Release a claimed task.
667
+ *
668
+ * @param {object} opts
669
+ * @param {string} opts.taskId - Task ID to release
670
+ * @param {string} [opts.claimToken] - Claim token (for verification)
671
+ * @param {string} [opts.instanceId] - Instance ID (defaults to presence state)
672
+ * @param {boolean} [opts.force] - Force release even if not owned
673
+ * @returns {Promise<object>} { success, error? }
674
+ */
675
+ export async function releaseTask(opts = {}) {
676
+ ensureInitialized();
677
+
678
+ const {
679
+ taskId,
680
+ claimToken,
681
+ instanceId = getPresenceState().instance_id,
682
+ force = false,
683
+ } = opts;
684
+
685
+ if (!taskId) {
686
+ return { success: false, error: "taskId is required" };
687
+ }
688
+
689
+ const registry = await loadClaimsRegistry();
690
+ const claim = registry.claims[taskId];
691
+
692
+ if (!claim) {
693
+ return { success: false, error: "task_not_claimed" };
694
+ }
695
+
696
+ // Verify ownership unless force=true
697
+ if (!force) {
698
+ if (claim.instance_id !== instanceId) {
699
+ return {
700
+ success: false,
701
+ error: "task_claimed_by_different_instance",
702
+ owner: claim.instance_id,
703
+ };
704
+ }
705
+ if (claimToken && claim.claim_token !== claimToken) {
706
+ return {
707
+ success: false,
708
+ error: "claim_token_mismatch",
709
+ };
710
+ }
711
+ }
712
+
713
+ // Release the claim
714
+ delete registry.claims[taskId];
715
+ await saveClaimsRegistry(registry);
716
+ await appendAuditEntry({
717
+ action: force ? "release_forced" : "release",
718
+ task_id: taskId,
719
+ instance_id: instanceId,
720
+ claim_token: claimToken,
721
+ previous_owner: claim.instance_id,
722
+ });
723
+
724
+ // Release shared state (mark complete if not forced, abandoned if forced)
725
+ if (SHARED_STATE_ENABLED) {
726
+ try {
727
+ const sharedResult = await releaseSharedState(
728
+ taskId,
729
+ claim.claim_token,
730
+ force ? "abandoned" : "complete",
731
+ force ? "Force released by user" : undefined,
732
+ state.repoRoot
733
+ );
734
+ if (!sharedResult.success) {
735
+ console.info(`[task-claims] Shared state release warning for ${taskId}: ${sharedResult.reason}`);
736
+ } else {
737
+ console.info(`[task-claims] Shared state released for ${taskId}`);
738
+ }
739
+ } catch (err) {
740
+ console.warn(`[task-claims] Shared state release failed for ${taskId}: ${err.message}`);
741
+ }
742
+ }
743
+
744
+ return { success: true };
745
+ }
746
+
747
+ /**
748
+ * Renew an existing claim (extend TTL).
749
+ *
750
+ * @param {object} opts
751
+ * @param {string} opts.taskId - Task ID
752
+ * @param {string} [opts.claimToken] - Claim token (for verification)
753
+ * @param {string} [opts.instanceId] - Instance ID (defaults to presence state)
754
+ * @param {number} [opts.ttlMinutes] - New TTL in minutes
755
+ * @returns {Promise<object>} { success, claim?, error? }
756
+ */
757
+ export async function renewClaim(opts = {}) {
758
+ ensureInitialized();
759
+
760
+ const {
761
+ taskId,
762
+ claimToken,
763
+ instanceId = getPresenceState().instance_id,
764
+ ttlMinutes = DEFAULT_TTL_MINUTES,
765
+ } = opts;
766
+
767
+ if (!taskId) {
768
+ return { success: false, error: "taskId is required" };
769
+ }
770
+
771
+ const registry = await loadClaimsRegistry();
772
+ const claim = registry.claims[taskId];
773
+
774
+ if (!claim) {
775
+ return { success: false, error: "task_not_claimed" };
776
+ }
777
+
778
+ // Verify ownership
779
+ if (claim.instance_id !== instanceId) {
780
+ return {
781
+ success: false,
782
+ error: "task_claimed_by_different_instance",
783
+ owner: claim.instance_id,
784
+ };
785
+ }
786
+ if (claimToken && claim.claim_token !== claimToken) {
787
+ return {
788
+ success: false,
789
+ error: "claim_token_mismatch",
790
+ };
791
+ }
792
+
793
+ // Renew the claim
794
+ const now = new Date();
795
+ const expiresAt = new Date(now.getTime() + ttlMinutes * 60 * 1000);
796
+ claim.expires_at = expiresAt.toISOString();
797
+ claim.ttl_minutes = ttlMinutes;
798
+ claim.renewed_at = now.toISOString();
799
+
800
+ await saveClaimsRegistry(registry);
801
+ await appendAuditEntry({
802
+ action: "renew",
803
+ task_id: taskId,
804
+ instance_id: instanceId,
805
+ claim_token: claimToken,
806
+ expires_at: expiresAt.toISOString(),
807
+ });
808
+
809
+ // Renew shared state heartbeat
810
+ if (SHARED_STATE_ENABLED) {
811
+ try {
812
+ const sharedResult = await renewSharedStateHeartbeat(
813
+ taskId,
814
+ instanceId,
815
+ claimToken,
816
+ state.repoRoot
817
+ );
818
+ if (!sharedResult.success) {
819
+ console.info(`[task-claims] Shared state heartbeat renewal warning for ${taskId}: ${sharedResult.reason}`);
820
+ }
821
+ } catch (err) {
822
+ console.warn(`[task-claims] Shared state heartbeat renewal failed for ${taskId}: ${err.message}`);
823
+ }
824
+ }
825
+
826
+ return { success: true, claim };
827
+ }
828
+
829
+ /**
830
+ * Get a claim by task ID.
831
+ *
832
+ * @param {string} taskId - Task ID
833
+ * @returns {Promise<object|null>} Claim object or null
834
+ */
835
+ export async function getClaim(taskId) {
836
+ ensureInitialized();
837
+ const registry = await loadClaimsRegistry();
838
+ return registry.claims[taskId] || null;
839
+ }
840
+
841
+ /**
842
+ * List all active claims.
843
+ *
844
+ * @param {object} opts
845
+ * @param {string} [opts.instanceId] - Filter by instance ID
846
+ * @param {boolean} [opts.includeExpired] - Include expired claims
847
+ * @returns {Promise<Array<object>>} Array of claim objects
848
+ */
849
+ export async function listClaims(opts = {}) {
850
+ ensureInitialized();
851
+ const { instanceId, includeExpired = false } = opts;
852
+
853
+ let registry = await loadClaimsRegistry();
854
+
855
+ if (!includeExpired) {
856
+ const sweepResult = sweepExpiredClaims(registry);
857
+ registry = sweepResult.registry;
858
+ }
859
+
860
+ let claims = Object.values(registry.claims);
861
+
862
+ if (instanceId) {
863
+ claims = claims.filter((c) => c.instance_id === instanceId);
864
+ }
865
+
866
+ return claims;
867
+ }
868
+
869
+ /**
870
+ * Check if a task is claimed.
871
+ *
872
+ * @param {string} taskId - Task ID
873
+ * @returns {Promise<boolean>} True if claimed (and not expired)
874
+ */
875
+ export async function isTaskClaimed(taskId) {
876
+ ensureInitialized();
877
+ const claim = await getClaim(taskId);
878
+ if (!claim) return false;
879
+ return !isClaimExpired(claim);
880
+ }
881
+
882
+ /**
883
+ * Get claim statistics.
884
+ *
885
+ * @returns {Promise<object>} Statistics object
886
+ */
887
+ export async function getClaimStats() {
888
+ ensureInitialized();
889
+ const registry = await loadClaimsRegistry();
890
+ const now = new Date();
891
+
892
+ let active = 0;
893
+ let expired = 0;
894
+ const byInstance = new Map();
895
+
896
+ for (const claim of Object.values(registry.claims)) {
897
+ if (isClaimExpired(claim, now)) {
898
+ expired++;
899
+ } else {
900
+ active++;
901
+ const count = byInstance.get(claim.instance_id) || 0;
902
+ byInstance.set(claim.instance_id, count + 1);
903
+ }
904
+ }
905
+
906
+ return {
907
+ total: active + expired,
908
+ active,
909
+ expired,
910
+ by_instance: Object.fromEntries(byInstance),
911
+ };
912
+ }
913
+
914
+ // ── Public API ───────────────────────────────────────────────────────────────
915
+
916
+ // For testing
917
+ export const _test = {
918
+ sweepExpiredClaims,
919
+ resolveDuplicateClaim,
920
+ isClaimExpired,
921
+ loadClaimsRegistry,
922
+ saveClaimsRegistry,
923
+ generateClaimToken,
924
+ };