u-foo 2.5.15 → 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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/code/agent.js +350 -246
  3. package/src/code/commands.js +16 -0
  4. package/src/code/context/assembler.js +18 -13
  5. package/src/code/context/executionSegment.js +102 -119
  6. package/src/code/context/index.js +11 -1
  7. package/src/code/context/planGraph.js +1410 -0
  8. package/src/code/context/planGraphService.js +857 -0
  9. package/src/code/context/planMode.js +405 -0
  10. package/src/code/context/planProjection.js +432 -0
  11. package/src/code/context/promptLayers.js +21 -5
  12. package/src/code/context/stateCommit.js +2 -0
  13. package/src/code/context/toolRuntime.js +172 -0
  14. package/src/code/context/userInteraction.js +457 -0
  15. package/src/code/context/userNudge.js +116 -0
  16. package/src/code/dispatch.js +17 -1
  17. package/src/code/index.js +4 -0
  18. package/src/code/nativeRunner.js +589 -172
  19. package/src/code/protocol/controlPlane.js +93 -0
  20. package/src/code/protocol/faultHarness.js +90 -0
  21. package/src/code/protocol/index.js +20 -0
  22. package/src/code/protocol/loopEvents.js +102 -0
  23. package/src/code/protocol/materialize.js +107 -0
  24. package/src/code/protocol/messageFixtures.js +116 -0
  25. package/src/code/protocol/ownership.js +147 -0
  26. package/src/code/protocol/protocolValidator.js +165 -0
  27. package/src/code/protocol/suspension.js +173 -0
  28. package/src/code/protocol/toolCallLedger.js +222 -0
  29. package/src/code/protocol/transitions.js +97 -0
  30. package/src/code/providers/anthropicMessagesTransport.js +93 -0
  31. package/src/code/providers/index.js +7 -0
  32. package/src/code/providers/openaiChatTransport.js +98 -0
  33. package/src/code/providers/transportContract.js +46 -0
  34. package/src/code/repl.js +147 -18
  35. package/src/code/runtime/agentWakeup.js +58 -0
  36. package/src/code/runtime/graphOwner.js +41 -0
  37. package/src/code/runtime/graphYieldRouter.js +42 -0
  38. package/src/code/runtime/index.js +15 -0
  39. package/src/code/runtime/loopMailbox.js +124 -0
  40. package/src/code/runtime/runtimeEvents.js +39 -0
  41. package/src/code/runtime/taskControl.js +565 -0
  42. package/src/code/runtime/taskFocus.js +165 -0
  43. package/src/code/runtime/taskLoop.js +394 -0
  44. package/src/code/runtime/taskRun.js +348 -0
  45. package/src/code/runtime/toolProvenance.js +70 -0
  46. package/src/code/runtime/workspaceLease.js +249 -0
  47. package/src/code/sessionStore.js +1 -10
  48. package/src/code/skills/injection.js +1 -0
  49. package/src/code/taskDecomposer.js +32 -8
  50. package/src/code/taskRoute.js +73 -0
  51. package/src/code/tools/askUser.js +11 -0
  52. package/src/code/tools/planGraph.js +29 -0
  53. package/src/ui/format/index.js +25 -1
  54. package/src/ui/format/markdownRenderer.js +224 -2
  55. package/src/ui/ink/UcodeApp.js +268 -22
  56. package/src/code/context/featureFlag.js +0 -13
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+
3
+ const { randomUUID } = require("crypto");
4
+
5
+ /**
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
16
+ */
17
+
18
+ const TASK_RUN_STATUSES = Object.freeze([
19
+ "queued",
20
+ "running",
21
+ "cancelling",
22
+ "succeeded",
23
+ "failed",
24
+ "cancelled",
25
+ ]);
26
+
27
+ const TASK_RUN_PHASES = Object.freeze([
28
+ "initializing",
29
+ "planning",
30
+ "waiting_model",
31
+ "executing_tools",
32
+ "finalizing",
33
+ ]);
34
+
35
+ const TERMINAL_TASK_RUN = new Set(["succeeded", "failed", "cancelled"]);
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
+ }
62
+ function createTaskRunId() {
63
+ return `trun_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`;
64
+ }
65
+
66
+ function emptyTaskRunStore() {
67
+ return {
68
+ byId: {},
69
+ commandLog: {},
70
+ wakeupLog: {},
71
+ };
72
+ }
73
+
74
+ function ensureTaskRunStore(executionState = null) {
75
+ const state = executionState && typeof executionState === "object" ? executionState : {};
76
+ if (!state.taskRuns || typeof state.taskRuns !== "object") {
77
+ state.taskRuns = emptyTaskRunStore();
78
+ }
79
+ if (!state.taskRuns.byId || typeof state.taskRuns.byId !== "object") {
80
+ state.taskRuns.byId = {};
81
+ }
82
+ if (!state.taskRuns.commandLog || typeof state.taskRuns.commandLog !== "object") {
83
+ state.taskRuns.commandLog = {};
84
+ }
85
+ if (!state.taskRuns.wakeupLog || typeof state.taskRuns.wakeupLog !== "object") {
86
+ state.taskRuns.wakeupLog = {};
87
+ }
88
+ return state.taskRuns;
89
+ }
90
+
91
+ function createTaskRun({
92
+ parentGraphId = "",
93
+ parentNodeId = "",
94
+ childGraphId = "",
95
+ attempt = 1,
96
+ } = {}) {
97
+ const now = new Date().toISOString();
98
+ return {
99
+ id: createTaskRunId(),
100
+ parentGraphId: String(parentGraphId || "").trim(),
101
+ parentNodeId: String(parentNodeId || "").trim(),
102
+ childGraphId: String(childGraphId || "").trim(),
103
+ status: "queued",
104
+ phase: "initializing",
105
+ attempt: Number.isFinite(attempt) ? Math.max(1, Math.floor(attempt)) : 1,
106
+ ignoreUserPrompts: true,
107
+ cancelRequested: false,
108
+ result: null,
109
+ error: null,
110
+ changedFiles: [],
111
+ createdAt: now,
112
+ startedAt: "",
113
+ completedAt: "",
114
+ heartbeatAt: "",
115
+ lastWakeupId: "",
116
+ };
117
+ }
118
+
119
+ function getTaskRun(executionState = null, taskRunId = "") {
120
+ const store = ensureTaskRunStore(executionState);
121
+ const id = String(taskRunId || "").trim();
122
+ return id && store.byId[id] ? store.byId[id] : null;
123
+ }
124
+
125
+ function putTaskRun(executionState = null, taskRun = null) {
126
+ if (!taskRun || !taskRun.id) return null;
127
+ const store = ensureTaskRunStore(executionState);
128
+ store.byId[taskRun.id] = taskRun;
129
+ return taskRun;
130
+ }
131
+
132
+ function findActiveTaskRunForNode(executionState = null, parentNodeId = "") {
133
+ const store = ensureTaskRunStore(executionState);
134
+ const nodeId = String(parentNodeId || "").trim();
135
+ for (const run of Object.values(store.byId)) {
136
+ if (!run || run.parentNodeId !== nodeId) continue;
137
+ if (run.status === "queued" || run.status === "running" || run.status === "cancelling") {
138
+ return run;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function listActiveWritingTaskRuns(executionState = null) {
145
+ const store = ensureTaskRunStore(executionState);
146
+ return Object.values(store.byId).filter((run) => (
147
+ run
148
+ && (run.status === "queued" || run.status === "running" || run.status === "cancelling")
149
+ ));
150
+ }
151
+
152
+ function isTerminalTaskRun(run = null) {
153
+ return Boolean(run && TERMINAL_TASK_RUN.has(String(run.status || "")));
154
+ }
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
+
174
+ /**
175
+ * Compare-and-set status transition. Enforces allowed edges; terminal is final.
176
+ */
177
+ function casTaskRunStatus(executionState = null, taskRunId = "", {
178
+ expectedStatus = "",
179
+ nextStatus = "",
180
+ phase = "",
181
+ result = null,
182
+ error = null,
183
+ changedFiles = null,
184
+ allowRecovery = false,
185
+ } = {}) {
186
+ const run = getTaskRun(executionState, taskRunId);
187
+ if (!run) return { ok: false, code: "TASK_RUN_NOT_FOUND", run: null };
188
+ const expected = String(expectedStatus || "").trim();
189
+ if (expected && run.status !== expected) {
190
+ return {
191
+ ok: false,
192
+ code: "TASK_STATUS_CAS_FAILED",
193
+ run,
194
+ currentStatus: run.status,
195
+ };
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
+ }
205
+ const next = String(nextStatus || "").trim();
206
+ if (!TASK_RUN_STATUSES.includes(next)) {
207
+ return { ok: false, code: "INVALID_TASK_STATUS", run };
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
+ }
219
+ run.status = next;
220
+ if (phase && TASK_RUN_PHASES.includes(phase)) run.phase = phase;
221
+ if (result !== null) run.result = result;
222
+ if (error !== null) run.error = error;
223
+ if (Array.isArray(changedFiles)) run.changedFiles = changedFiles.map(String);
224
+ if (next === "running" && !run.startedAt) run.startedAt = new Date().toISOString();
225
+ if (TERMINAL_TASK_RUN.has(next)) {
226
+ run.completedAt = new Date().toISOString();
227
+ run.phase = "finalizing";
228
+ }
229
+ if (next === "cancelling") run.cancelRequested = true;
230
+ run.heartbeatAt = new Date().toISOString();
231
+ putTaskRun(executionState, run);
232
+ return { ok: true, run };
233
+ }
234
+
235
+ function cacheControlCommand(executionState = null, commandId = "", payload = {}) {
236
+ const id = String(commandId || "").trim();
237
+ if (!id) return;
238
+ const store = ensureTaskRunStore(executionState);
239
+ store.commandLog[id] = JSON.parse(JSON.stringify(payload));
240
+ }
241
+
242
+ function getCachedControlCommand(executionState = null, commandId = "") {
243
+ const id = String(commandId || "").trim();
244
+ if (!id) return null;
245
+ const store = ensureTaskRunStore(executionState);
246
+ return store.commandLog[id] ? JSON.parse(JSON.stringify(store.commandLog[id])) : null;
247
+ }
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
+
326
+ module.exports = {
327
+ TASK_RUN_STATUSES,
328
+ TASK_RUN_PHASES,
329
+ TERMINAL_TASK_RUN,
330
+ TASK_RUN_TRANSITIONS,
331
+ DEFAULT_LEASE_STALE_MS,
332
+ createTaskRunId,
333
+ emptyTaskRunStore,
334
+ ensureTaskRunStore,
335
+ createTaskRun,
336
+ getTaskRun,
337
+ putTaskRun,
338
+ findActiveTaskRunForNode,
339
+ listActiveWritingTaskRuns,
340
+ isTerminalTaskRun,
341
+ touchTaskRunHeartbeat,
342
+ casTaskRunStatus,
343
+ cacheControlCommand,
344
+ getCachedControlCommand,
345
+ beginWakeup,
346
+ completeWakeup,
347
+ recoverTaskRunsAfterRestart,
348
+ };
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Per-TaskRun tool provenance for changedFiles (not git diff attribution).
5
+ */
6
+
7
+ function ensureProvenanceStore(executionState = null) {
8
+ const state = executionState && typeof executionState === "object" ? executionState : {};
9
+ if (!state.toolProvenance || typeof state.toolProvenance !== "object") {
10
+ state.toolProvenance = { byTaskRunId: {} };
11
+ }
12
+ if (!state.toolProvenance.byTaskRunId || typeof state.toolProvenance.byTaskRunId !== "object") {
13
+ state.toolProvenance.byTaskRunId = {};
14
+ }
15
+ return state.toolProvenance;
16
+ }
17
+
18
+ function touchedPathsFromTool(tool = "", args = {}) {
19
+ const name = String(tool || "").trim().toLowerCase();
20
+ const paths = [];
21
+ if (name === "write" || name === "edit") {
22
+ const path = String(args && args.path || "").trim();
23
+ if (path) paths.push(path);
24
+ }
25
+ return paths;
26
+ }
27
+
28
+ function recordToolProvenance(executionState = null, {
29
+ taskRunId = "",
30
+ tool = "",
31
+ args = {},
32
+ graphId = "",
33
+ nodeId = "",
34
+ } = {}) {
35
+ const id = String(taskRunId || "").trim();
36
+ if (!id) return [];
37
+ const store = ensureProvenanceStore(executionState);
38
+ if (!store.byTaskRunId[id]) {
39
+ store.byTaskRunId[id] = { paths: [], events: [] };
40
+ }
41
+ const bucket = store.byTaskRunId[id];
42
+ const paths = touchedPathsFromTool(tool, args);
43
+ for (const path of paths) {
44
+ if (!bucket.paths.includes(path)) bucket.paths.push(path);
45
+ }
46
+ bucket.events.push({
47
+ at: new Date().toISOString(),
48
+ tool: String(tool || ""),
49
+ graphId: String(graphId || ""),
50
+ nodeId: String(nodeId || ""),
51
+ paths,
52
+ });
53
+ // Cap event log
54
+ if (bucket.events.length > 200) bucket.events = bucket.events.slice(-200);
55
+ return paths;
56
+ }
57
+
58
+ function getProvenanceChangedFiles(executionState = null, taskRunId = "") {
59
+ const store = ensureProvenanceStore(executionState);
60
+ const id = String(taskRunId || "").trim();
61
+ const bucket = store.byTaskRunId[id];
62
+ return bucket && Array.isArray(bucket.paths) ? bucket.paths.slice() : [];
63
+ }
64
+
65
+ module.exports = {
66
+ ensureProvenanceStore,
67
+ touchedPathsFromTool,
68
+ recordToolProvenance,
69
+ getProvenanceChangedFiles,
70
+ };
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Workspace write lease — up to MAX_CONCURRENT_WRITE_LEASES writing TaskRuns.
5
+ * Agent write/edit/side-effect bash rejected while any Task holds a write lease.
6
+ */
7
+
8
+ const WRITE_TOOLS = new Set(["write", "edit", "bash"]);
9
+ const MAX_CONCURRENT_WRITE_LEASES = 6;
10
+
11
+ function emptyWorkspaceLease() {
12
+ return {
13
+ holders: [], // [{ kind: "task_run", taskRunId, acquiredAt }]
14
+ mode: "write",
15
+ // Legacy single-holder field; migrated in ensureWorkspaceLease.
16
+ holder: null,
17
+ acquiredAt: "",
18
+ };
19
+ }
20
+
21
+ function ensureWorkspaceLease(executionState = null) {
22
+ const state = executionState && typeof executionState === "object" ? executionState : {};
23
+ if (!state.workspaceLease || typeof state.workspaceLease !== "object") {
24
+ state.workspaceLease = emptyWorkspaceLease();
25
+ }
26
+ normalizeHolders(state.workspaceLease);
27
+ return state.workspaceLease;
28
+ }
29
+
30
+ function normalizeHolders(lease = null) {
31
+ const next = lease && typeof lease === "object" ? lease : emptyWorkspaceLease();
32
+ if (!Array.isArray(next.holders)) next.holders = [];
33
+
34
+ // Migrate V1 single-holder shape.
35
+ if (next.holder && typeof next.holder === "object" && next.holder.taskRunId) {
36
+ const id = String(next.holder.taskRunId || "").trim();
37
+ if (id && !next.holders.some((h) => h && h.taskRunId === id)) {
38
+ next.holders.push({
39
+ kind: "task_run",
40
+ taskRunId: id,
41
+ acquiredAt: String(next.acquiredAt || new Date().toISOString()),
42
+ });
43
+ }
44
+ next.holder = null;
45
+ }
46
+
47
+ next.holders = next.holders
48
+ .filter((h) => h && h.kind === "task_run" && String(h.taskRunId || "").trim())
49
+ .map((h) => ({
50
+ kind: "task_run",
51
+ taskRunId: String(h.taskRunId).trim(),
52
+ acquiredAt: String(h.acquiredAt || ""),
53
+ }));
54
+
55
+ // Cap defensive (should not happen if acquire gates correctly).
56
+ if (next.holders.length > MAX_CONCURRENT_WRITE_LEASES) {
57
+ next.holders = next.holders.slice(0, MAX_CONCURRENT_WRITE_LEASES);
58
+ }
59
+ return next.holders;
60
+ }
61
+
62
+ function listWriteLeaseHolders(executionState = null) {
63
+ return normalizeHolders(ensureWorkspaceLease(executionState)).slice();
64
+ }
65
+
66
+ function countWriteLeases(executionState = null) {
67
+ return listWriteLeaseHolders(executionState).length;
68
+ }
69
+
70
+ function findWriteLeaseHolder(executionState = null, taskRunId = "") {
71
+ const id = String(taskRunId || "").trim();
72
+ if (!id) return null;
73
+ return listWriteLeaseHolders(executionState).find((h) => h.taskRunId === id) || null;
74
+ }
75
+
76
+ function canAcquireWriteLease(executionState = null, taskRunId = "") {
77
+ const id = String(taskRunId || "").trim();
78
+ if (!id) return { ok: false, code: "MISSING_TASK_RUN_ID" };
79
+ if (findWriteLeaseHolder(executionState, id)) {
80
+ return { ok: true, idempotent: true };
81
+ }
82
+ const count = countWriteLeases(executionState);
83
+ if (count >= MAX_CONCURRENT_WRITE_LEASES) {
84
+ return {
85
+ ok: false,
86
+ code: "MAX_CONCURRENT_TASKS",
87
+ max: MAX_CONCURRENT_WRITE_LEASES,
88
+ current: count,
89
+ holders: listWriteLeaseHolders(executionState),
90
+ };
91
+ }
92
+ return { ok: true, current: count, max: MAX_CONCURRENT_WRITE_LEASES };
93
+ }
94
+
95
+ function acquireTaskWriteLease(executionState = null, taskRunId = "") {
96
+ const lease = ensureWorkspaceLease(executionState);
97
+ const holders = normalizeHolders(lease);
98
+ const id = String(taskRunId || "").trim();
99
+ if (!id) {
100
+ return { ok: false, code: "MISSING_TASK_RUN_ID" };
101
+ }
102
+
103
+ const existing = holders.find((h) => h.taskRunId === id);
104
+ if (existing) {
105
+ return { ok: true, lease, holder: existing, idempotent: true };
106
+ }
107
+
108
+ if (holders.length >= MAX_CONCURRENT_WRITE_LEASES) {
109
+ return {
110
+ ok: false,
111
+ code: "MAX_CONCURRENT_TASKS",
112
+ max: MAX_CONCURRENT_WRITE_LEASES,
113
+ current: holders.length,
114
+ holders: holders.slice(),
115
+ message: `At most ${MAX_CONCURRENT_WRITE_LEASES} concurrent writing TaskRuns`,
116
+ };
117
+ }
118
+
119
+ const holder = {
120
+ kind: "task_run",
121
+ taskRunId: id,
122
+ acquiredAt: new Date().toISOString(),
123
+ };
124
+ holders.push(holder);
125
+ lease.holders = holders;
126
+ lease.mode = "write";
127
+ return { ok: true, lease, holder };
128
+ }
129
+
130
+ function releaseTaskWriteLease(executionState = null, taskRunId = "") {
131
+ const lease = ensureWorkspaceLease(executionState);
132
+ const holders = normalizeHolders(lease);
133
+ const id = String(taskRunId || "").trim();
134
+ if (!id) return { ok: true, lease };
135
+ lease.holders = holders.filter((h) => h.taskRunId !== id);
136
+ if (lease.holders.length === 0) {
137
+ lease.acquiredAt = "";
138
+ }
139
+ return { ok: true, lease };
140
+ }
141
+
142
+ function clearWorkspaceLease(executionState = null) {
143
+ const lease = ensureWorkspaceLease(executionState);
144
+ lease.holders = [];
145
+ lease.holder = null;
146
+ lease.acquiredAt = "";
147
+ return lease;
148
+ }
149
+
150
+ /**
151
+ * @param {string} tool
152
+ * @param {"agent_loop"|"task_loop"} originKind
153
+ * @param {string} [taskRunId]
154
+ */
155
+ function checkWriteAllowed(executionState = null, {
156
+ tool = "",
157
+ originKind = "agent_loop",
158
+ taskRunId = "",
159
+ } = {}) {
160
+ const name = String(tool || "").trim().toLowerCase();
161
+ if (!WRITE_TOOLS.has(name)) return { ok: true };
162
+
163
+ const holders = listWriteLeaseHolders(executionState);
164
+ if (holders.length === 0) return { ok: true };
165
+
166
+ if (originKind === "task_loop") {
167
+ const id = String(taskRunId || "").trim();
168
+ if (holders.some((h) => h.taskRunId === id)) {
169
+ return { ok: true };
170
+ }
171
+ return {
172
+ ok: false,
173
+ code: "WORKSPACE_WRITE_LEASE_HELD",
174
+ holders,
175
+ message: "This TaskRun does not hold a workspace write lease.",
176
+ };
177
+ }
178
+
179
+ // Agent loop cannot write while any task holds a lease.
180
+ return {
181
+ ok: false,
182
+ code: "WORKSPACE_WRITE_LEASE_HELD",
183
+ holders,
184
+ owner: holders[0] || null,
185
+ message: `${holders.length} active TaskRun(s) hold workspace write lease(s); cancel or wait before writing.`,
186
+ };
187
+ }
188
+
189
+ function hasActiveWriteLease(executionState = null) {
190
+ return countWriteLeases(executionState) > 0;
191
+ }
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
+
233
+ module.exports = {
234
+ WRITE_TOOLS,
235
+ MAX_CONCURRENT_WRITE_LEASES,
236
+ emptyWorkspaceLease,
237
+ ensureWorkspaceLease,
238
+ normalizeHolders,
239
+ listWriteLeaseHolders,
240
+ countWriteLeases,
241
+ findWriteLeaseHolder,
242
+ canAcquireWriteLease,
243
+ acquireTaskWriteLease,
244
+ releaseTaskWriteLease,
245
+ clearWorkspaceLease,
246
+ checkWriteAllowed,
247
+ hasActiveWriteLease,
248
+ releaseStaleWriteLeases,
249
+ };
@@ -1,7 +1,6 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
3
  const { randomUUID } = require("crypto");
4
- const { isContextV2Enabled } = require("./context/featureFlag");
5
4
  const {
6
5
  getTranscriptsDir,
7
6
  getTranscriptFilePath,
@@ -69,10 +68,10 @@ function normalizeContextPolicy(value = {}) {
69
68
  }
70
69
 
71
70
  function buildSessionSnapshot(input = {}) {
71
+ // Durable session fields vs projections: src/code/protocol/ownership.js
72
72
  const source = input && typeof input === "object" ? input : {};
73
73
  const sessionId = resolveSessionId(source.sessionId);
74
74
  const createdAt = String(source.createdAt || "").trim() || toIsoNow();
75
- const useV2 = isContextV2Enabled() || Number(source.version) >= 2;
76
75
 
77
76
  const base = {
78
77
  sessionId,
@@ -84,14 +83,6 @@ function buildSessionSnapshot(input = {}) {
84
83
  updatedAt: toIsoNow(),
85
84
  };
86
85
 
87
- if (!useV2) {
88
- return {
89
- version: 1,
90
- ...base,
91
- nlMessages: cloneMessages(source.nlMessages),
92
- };
93
- }
94
-
95
86
  return {
96
87
  version: 2,
97
88
  ...base,
@@ -195,4 +195,5 @@ module.exports = {
195
195
  readSkillBlock,
196
196
  persistSkillBodyArtifact,
197
197
  buildSkillInjections,
198
+ sanitizeSkillContent,
198
199
  };