u-foo 3.0.0 → 3.0.1

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.
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+
3
+ const { assertTransport } = require("./transportContract");
4
+
5
+ /**
6
+ * Anthropic Messages API transport adapter.
7
+ * @param {{
8
+ * resolveUrl: Function,
9
+ * runTurn: Function,
10
+ * toJsonString: Function,
11
+ * clipText: Function,
12
+ * }} deps
13
+ */
14
+ function createAnthropicMessagesTransport(deps = {}) {
15
+ const {
16
+ resolveUrl,
17
+ runTurn,
18
+ toJsonString,
19
+ clipText,
20
+ } = deps;
21
+
22
+ const transport = {
23
+ name: "anthropic-messages",
24
+ resolveUrl,
25
+ prepareMessages({ messages, prompt }) {
26
+ messages.push({
27
+ role: "user",
28
+ content: String(prompt || ""),
29
+ });
30
+ },
31
+ runTurn,
32
+ getToolCalls(turnResult) {
33
+ return Array.isArray(turnResult.toolCalls) ? turnResult.toolCalls : [];
34
+ },
35
+ appendFinalAssistantMessage({ messages, turnResult }) {
36
+ const assistantContent = Array.isArray(turnResult.assistantContent)
37
+ ? turnResult.assistantContent
38
+ : [];
39
+ if (assistantContent.length > 0) {
40
+ messages.push({
41
+ role: "assistant",
42
+ content: assistantContent,
43
+ });
44
+ } else if (String(turnResult.text || "").trim()) {
45
+ messages.push({
46
+ role: "assistant",
47
+ content: [
48
+ {
49
+ type: "text",
50
+ text: String(turnResult.text || ""),
51
+ },
52
+ ],
53
+ });
54
+ }
55
+ },
56
+ prepareToolCalls({ messages, turnResult, toolCalls }) {
57
+ const assistantContent = Array.isArray(turnResult.assistantContent)
58
+ ? turnResult.assistantContent
59
+ : [];
60
+
61
+ messages.push({
62
+ role: "assistant",
63
+ content: assistantContent,
64
+ });
65
+
66
+ return toolCalls.map((call) => ({
67
+ name: call.name,
68
+ args: call.args,
69
+ source: call,
70
+ }));
71
+ },
72
+ appendToolResult({ collected, call, toolResult }) {
73
+ collected.push({
74
+ type: "tool_result",
75
+ tool_use_id: String(call.source.id || ""),
76
+ content: clipText(toJsonString(toolResult), 12000),
77
+ is_error: Boolean(!toolResult || toolResult.ok === false),
78
+ });
79
+ },
80
+ flushToolResults({ messages, collected }) {
81
+ messages.push({
82
+ role: "user",
83
+ content: collected,
84
+ });
85
+ },
86
+ };
87
+
88
+ return assertTransport(transport, "anthropic-messages");
89
+ }
90
+
91
+ module.exports = {
92
+ createAnthropicMessagesTransport,
93
+ };
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+
3
+ module.exports = {
4
+ ...require("./transportContract"),
5
+ ...require("./openaiChatTransport"),
6
+ ...require("./anthropicMessagesTransport"),
7
+ };
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ const { randomUUID } = require("crypto");
4
+ const { assertTransport } = require("./transportContract");
5
+
6
+ /**
7
+ * OpenAI-compatible chat-completions transport adapter.
8
+ * @param {{
9
+ * resolveUrl: Function,
10
+ * runTurn: Function,
11
+ * normalizeToolName: Function,
12
+ * normalizeToolCallArgs: Function,
13
+ * toJsonString: Function,
14
+ * clipText: Function,
15
+ * }} deps
16
+ */
17
+ function createOpenAiChatTransport(deps = {}) {
18
+ const {
19
+ resolveUrl,
20
+ runTurn,
21
+ normalizeToolName,
22
+ normalizeToolCallArgs,
23
+ toJsonString,
24
+ clipText,
25
+ } = deps;
26
+
27
+ const transport = {
28
+ name: "openai-chat",
29
+ resolveUrl,
30
+ prepareMessages({ messages, systemPrompt, prompt }) {
31
+ const systemText = String(systemPrompt || "").trim();
32
+ const hasSystem = messages.some((entry) => String(entry.role || "").trim() === "system");
33
+ if (systemText && !hasSystem) {
34
+ messages.unshift({ role: "system", content: systemText });
35
+ }
36
+ messages.push({ role: "user", content: String(prompt || "") });
37
+ },
38
+ runTurn,
39
+ getToolCalls(turnResult) {
40
+ return Array.isArray(turnResult.toolCalls)
41
+ ? turnResult.toolCalls.filter((call) => call && call.function && typeof call.function === "object")
42
+ : [];
43
+ },
44
+ appendFinalAssistantMessage({ messages, turnResult }) {
45
+ const text = String(turnResult.text || "").trim();
46
+ if (text) {
47
+ messages.push({
48
+ role: "assistant",
49
+ content: text,
50
+ });
51
+ }
52
+ },
53
+ prepareToolCalls({ messages, toolCalls }) {
54
+ const assistantToolCalls = [];
55
+ for (const call of toolCalls) {
56
+ const callId = String(call.id || `call_${randomUUID()}`);
57
+ const name = normalizeToolName(call.function.name || "");
58
+ const args = normalizeToolCallArgs(call.function.arguments || "");
59
+
60
+ assistantToolCalls.push({
61
+ id: callId,
62
+ type: "function",
63
+ function: {
64
+ name: name || String(call.function.name || ""),
65
+ arguments: toJsonString(args),
66
+ },
67
+ });
68
+ }
69
+
70
+ if (assistantToolCalls.length === 0) return null;
71
+
72
+ messages.push({
73
+ role: "assistant",
74
+ content: null,
75
+ tool_calls: assistantToolCalls,
76
+ });
77
+
78
+ return assistantToolCalls.map((toolCall) => ({
79
+ name: toolCall.function.name,
80
+ args: normalizeToolCallArgs(toolCall.function.arguments),
81
+ source: toolCall,
82
+ }));
83
+ },
84
+ appendToolResult({ messages, call, toolResult }) {
85
+ messages.push({
86
+ role: "tool",
87
+ tool_call_id: call.source.id,
88
+ content: clipText(toJsonString(toolResult), 12000),
89
+ });
90
+ },
91
+ };
92
+
93
+ return assertTransport(transport, "openai-chat");
94
+ }
95
+
96
+ module.exports = {
97
+ createOpenAiChatTransport,
98
+ };
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Transport contract for native Agent Loop Provider adapters.
5
+ *
6
+ * Transports own wire-format conversion and turn execution only.
7
+ * They must not decide Plan Mode, write leases, or tool batch policy.
8
+ *
9
+ * Required methods:
10
+ * - resolveUrl(baseUrl) → string
11
+ * - prepareMessages({ messages, systemPrompt?, prompt })
12
+ * - runTurn(params) → Promise<turnResult>
13
+ * - getToolCalls(turnResult) → array
14
+ * - appendFinalAssistantMessage({ messages, turnResult })
15
+ * - prepareToolCalls({ messages, turnResult?, toolCalls }) → pendingCalls|null
16
+ * - appendToolResult({ messages?, collected?, call, toolResult })
17
+ * - flushToolResults?({ messages, collected }) // Anthropic-style batch
18
+ */
19
+
20
+ const TRANSPORT_NAMES = Object.freeze(["openai-chat", "anthropic-messages"]);
21
+
22
+ function assertTransport(transport = null, name = "") {
23
+ if (!transport || typeof transport !== "object") {
24
+ throw new Error(`missing transport${name ? `: ${name}` : ""}`);
25
+ }
26
+ const required = [
27
+ "resolveUrl",
28
+ "prepareMessages",
29
+ "runTurn",
30
+ "getToolCalls",
31
+ "appendFinalAssistantMessage",
32
+ "prepareToolCalls",
33
+ "appendToolResult",
34
+ ];
35
+ for (const key of required) {
36
+ if (typeof transport[key] !== "function") {
37
+ throw new Error(`transport ${name || "?"} missing ${key}`);
38
+ }
39
+ }
40
+ return transport;
41
+ }
42
+
43
+ module.exports = {
44
+ TRANSPORT_NAMES,
45
+ assertTransport,
46
+ };
package/src/code/repl.js CHANGED
@@ -283,6 +283,7 @@ async function runUcodeCoreAgent({
283
283
  resolveUcodeProviderModel,
284
284
  runNaturalLanguageTask,
285
285
  resumeAfterUserInteraction,
286
+ submitUserInteractionAnswer,
286
287
  } = require("./agent");
287
288
  const resolvedWorkspaceRoot = resolveUfooProjectRoot(workspaceRoot);
288
289
  const resolvedUcode = resolveUcodeProviderModel({
@@ -592,23 +593,12 @@ async function runUcodeCoreAgent({
592
593
 
593
594
  // Pending approval/choice/chat takes priority over nudge / new NL.
594
595
  try {
595
- const {
596
- hasPendingUserInteraction,
597
- parseUserInteractionInput,
598
- getPendingUserInteraction,
599
- } = require("./context/userInteraction");
596
+ const { hasPendingUserInteraction } = require("./context/userInteraction");
600
597
  if (
601
598
  trimmed
602
599
  && state.executionState
603
600
  && hasPendingUserInteraction(state.executionState)
604
601
  ) {
605
- const pending = getPendingUserInteraction(state.executionState);
606
- const parsed = parseUserInteractionInput(pending, trimmed);
607
- if (!parsed.ok) {
608
- stdout.write(`${parsed.error || "Invalid reply"}\n`);
609
- printPrompt(stdout);
610
- return;
611
- }
612
602
  chain = chain.then(async () => {
613
603
  let streamBuffer = null;
614
604
  let streamedVisible = false;
@@ -622,7 +612,7 @@ async function runUcodeCoreAgent({
622
612
  taskInFlight = true;
623
613
  let resumeResult;
624
614
  try {
625
- resumeResult = await resumeAfterUserInteraction(trimmed, state, {
615
+ resumeResult = await submitUserInteractionAnswer(trimmed, state, {
626
616
  onDelta: state.jsonOutput
627
617
  ? null
628
618
  : async (delta) => {
@@ -649,15 +639,12 @@ async function runUcodeCoreAgent({
649
639
  if (streamed && streamedVisible && resumeResult && resumeResult.streamLastChar !== "\n") {
650
640
  stdout.write("\n");
651
641
  }
652
- if (resumeResult && resumeResult.waitingUserInteraction) {
653
- stdout.write("Still waiting for your reply.\n");
654
- } else if (!resumeResult || resumeResult.ok === false) {
642
+ if (!resumeResult || resumeResult.ok === false) {
655
643
  stdout.write(`Error: ${(resumeResult && resumeResult.error) || "resume failed"}\n`);
656
- } else {
657
- const shouldSkipSummary = Boolean(streamed && resumeResult.ok && streamedVisible);
658
- if (!shouldSkipSummary && resumeResult.summary) {
659
- stdout.write(`${resumeResult.summary}\n`);
660
- }
644
+ } else if (resumeResult.shouldEchoSummary && resumeResult.echoSummaryText) {
645
+ stdout.write(`${resumeResult.echoSummaryText}\n`);
646
+ } else if (resumeResult.waitingUserInteraction) {
647
+ stdout.write("Still waiting for your reply.\n");
661
648
  }
662
649
  const persisted = persistSessionState(state);
663
650
  if (!state.jsonOutput && (!persisted || persisted.ok === false)) {
@@ -357,17 +357,28 @@ function processTaskRun(executionState = null, taskRunId = "", options = {}) {
357
357
 
358
358
  /**
359
359
  * Resume queued/running TaskRuns after process restart (executionState restored).
360
+ * Requeues interrupted mid-tool/model runs, releases stale leases, then advances.
360
361
  */
361
362
  function resumePersistedTaskRuns(executionState = null, options = {}) {
362
363
  const state = ensureGraphs(executionState);
364
+ const {
365
+ recoverTaskRunsAfterRestart,
366
+ } = require("./taskRun");
367
+ const { releaseStaleWriteLeases } = require("./workspaceLease");
368
+ const recovery = recoverTaskRunsAfterRestart(state);
369
+ const leases = releaseStaleWriteLeases(state, {
370
+ maxAgeMs: options.leaseStaleMs,
371
+ });
363
372
  const byId = state.taskRuns && state.taskRuns.byId ? state.taskRuns.byId : {};
364
373
  const results = [];
365
374
  for (const run of Object.values(byId)) {
366
375
  if (!run) continue;
367
- if (run.status !== "queued" && run.status !== "running") continue;
376
+ if (run.status !== "queued" && run.status !== "running" && run.status !== "cancelling") {
377
+ continue;
378
+ }
368
379
  results.push(processTaskRun(state, run.id, options));
369
380
  }
370
- return results;
381
+ return { recovery, leases, results };
371
382
  }
372
383
 
373
384
  module.exports = {
@@ -4,6 +4,15 @@ const { randomUUID } = require("crypto");
4
4
 
5
5
  /**
6
6
  * TaskRun registry — parent Task node identity vs runnable attempt.
7
+ *
8
+ * Scheduler owner: TaskLoop (`processTaskRun` / `resumePersistedTaskRuns`).
9
+ * Agent Loop may only issue control commands (start/cancel/complete) via CAS.
10
+ *
11
+ * Restart rules:
12
+ * - queued → remain queued (scheduler resumes)
13
+ * - running + phase waiting_model|executing_tools|planning → requeue to queued
14
+ * - cancelling → stay cancelling until cancel completes
15
+ * - terminal → never transition backward
7
16
  */
8
17
 
9
18
  const TASK_RUN_STATUSES = Object.freeze([
@@ -25,6 +34,31 @@ const TASK_RUN_PHASES = Object.freeze([
25
34
 
26
35
  const TERMINAL_TASK_RUN = new Set(["succeeded", "failed", "cancelled"]);
27
36
 
37
+ /** Keep in sync with protocol/transitions.TASK_RUN_TRANSITIONS. */
38
+ const TASK_RUN_TRANSITIONS = Object.freeze({
39
+ queued: Object.freeze(["running", "cancelled"]),
40
+ running: Object.freeze(["succeeded", "failed", "cancelling"]),
41
+ cancelling: Object.freeze(["cancelled", "failed"]),
42
+ succeeded: Object.freeze([]),
43
+ failed: Object.freeze([]),
44
+ cancelled: Object.freeze([]),
45
+ });
46
+
47
+ /** Default write-lease / heartbeat staleness (ms). */
48
+ const DEFAULT_LEASE_STALE_MS = 30 * 60 * 1000;
49
+
50
+ /** Extra recovery edge used only by recoverTaskRunsAfterRestart. */
51
+ const RECOVERY_TRANSITIONS = Object.freeze({
52
+ running: Object.freeze(["queued"]),
53
+ });
54
+
55
+ function isAllowedTaskRunTransition(fromStatus = "", toStatus = "") {
56
+ const from = String(fromStatus || "").trim();
57
+ const to = String(toStatus || "").trim();
58
+ const allowed = TASK_RUN_TRANSITIONS[from];
59
+ if (!allowed) return false;
60
+ return allowed.includes(to);
61
+ }
28
62
  function createTaskRunId() {
29
63
  return `trun_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`;
30
64
  }
@@ -33,6 +67,7 @@ function emptyTaskRunStore() {
33
67
  return {
34
68
  byId: {},
35
69
  commandLog: {},
70
+ wakeupLog: {},
36
71
  };
37
72
  }
38
73
 
@@ -47,6 +82,9 @@ function ensureTaskRunStore(executionState = null) {
47
82
  if (!state.taskRuns.commandLog || typeof state.taskRuns.commandLog !== "object") {
48
83
  state.taskRuns.commandLog = {};
49
84
  }
85
+ if (!state.taskRuns.wakeupLog || typeof state.taskRuns.wakeupLog !== "object") {
86
+ state.taskRuns.wakeupLog = {};
87
+ }
50
88
  return state.taskRuns;
51
89
  }
52
90
 
@@ -73,6 +111,8 @@ function createTaskRun({
73
111
  createdAt: now,
74
112
  startedAt: "",
75
113
  completedAt: "",
114
+ heartbeatAt: "",
115
+ lastWakeupId: "",
76
116
  };
77
117
  }
78
118
 
@@ -113,8 +153,26 @@ function isTerminalTaskRun(run = null) {
113
153
  return Boolean(run && TERMINAL_TASK_RUN.has(String(run.status || "")));
114
154
  }
115
155
 
156
+ function touchTaskRunHeartbeat(executionState = null, taskRunId = "") {
157
+ const run = getTaskRun(executionState, taskRunId);
158
+ if (!run) return null;
159
+ run.heartbeatAt = new Date().toISOString();
160
+ putTaskRun(executionState, run);
161
+ return run;
162
+ }
163
+
164
+ function isTransitionAllowed(fromStatus, toStatus, { allowRecovery = false } = {}) {
165
+ if (fromStatus === toStatus) return true;
166
+ if (isAllowedTaskRunTransition(fromStatus, toStatus)) return true;
167
+ if (allowRecovery) {
168
+ const extra = RECOVERY_TRANSITIONS[fromStatus] || [];
169
+ return extra.includes(toStatus);
170
+ }
171
+ return false;
172
+ }
173
+
116
174
  /**
117
- * Compare-and-set status transition. Returns { ok, run }.
175
+ * Compare-and-set status transition. Enforces allowed edges; terminal is final.
118
176
  */
119
177
  function casTaskRunStatus(executionState = null, taskRunId = "", {
120
178
  expectedStatus = "",
@@ -123,6 +181,7 @@ function casTaskRunStatus(executionState = null, taskRunId = "", {
123
181
  result = null,
124
182
  error = null,
125
183
  changedFiles = null,
184
+ allowRecovery = false,
126
185
  } = {}) {
127
186
  const run = getTaskRun(executionState, taskRunId);
128
187
  if (!run) return { ok: false, code: "TASK_RUN_NOT_FOUND", run: null };
@@ -135,10 +194,28 @@ function casTaskRunStatus(executionState = null, taskRunId = "", {
135
194
  currentStatus: run.status,
136
195
  };
137
196
  }
197
+ if (TERMINAL_TASK_RUN.has(run.status)) {
198
+ return {
199
+ ok: false,
200
+ code: "TASK_ALREADY_TERMINAL",
201
+ run,
202
+ currentStatus: run.status,
203
+ };
204
+ }
138
205
  const next = String(nextStatus || "").trim();
139
206
  if (!TASK_RUN_STATUSES.includes(next)) {
140
207
  return { ok: false, code: "INVALID_TASK_STATUS", run };
141
208
  }
209
+ if (!isTransitionAllowed(run.status, next, { allowRecovery })) {
210
+ return {
211
+ ok: false,
212
+ code: "TASK_TRANSITION_FORBIDDEN",
213
+ run,
214
+ currentStatus: run.status,
215
+ nextStatus: next,
216
+ allowed: (TASK_RUN_TRANSITIONS[run.status] || []).slice(),
217
+ };
218
+ }
142
219
  run.status = next;
143
220
  if (phase && TASK_RUN_PHASES.includes(phase)) run.phase = phase;
144
221
  if (result !== null) run.result = result;
@@ -150,6 +227,7 @@ function casTaskRunStatus(executionState = null, taskRunId = "", {
150
227
  run.phase = "finalizing";
151
228
  }
152
229
  if (next === "cancelling") run.cancelRequested = true;
230
+ run.heartbeatAt = new Date().toISOString();
153
231
  putTaskRun(executionState, run);
154
232
  return { ok: true, run };
155
233
  }
@@ -168,10 +246,89 @@ function getCachedControlCommand(executionState = null, commandId = "") {
168
246
  return store.commandLog[id] ? JSON.parse(JSON.stringify(store.commandLog[id])) : null;
169
247
  }
170
248
 
249
+ /**
250
+ * Deduplicate wakeups by wakeupId. Second delivery returns cached result.
251
+ */
252
+ function beginWakeup(executionState = null, wakeupId = "", meta = {}) {
253
+ const id = String(wakeupId || "").trim();
254
+ if (!id) return { ok: true, fresh: true };
255
+ const store = ensureTaskRunStore(executionState);
256
+ const existing = store.wakeupLog[id];
257
+ if (existing && existing.status === "completed") {
258
+ return {
259
+ ok: true,
260
+ fresh: false,
261
+ idempotentReplay: true,
262
+ result: existing.result ? JSON.parse(JSON.stringify(existing.result)) : existing,
263
+ };
264
+ }
265
+ if (existing && existing.status === "started") {
266
+ return {
267
+ ok: true,
268
+ fresh: false,
269
+ idempotentReplay: true,
270
+ result: { status: "in_flight", wakeupId: id },
271
+ };
272
+ }
273
+ store.wakeupLog[id] = {
274
+ status: "started",
275
+ startedAt: new Date().toISOString(),
276
+ ...meta,
277
+ };
278
+ return { ok: true, fresh: true };
279
+ }
280
+
281
+ function completeWakeup(executionState = null, wakeupId = "", result = {}) {
282
+ const id = String(wakeupId || "").trim();
283
+ if (!id) return;
284
+ const store = ensureTaskRunStore(executionState);
285
+ store.wakeupLog[id] = {
286
+ ...(store.wakeupLog[id] || {}),
287
+ status: "completed",
288
+ completedAt: new Date().toISOString(),
289
+ result: JSON.parse(JSON.stringify(result || {})),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * After process restart: requeue interrupted running runs; leave cancelling alone.
295
+ * Does not execute tools — caller should invoke processTaskRun separately.
296
+ */
297
+ function recoverTaskRunsAfterRestart(executionState = null) {
298
+ const store = ensureTaskRunStore(executionState);
299
+ const recovered = [];
300
+ for (const run of Object.values(store.byId)) {
301
+ if (!run) continue;
302
+ if (run.status === "running") {
303
+ const phase = String(run.phase || "");
304
+ if (phase === "waiting_model" || phase === "executing_tools" || phase === "planning") {
305
+ const cas = casTaskRunStatus(executionState, run.id, {
306
+ expectedStatus: "running",
307
+ nextStatus: "queued",
308
+ phase: "initializing",
309
+ allowRecovery: true,
310
+ });
311
+ recovered.push({
312
+ taskRunId: run.id,
313
+ action: cas.ok ? "requeued" : "skip",
314
+ code: cas.ok ? "" : cas.code,
315
+ });
316
+ } else {
317
+ recovered.push({ taskRunId: run.id, action: "resume_running" });
318
+ }
319
+ } else if (run.status === "queued" || run.status === "cancelling") {
320
+ recovered.push({ taskRunId: run.id, action: `resume_${run.status}` });
321
+ }
322
+ }
323
+ return recovered;
324
+ }
325
+
171
326
  module.exports = {
172
327
  TASK_RUN_STATUSES,
173
328
  TASK_RUN_PHASES,
174
329
  TERMINAL_TASK_RUN,
330
+ TASK_RUN_TRANSITIONS,
331
+ DEFAULT_LEASE_STALE_MS,
175
332
  createTaskRunId,
176
333
  emptyTaskRunStore,
177
334
  ensureTaskRunStore,
@@ -181,7 +338,11 @@ module.exports = {
181
338
  findActiveTaskRunForNode,
182
339
  listActiveWritingTaskRuns,
183
340
  isTerminalTaskRun,
341
+ touchTaskRunHeartbeat,
184
342
  casTaskRunStatus,
185
343
  cacheControlCommand,
186
344
  getCachedControlCommand,
345
+ beginWakeup,
346
+ completeWakeup,
347
+ recoverTaskRunsAfterRestart,
187
348
  };
@@ -190,6 +190,46 @@ function hasActiveWriteLease(executionState = null) {
190
190
  return countWriteLeases(executionState) > 0;
191
191
  }
192
192
 
193
+ /**
194
+ * Release leases whose TaskRun is missing/terminal, or whose acquiredAt is older
195
+ * than maxAgeMs without an active non-terminal run heartbeat.
196
+ */
197
+ function releaseStaleWriteLeases(executionState = null, {
198
+ maxAgeMs = require("./taskRun").DEFAULT_LEASE_STALE_MS,
199
+ nowMs = Date.now(),
200
+ } = {}) {
201
+ const {
202
+ getTaskRun,
203
+ isTerminalTaskRun,
204
+ } = require("./taskRun");
205
+ const lease = ensureWorkspaceLease(executionState);
206
+ const holders = normalizeHolders(lease);
207
+ const released = [];
208
+ const kept = [];
209
+ const maxAge = Number.isFinite(maxAgeMs) ? Math.max(0, maxAgeMs) : 0;
210
+
211
+ for (const holder of holders) {
212
+ const run = getTaskRun(executionState, holder.taskRunId);
213
+ if (!run || isTerminalTaskRun(run)) {
214
+ released.push({ taskRunId: holder.taskRunId, reason: "terminal_or_missing" });
215
+ continue;
216
+ }
217
+ const acquired = Date.parse(String(holder.acquiredAt || ""));
218
+ if (maxAge > 0 && Number.isFinite(acquired) && (nowMs - acquired) > maxAge) {
219
+ const beat = Date.parse(String(run.heartbeatAt || run.startedAt || ""));
220
+ if (!Number.isFinite(beat) || (nowMs - beat) > maxAge) {
221
+ released.push({ taskRunId: holder.taskRunId, reason: "stale_heartbeat" });
222
+ continue;
223
+ }
224
+ }
225
+ kept.push(holder);
226
+ }
227
+
228
+ lease.holders = kept;
229
+ if (kept.length === 0) lease.acquiredAt = "";
230
+ return { ok: true, released, kept: kept.slice(), lease };
231
+ }
232
+
193
233
  module.exports = {
194
234
  WRITE_TOOLS,
195
235
  MAX_CONCURRENT_WRITE_LEASES,
@@ -205,4 +245,5 @@ module.exports = {
205
245
  clearWorkspaceLease,
206
246
  checkWriteAllowed,
207
247
  hasActiveWriteLease,
248
+ releaseStaleWriteLeases,
208
249
  };
@@ -68,6 +68,7 @@ function normalizeContextPolicy(value = {}) {
68
68
  }
69
69
 
70
70
  function buildSessionSnapshot(input = {}) {
71
+ // Durable session fields vs projections: src/code/protocol/ownership.js
71
72
  const source = input && typeof input === "object" ? input : {};
72
73
  const sessionId = resolveSessionId(source.sessionId);
73
74
  const createdAt = String(source.createdAt || "").trim() || toIsoNow();