@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/task-claims.mjs
ADDED
|
@@ -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
|
+
};
|