u-foo 2.5.14 → 3.0.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 (59) hide show
  1. package/package.json +1 -1
  2. package/src/agents/prompts/native/environment.js +20 -8
  3. package/src/code/agent.js +517 -112
  4. package/src/code/commands.js +77 -0
  5. package/src/code/context/artifactGc.js +292 -0
  6. package/src/code/context/artifactIndex.js +161 -0
  7. package/src/code/context/artifacts.js +183 -0
  8. package/src/code/context/assembler.js +703 -0
  9. package/src/code/context/executionSegment.js +292 -0
  10. package/src/code/context/index.js +28 -0
  11. package/src/code/context/planGraph.js +1410 -0
  12. package/src/code/context/planGraphService.js +857 -0
  13. package/src/code/context/planMode.js +398 -0
  14. package/src/code/context/planProjection.js +432 -0
  15. package/src/code/context/projectSnapshot.js +201 -0
  16. package/src/code/context/promptLayers.js +175 -0
  17. package/src/code/context/reducers.js +328 -0
  18. package/src/code/context/stableJson.js +29 -0
  19. package/src/code/context/stateCommit.js +414 -0
  20. package/src/code/context/toolRuntime.js +172 -0
  21. package/src/code/context/transcript.js +182 -0
  22. package/src/code/context/transcriptSync.js +106 -0
  23. package/src/code/context/userInteraction.js +457 -0
  24. package/src/code/context/userNudge.js +116 -0
  25. package/src/code/context/workingSet.js +323 -0
  26. package/src/code/dispatch.js +20 -1
  27. package/src/code/index.js +8 -0
  28. package/src/code/modelCommand.js +87 -0
  29. package/src/code/nativeRunner.js +625 -34
  30. package/src/code/repl.js +196 -50
  31. package/src/code/runtime/agentWakeup.js +58 -0
  32. package/src/code/runtime/graphOwner.js +41 -0
  33. package/src/code/runtime/graphYieldRouter.js +42 -0
  34. package/src/code/runtime/index.js +15 -0
  35. package/src/code/runtime/loopMailbox.js +124 -0
  36. package/src/code/runtime/runtimeEvents.js +39 -0
  37. package/src/code/runtime/taskControl.js +565 -0
  38. package/src/code/runtime/taskFocus.js +165 -0
  39. package/src/code/runtime/taskLoop.js +383 -0
  40. package/src/code/runtime/taskRun.js +187 -0
  41. package/src/code/runtime/toolProvenance.js +70 -0
  42. package/src/code/runtime/workspaceLease.js +208 -0
  43. package/src/code/sessionStore.js +217 -15
  44. package/src/code/skills/index.js +10 -0
  45. package/src/code/skills/injection.js +66 -3
  46. package/src/code/skills/loader.js +21 -0
  47. package/src/code/skills/manifest.js +87 -0
  48. package/src/code/skills/render.js +15 -1
  49. package/src/code/taskDecomposer.js +56 -2
  50. package/src/code/tools/artifactRead.js +40 -0
  51. package/src/code/tools/askUser.js +11 -0
  52. package/src/code/tools/planGraph.js +29 -0
  53. package/src/code/tui.js +2 -0
  54. package/src/code/usageStore.js +15 -0
  55. package/src/ui/format/index.js +285 -45
  56. package/src/ui/format/markdownRenderer.js +436 -71
  57. package/src/ui/ink/ChatApp.js +39 -8
  58. package/src/ui/ink/UcodeApp.js +592 -43
  59. package/src/ui/ink/chatLogModel.js +102 -21
@@ -0,0 +1,414 @@
1
+ "use strict";
2
+
3
+ function emptyTaskContract() {
4
+ return {
5
+ objective: "",
6
+ successCriteria: [],
7
+ constraints: [],
8
+ preferences: [],
9
+ };
10
+ }
11
+
12
+ function emptyStateEpoch() {
13
+ return {
14
+ epochId: 1,
15
+ snapshot: {
16
+ phase: "active",
17
+ currentObjective: "",
18
+ facts: [],
19
+ hypotheses: [],
20
+ decisions: [],
21
+ openQuestions: [],
22
+ },
23
+ commits: [],
24
+ };
25
+ }
26
+
27
+ function extractConstraintsFromText(text = "") {
28
+ const raw = String(text || "");
29
+ const constraints = [];
30
+ const patterns = [
31
+ /(?:don'?t|do not|never|without)\s+[^!?\n]{8,120}/gi,
32
+ /(?:must|should|need to)\s+[^!?\n]{8,120}/gi,
33
+ /(?:不要|不能|禁止|必须)[^.!?\n]{4,80}/g,
34
+ ];
35
+ for (const re of patterns) {
36
+ let match;
37
+ while ((match = re.exec(raw))) {
38
+ const item = String(match[0] || "").trim();
39
+ if (item && !constraints.includes(item)) constraints.push(item);
40
+ }
41
+ }
42
+ return constraints.slice(0, 8);
43
+ }
44
+
45
+ function buildInitialTaskContract(userTask = "") {
46
+ const task = String(userTask || "").trim();
47
+ if (!task) return emptyTaskContract();
48
+ const firstLine = task.split(/\r?\n/).map((l) => l.trim()).find(Boolean) || task;
49
+ return {
50
+ objective: firstLine.slice(0, 500),
51
+ successCriteria: [
52
+ "Complete the requested work with verifiable outcomes",
53
+ "Keep changes aligned with repository conventions",
54
+ ],
55
+ constraints: extractConstraintsFromText(task),
56
+ preferences: [],
57
+ };
58
+ }
59
+
60
+ function patchTaskContract(contract = {}, patch = {}) {
61
+ const base = contract && typeof contract === "object" ? { ...contract } : emptyTaskContract();
62
+ const source = patch && typeof patch === "object" ? patch : {};
63
+ if (typeof source.objective === "string" && source.objective.trim()) {
64
+ // Runtime does not allow silent objective overwrite unless explicitly patched
65
+ if (source.allowObjectiveReplace === true) {
66
+ base.objective = source.objective.trim();
67
+ }
68
+ }
69
+ const mergeList = (key) => {
70
+ const incoming = Array.isArray(source[key]) ? source[key].map(String).filter(Boolean) : [];
71
+ if (incoming.length === 0) return;
72
+ const set = new Set([...(Array.isArray(base[key]) ? base[key] : []), ...incoming]);
73
+ base[key] = Array.from(set);
74
+ };
75
+ mergeList("successCriteria");
76
+ mergeList("constraints");
77
+ mergeList("preferences");
78
+ return base;
79
+ }
80
+
81
+ function renderTaskContract(contract = null) {
82
+ if (!contract || !contract.objective) return "";
83
+ const lines = [
84
+ "Task Contract:",
85
+ `- Objective: ${contract.objective}`,
86
+ ];
87
+ if (Array.isArray(contract.successCriteria) && contract.successCriteria.length > 0) {
88
+ lines.push(`- Success criteria: ${contract.successCriteria.join("; ")}`);
89
+ }
90
+ if (Array.isArray(contract.constraints) && contract.constraints.length > 0) {
91
+ lines.push(`- Constraints: ${contract.constraints.join("; ")}`);
92
+ }
93
+ if (Array.isArray(contract.preferences) && contract.preferences.length > 0) {
94
+ lines.push(`- Preferences: ${contract.preferences.join("; ")}`);
95
+ }
96
+ return lines.join("\n");
97
+ }
98
+
99
+ function normalizeStateCommit(commit = {}) {
100
+ const source = commit && typeof commit === "object" ? commit : {};
101
+ return {
102
+ factsAdd: Array.isArray(source.factsAdd) ? source.factsAdd.map(String).filter(Boolean) : [],
103
+ factsUpdate: Array.isArray(source.factsUpdate) ? source.factsUpdate : [],
104
+ hypothesesAdd: Array.isArray(source.hypothesesAdd) ? source.hypothesesAdd.map(String).filter(Boolean) : [],
105
+ hypothesesUpdate: Array.isArray(source.hypothesesUpdate) ? source.hypothesesUpdate : [],
106
+ decisionsAdd: Array.isArray(source.decisionsAdd) ? source.decisionsAdd.map(String).filter(Boolean) : [],
107
+ questionsAdd: Array.isArray(source.questionsAdd) ? source.questionsAdd.map(String).filter(Boolean) : [],
108
+ questionsClose: Array.isArray(source.questionsClose) ? source.questionsClose.map(String).filter(Boolean) : [],
109
+ nextObjective: typeof source.nextObjective === "string" ? source.nextObjective.trim() : "",
110
+ };
111
+ }
112
+
113
+ function applyListPatch(list = [], { add = [], update = [], close = [] } = {}) {
114
+ let next = Array.isArray(list) ? list.slice() : [];
115
+ for (const item of add) {
116
+ if (!next.includes(item)) next.push(item);
117
+ }
118
+ for (const entry of update) {
119
+ if (!entry || typeof entry !== "object") continue;
120
+ const from = String(entry.from || "").trim();
121
+ const to = String(entry.to || "").trim();
122
+ if (!from || !to) continue;
123
+ next = next.map((v) => (v === from ? to : v));
124
+ }
125
+ for (const item of close) {
126
+ next = next.filter((v) => v !== item);
127
+ }
128
+ return next;
129
+ }
130
+
131
+ function applyStateCommit(stateEpoch = null, commit = {}) {
132
+ const epoch = stateEpoch && typeof stateEpoch === "object"
133
+ ? JSON.parse(JSON.stringify(stateEpoch))
134
+ : emptyStateEpoch();
135
+ const normalized = normalizeStateCommit(commit);
136
+ const snapshot = epoch.snapshot && typeof epoch.snapshot === "object"
137
+ ? epoch.snapshot
138
+ : emptyStateEpoch().snapshot;
139
+
140
+ snapshot.facts = applyListPatch(snapshot.facts, {
141
+ add: normalized.factsAdd,
142
+ update: normalized.factsUpdate,
143
+ });
144
+ snapshot.hypotheses = applyListPatch(snapshot.hypotheses, {
145
+ add: normalized.hypothesesAdd,
146
+ update: normalized.hypothesesUpdate,
147
+ });
148
+ snapshot.decisions = applyListPatch(snapshot.decisions, {
149
+ add: normalized.decisionsAdd,
150
+ });
151
+ snapshot.openQuestions = applyListPatch(snapshot.openQuestions, {
152
+ add: normalized.questionsAdd,
153
+ close: normalized.questionsClose,
154
+ });
155
+ if (normalized.nextObjective) snapshot.currentObjective = normalized.nextObjective;
156
+
157
+ epoch.snapshot = snapshot;
158
+ epoch.commits = Array.isArray(epoch.commits) ? epoch.commits : [];
159
+ epoch.commits.push({
160
+ at: new Date().toISOString(),
161
+ commit: normalized,
162
+ });
163
+ return epoch;
164
+ }
165
+
166
+ function renderStateEpoch(stateEpoch = null) {
167
+ if (!stateEpoch || !stateEpoch.snapshot) return "";
168
+ const snap = stateEpoch.snapshot;
169
+ const lines = [
170
+ "State Snapshot:",
171
+ `- Phase: ${snap.phase || "active"}`,
172
+ snap.currentObjective ? `- Current objective: ${snap.currentObjective}` : "",
173
+ snap.facts && snap.facts.length > 0 ? `- Facts: ${snap.facts.join("; ")}` : "",
174
+ snap.hypotheses && snap.hypotheses.length > 0 ? `- Hypotheses: ${snap.hypotheses.join("; ")}` : "",
175
+ snap.decisions && snap.decisions.length > 0 ? `- Decisions: ${snap.decisions.join("; ")}` : "",
176
+ snap.openQuestions && snap.openQuestions.length > 0 ? `- Open questions: ${snap.openQuestions.join("; ")}` : "",
177
+ ].filter(Boolean);
178
+ const commits = Array.isArray(stateEpoch.commits) ? stateEpoch.commits.slice(-3) : [];
179
+ if (commits.length > 0) {
180
+ lines.push("Recent state commits:");
181
+ for (const entry of commits) {
182
+ const c = entry.commit || {};
183
+ const bits = [];
184
+ if (c.factsAdd && c.factsAdd.length) bits.push(`facts+${c.factsAdd.length}`);
185
+ if (c.decisionsAdd && c.decisionsAdd.length) bits.push(`decisions+${c.decisionsAdd.length}`);
186
+ if (c.nextObjective) bits.push(`next=${c.nextObjective}`);
187
+ lines.push(`- ${entry.at}: ${bits.join(", ") || "noop"}`);
188
+ }
189
+ }
190
+ return lines.join("\n");
191
+ }
192
+
193
+ function extractBalancedJsonObjects(text = "") {
194
+ const source = String(text || "");
195
+ const objects = [];
196
+ for (let i = 0; i < source.length; i += 1) {
197
+ if (source[i] !== "{") continue;
198
+ let depth = 0;
199
+ let inString = false;
200
+ let escaped = false;
201
+ for (let j = i; j < source.length; j += 1) {
202
+ const ch = source[j];
203
+ if (inString) {
204
+ if (escaped) {
205
+ escaped = false;
206
+ } else if (ch === "\\") {
207
+ escaped = true;
208
+ } else if (ch === "\"") {
209
+ inString = false;
210
+ }
211
+ continue;
212
+ }
213
+ if (ch === "\"") {
214
+ inString = true;
215
+ continue;
216
+ }
217
+ if (ch === "{") depth += 1;
218
+ if (ch === "}") {
219
+ depth -= 1;
220
+ if (depth === 0) {
221
+ objects.push(source.slice(i, j + 1));
222
+ i = j;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return objects;
229
+ }
230
+
231
+ function isStructuredSideEffectPayload(parsed = null) {
232
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
233
+ // Strict: only recognized control envelopes. Do not treat arbitrary JSON
234
+ // (examples, docs, code) as executable side effects.
235
+ return Boolean(
236
+ parsed.stateCommit
237
+ || parsed.contextPlan
238
+ || parsed.nextSegment
239
+ || parsed.type === "execution_segment",
240
+ );
241
+ }
242
+
243
+ function parseStructuredSideEffects(text = "") {
244
+ const raw = String(text || "").trim();
245
+ if (!raw) return null;
246
+ const candidates = [];
247
+ const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/g);
248
+ if (fence) {
249
+ for (const block of fence) {
250
+ candidates.push(block.replace(/```(?:json)?/g, "").replace(/```/g, "").trim());
251
+ }
252
+ }
253
+ candidates.push(raw);
254
+ for (const objectText of extractBalancedJsonObjects(raw)) {
255
+ candidates.push(objectText);
256
+ }
257
+
258
+ for (const item of candidates) {
259
+ try {
260
+ const parsed = JSON.parse(item);
261
+ if (isStructuredSideEffectPayload(parsed)) return parsed;
262
+ } catch {
263
+ // keep scanning
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+
269
+ function resolveCommitInterval(env = process.env) {
270
+ const parsed = Number.parseInt(String(env.UFOO_UCODE_COMMIT_INTERVAL || ""), 10);
271
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
272
+ return 4;
273
+ }
274
+
275
+ function validateStateCommit(commit = {}) {
276
+ const normalized = normalizeStateCommit(commit);
277
+ const errors = [];
278
+ for (const listName of ["factsAdd", "hypothesesAdd", "decisionsAdd", "questionsAdd", "questionsClose"]) {
279
+ const list = normalized[listName];
280
+ if (!Array.isArray(list)) errors.push(`${listName} must be an array`);
281
+ else if (list.some((item) => String(item).length > 500)) errors.push(`${listName} item too long`);
282
+ }
283
+ for (const listName of ["factsUpdate", "hypothesesUpdate"]) {
284
+ const list = normalized[listName];
285
+ if (!Array.isArray(list)) errors.push(`${listName} must be an array`);
286
+ else {
287
+ for (const entry of list) {
288
+ if (!entry || typeof entry !== "object" || !entry.from || !entry.to) {
289
+ errors.push(`${listName} entries require from/to`);
290
+ }
291
+ }
292
+ }
293
+ }
294
+ if (normalized.nextObjective.length > 500) errors.push("nextObjective too long");
295
+ return { ok: errors.length === 0, errors, commit: normalized };
296
+ }
297
+
298
+ function buildDeterministicToolCommit(toolEvent = {}) {
299
+ const source = toolEvent && typeof toolEvent === "object" ? toolEvent : {};
300
+ const tool = String(source.tool || "").trim().toLowerCase();
301
+ const preview = String(source.preview || source.summary || "").trim();
302
+ const artifactId = String(source.artifactId || "").trim();
303
+ if (!tool && !preview) return null;
304
+ const fact = [
305
+ tool ? `tool:${tool}` : "",
306
+ artifactId ? `artifact:${artifactId}` : "",
307
+ preview ? preview.slice(0, 180) : "",
308
+ ].filter(Boolean).join(" ");
309
+ if (!fact) return null;
310
+ return {
311
+ factsAdd: [fact],
312
+ nextObjective: "",
313
+ };
314
+ }
315
+
316
+ function shouldCommitAfterToolCall(session = {}, interval = resolveCommitInterval()) {
317
+ const count = Number(session.toolCallsSinceCommit) || 0;
318
+ return count >= interval;
319
+ }
320
+
321
+ function appendCommitLog(workspaceRoot = process.cwd(), sessionId = "", commitEntry = {}) {
322
+ if (!sessionId) return { ok: false, error: "invalid session id" };
323
+ const fs = require("fs");
324
+ const path = require("path");
325
+ const filePath = path.join(
326
+ path.resolve(workspaceRoot || process.cwd()),
327
+ ".ufoo",
328
+ "agent",
329
+ "ucode",
330
+ "commits",
331
+ `${sessionId}.jsonl`,
332
+ );
333
+ try {
334
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
335
+ fs.appendFileSync(filePath, `${JSON.stringify(commitEntry)}\n`, "utf8");
336
+ return { ok: true, filePath };
337
+ } catch (err) {
338
+ return { ok: false, error: err && err.message ? err.message : "failed to append commit log" };
339
+ }
340
+ }
341
+
342
+ function resolveCommitFoldThreshold(env = process.env) {
343
+ const parsed = Number.parseInt(String(env.UFOO_UCODE_COMMIT_FOLD_THRESHOLD || ""), 10);
344
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
345
+ return 8;
346
+ }
347
+
348
+ function foldCommitsIfNeeded(stateEpoch = null, threshold = resolveCommitFoldThreshold()) {
349
+ const epoch = stateEpoch && typeof stateEpoch === "object"
350
+ ? JSON.parse(JSON.stringify(stateEpoch))
351
+ : emptyStateEpoch();
352
+ const commits = Array.isArray(epoch.commits) ? epoch.commits : [];
353
+ if (commits.length < threshold) return epoch;
354
+ epoch.commits = commits.slice(-3);
355
+ epoch.epochId = (Number(epoch.epochId) || 1) + 1;
356
+ return epoch;
357
+ }
358
+
359
+ function patchTaskContractFromUserMessage(contract = {}, userMessage = "") {
360
+ const constraints = extractConstraintsFromText(userMessage);
361
+ if (constraints.length === 0) return contract;
362
+ return patchTaskContract(contract, { constraints });
363
+ }
364
+
365
+ function applyValidatedStateCommit(session = {}, commit = {}, workspaceRoot = process.cwd()) {
366
+ const validated = validateStateCommit(commit);
367
+ if (!validated.ok) return { ok: false, errors: validated.errors, stateEpoch: session.stateEpoch };
368
+ session.stateEpoch = applyStateCommit(session.stateEpoch, validated.commit);
369
+ session.stateEpoch = foldCommitsIfNeeded(session.stateEpoch);
370
+ session.toolCallsSinceCommit = 0;
371
+ appendCommitLog(workspaceRoot, session.sessionId, {
372
+ at: new Date().toISOString(),
373
+ commit: validated.commit,
374
+ source: "runtime",
375
+ });
376
+ return { ok: true, errors: [], stateEpoch: session.stateEpoch };
377
+ }
378
+
379
+ function ensureTaskContract(session = {}, userTask = "") {
380
+ if (session.taskContract && session.taskContract.objective) return session.taskContract;
381
+ session.taskContract = buildInitialTaskContract(userTask);
382
+ return session.taskContract;
383
+ }
384
+
385
+ function ensureStateEpoch(session = {}) {
386
+ if (session.stateEpoch && session.stateEpoch.snapshot) return session.stateEpoch;
387
+ session.stateEpoch = emptyStateEpoch();
388
+ return session.stateEpoch;
389
+ }
390
+
391
+ module.exports = {
392
+ emptyTaskContract,
393
+ emptyStateEpoch,
394
+ buildInitialTaskContract,
395
+ patchTaskContract,
396
+ patchTaskContractFromUserMessage,
397
+ renderTaskContract,
398
+ normalizeStateCommit,
399
+ validateStateCommit,
400
+ buildDeterministicToolCommit,
401
+ resolveCommitInterval,
402
+ resolveCommitFoldThreshold,
403
+ foldCommitsIfNeeded,
404
+ shouldCommitAfterToolCall,
405
+ appendCommitLog,
406
+ applyValidatedStateCommit,
407
+ applyStateCommit,
408
+ renderStateEpoch,
409
+ parseStructuredSideEffects,
410
+ extractBalancedJsonObjects,
411
+ isStructuredSideEffectPayload,
412
+ ensureTaskContract,
413
+ ensureStateEpoch,
414
+ };
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Tool runtime descriptors, resource locks, and node claim leases.
5
+ * Used by the plan graph scheduler — not a second tool ABI.
6
+ */
7
+
8
+ const { randomUUID } = require("crypto");
9
+
10
+ const DEFAULT_LEASE_MS = 120000;
11
+
12
+ const TOOL_DESCRIPTORS = Object.freeze({
13
+ read: {
14
+ sideEffect: "none",
15
+ supportsCancellation: true,
16
+ retryClass: "safe",
17
+ resourceKeys(args = {}) {
18
+ const path = String(args.path || "").trim();
19
+ // Share the file key with write/edit so mixed batches stay conflict-free.
20
+ return path ? [`file:${path}`] : [];
21
+ },
22
+ },
23
+ artifact_read: {
24
+ sideEffect: "none",
25
+ supportsCancellation: true,
26
+ retryClass: "safe",
27
+ resourceKeys(args = {}) {
28
+ const id = String(args.artifactId || args.id || "").trim();
29
+ return id ? [`artifact:${id}`] : [];
30
+ },
31
+ },
32
+ write: {
33
+ sideEffect: "workspace",
34
+ supportsCancellation: false,
35
+ retryClass: "unsafe",
36
+ resourceKeys(args = {}) {
37
+ const path = String(args.path || "").trim();
38
+ return path ? [`file:${path}`] : ["workspace:*"];
39
+ },
40
+ },
41
+ edit: {
42
+ sideEffect: "workspace",
43
+ supportsCancellation: false,
44
+ retryClass: "unsafe",
45
+ resourceKeys(args = {}) {
46
+ const path = String(args.path || "").trim();
47
+ return path ? [`file:${path}`] : ["workspace:*"];
48
+ },
49
+ },
50
+ bash: {
51
+ sideEffect: "workspace",
52
+ supportsCancellation: true,
53
+ retryClass: "conditional",
54
+ resourceKeys() {
55
+ return ["workspace:*"];
56
+ },
57
+ },
58
+ });
59
+
60
+ function getToolDescriptor(tool = "") {
61
+ const name = String(tool || "").trim().toLowerCase();
62
+ return TOOL_DESCRIPTORS[name] || {
63
+ sideEffect: "external",
64
+ supportsCancellation: false,
65
+ retryClass: "unsafe",
66
+ resourceKeys: () => ["workspace:*"],
67
+ };
68
+ }
69
+
70
+ function createLease({
71
+ workerId = "local",
72
+ leaseMs = DEFAULT_LEASE_MS,
73
+ now = Date.now(),
74
+ } = {}) {
75
+ const ttl = Number.isFinite(leaseMs) ? Math.max(1000, Math.floor(leaseMs)) : DEFAULT_LEASE_MS;
76
+ return {
77
+ id: `lease_${randomUUID().slice(0, 8)}`,
78
+ workerId: String(workerId || "local"),
79
+ claimedAt: new Date(now).toISOString(),
80
+ expiresAt: new Date(now + ttl).toISOString(),
81
+ };
82
+ }
83
+
84
+ function isLeaseExpired(lease = null, now = Date.now()) {
85
+ if (!lease || !lease.expiresAt) return true;
86
+ const exp = Date.parse(String(lease.expiresAt));
87
+ if (!Number.isFinite(exp)) return true;
88
+ return now >= exp;
89
+ }
90
+
91
+ function recoverExpiredLeases(nodeMap = new Map(), {
92
+ now = Date.now(),
93
+ } = {}) {
94
+ const recovered = [];
95
+ for (const node of nodeMap.values()) {
96
+ if (node.status !== "running") continue;
97
+ if (!isLeaseExpired(node.lease, now)) continue;
98
+ const descriptor = getToolDescriptor(node.tool);
99
+ if (descriptor.retryClass === "safe") {
100
+ node.status = "pending";
101
+ node.lease = null;
102
+ node.error = "lease expired; retrying";
103
+ recovered.push({ id: node.id, action: "retry" });
104
+ } else {
105
+ node.status = "failed";
106
+ node.error = "lease expired; unsafe to auto-retry";
107
+ node.lease = null;
108
+ recovered.push({ id: node.id, action: "fail" });
109
+ }
110
+ }
111
+ return recovered;
112
+ }
113
+
114
+ function resourcesConflict(a = [], b = []) {
115
+ const setB = new Set(b);
116
+ for (const key of a) {
117
+ if (setB.has(key)) return true;
118
+ if (key === "workspace:*" && b.length > 0) return true;
119
+ if (setB.has("workspace:*") && a.length > 0) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Claim a conflict-free batch of ready tool nodes.
126
+ * parallel=false → at most one. Locks are advisory within a single advance pass.
127
+ */
128
+ function claimSafeReadyToolBatch(readyTools = [], {
129
+ parallel = true,
130
+ resolveArgs = (node) => node.args || {},
131
+ workerId = "local",
132
+ leaseMs = DEFAULT_LEASE_MS,
133
+ now = Date.now(),
134
+ maxBatch = 8,
135
+ } = {}) {
136
+ const claimed = [];
137
+ const held = [];
138
+
139
+ for (const node of readyTools) {
140
+ if (!parallel && claimed.length >= 1) break;
141
+ if (claimed.length >= maxBatch) break;
142
+
143
+ const args = resolveArgs(node) || {};
144
+ const descriptor = getToolDescriptor(node.tool);
145
+ const keys = typeof descriptor.resourceKeys === "function"
146
+ ? descriptor.resourceKeys(args)
147
+ : [];
148
+
149
+ const conflict = held.some((entry) => resourcesConflict(entry.keys, keys));
150
+ if (conflict) continue;
151
+
152
+ node.status = "running";
153
+ node.attempt = (Number(node.attempt) || 0) + 1;
154
+ node.lease = createLease({ workerId, leaseMs, now });
155
+ node.executionId = `exec_${node.id}_${node.attempt}_${randomUUID().slice(0, 6)}`;
156
+ claimed.push(node);
157
+ held.push({ keys, nodeId: node.id });
158
+ }
159
+
160
+ return claimed;
161
+ }
162
+
163
+ module.exports = {
164
+ DEFAULT_LEASE_MS,
165
+ TOOL_DESCRIPTORS,
166
+ getToolDescriptor,
167
+ createLease,
168
+ isLeaseExpired,
169
+ recoverExpiredLeases,
170
+ resourcesConflict,
171
+ claimSafeReadyToolBatch,
172
+ };