@bojackduy/opencode-loopd 1.12.3 → 1.13.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/README.md +4 -0
- package/dist/server.js +1209 -295
- package/dist/tui.js +1098 -6468
- package/package.json +1 -1
- package/scripts/build-tui.ts +3 -0
package/dist/server.js
CHANGED
|
@@ -125,8 +125,59 @@ function removeToolCall(rt, callID) {
|
|
|
125
125
|
}
|
|
126
126
|
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
127
127
|
|
|
128
|
-
// src/infrastructure/
|
|
128
|
+
// src/infrastructure/event-tail.ts
|
|
129
129
|
import { promises as fs } from "fs";
|
|
130
|
+
async function readEventTail(target, limit) {
|
|
131
|
+
try {
|
|
132
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
133
|
+
const raw = await fs.readFile(target, "utf8");
|
|
134
|
+
return raw.trim().split(`
|
|
135
|
+
`).filter(Boolean).slice(-limit).map((line) => JSON.parse(line));
|
|
136
|
+
}
|
|
137
|
+
const count = Math.trunc(limit);
|
|
138
|
+
const file = await fs.open(target, "r");
|
|
139
|
+
try {
|
|
140
|
+
let position = (await file.stat()).size;
|
|
141
|
+
let fragments = [];
|
|
142
|
+
const lines = [];
|
|
143
|
+
const finishLine = (first = false) => {
|
|
144
|
+
const line = Buffer.concat(fragments.reverse()).toString("utf8");
|
|
145
|
+
fragments = [];
|
|
146
|
+
if (line && !((first || lines.length === 0) && !line.trim()))
|
|
147
|
+
lines.push(line);
|
|
148
|
+
};
|
|
149
|
+
while (position > 0 && lines.length < count) {
|
|
150
|
+
const length = Math.min(16 * 1024, position);
|
|
151
|
+
position -= length;
|
|
152
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
153
|
+
const { bytesRead } = await file.read(buffer, 0, length, position);
|
|
154
|
+
let end = bytesRead;
|
|
155
|
+
for (let i = bytesRead - 1;i >= 0; i--) {
|
|
156
|
+
if (buffer[i] !== 10)
|
|
157
|
+
continue;
|
|
158
|
+
fragments.push(buffer.subarray(i + 1, end));
|
|
159
|
+
finishLine();
|
|
160
|
+
end = i;
|
|
161
|
+
if (lines.length === count)
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (lines.length < count)
|
|
165
|
+
fragments.push(buffer.subarray(0, end));
|
|
166
|
+
}
|
|
167
|
+
if (lines.length < count)
|
|
168
|
+
finishLine(true);
|
|
169
|
+
return lines.reverse().map((line) => JSON.parse(line));
|
|
170
|
+
} finally {
|
|
171
|
+
await file.close();
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
var init_event_tail = () => {};
|
|
178
|
+
|
|
179
|
+
// src/infrastructure/state-repository.ts
|
|
180
|
+
import { promises as fs2 } from "fs";
|
|
130
181
|
import path from "path";
|
|
131
182
|
import os from "os";
|
|
132
183
|
import { createHash } from "crypto";
|
|
@@ -159,21 +210,21 @@ function processIsAlive(pid) {
|
|
|
159
210
|
}
|
|
160
211
|
async function acquireLock(directory, key, operation) {
|
|
161
212
|
const dir = lockDir(directory);
|
|
162
|
-
await
|
|
213
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
163
214
|
const lockPath = lockFile(directory, key);
|
|
164
215
|
const deadline = performance.now() + LOCK_TIMEOUT_MS;
|
|
165
216
|
for (let attempt = 0;performance.now() < deadline; attempt++) {
|
|
166
217
|
try {
|
|
167
218
|
try {
|
|
168
|
-
const raw = await
|
|
219
|
+
const raw = await fs2.readFile(lockPath, "utf8");
|
|
169
220
|
const meta = JSON.parse(raw);
|
|
170
221
|
const age = Date.now() - Date.parse(meta.acquiredAt);
|
|
171
222
|
if (age > LOCK_STALE_MS && !processIsAlive(meta.pid)) {
|
|
172
|
-
await
|
|
223
|
+
await fs2.rm(lockPath, { force: true });
|
|
173
224
|
}
|
|
174
225
|
} catch {}
|
|
175
226
|
const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
|
|
176
|
-
const fd = await
|
|
227
|
+
const fd = await fs2.open(lockPath, "wx");
|
|
177
228
|
try {
|
|
178
229
|
await fd.writeFile(JSON.stringify(meta), "utf8");
|
|
179
230
|
} finally {
|
|
@@ -182,7 +233,7 @@ async function acquireLock(directory, key, operation) {
|
|
|
182
233
|
return;
|
|
183
234
|
} catch (error) {
|
|
184
235
|
if (error?.code === "EEXIST") {} else if (error?.code === "ENOENT") {
|
|
185
|
-
await
|
|
236
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
186
237
|
continue;
|
|
187
238
|
} else {
|
|
188
239
|
throw error;
|
|
@@ -195,20 +246,20 @@ async function acquireLock(directory, key, operation) {
|
|
|
195
246
|
async function releaseLock(directory, key) {
|
|
196
247
|
const lockPath = lockFile(directory, key);
|
|
197
248
|
try {
|
|
198
|
-
const raw = await
|
|
249
|
+
const raw = await fs2.readFile(lockPath, "utf8");
|
|
199
250
|
const meta = JSON.parse(raw);
|
|
200
251
|
const shouldRelease = meta.pid === process.pid;
|
|
201
252
|
if (!shouldRelease)
|
|
202
253
|
return;
|
|
203
254
|
try {
|
|
204
|
-
const raw2 = await
|
|
255
|
+
const raw2 = await fs2.readFile(lockPath, "utf8");
|
|
205
256
|
const meta2 = JSON.parse(raw2);
|
|
206
257
|
if (meta2.acquiredAt !== meta.acquiredAt || meta2.pid !== meta.pid)
|
|
207
258
|
return;
|
|
208
259
|
} catch {
|
|
209
260
|
return;
|
|
210
261
|
}
|
|
211
|
-
await
|
|
262
|
+
await fs2.rm(lockPath, { force: true });
|
|
212
263
|
} catch {}
|
|
213
264
|
}
|
|
214
265
|
async function readState(directory) {
|
|
@@ -216,7 +267,7 @@ async function readState(directory) {
|
|
|
216
267
|
const attempts = 5;
|
|
217
268
|
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
218
269
|
try {
|
|
219
|
-
const raw = await
|
|
270
|
+
const raw = await fs2.readFile(target, "utf8");
|
|
220
271
|
const parsed = JSON.parse(raw);
|
|
221
272
|
if (parsed && typeof parsed === "object" && Array.isArray(parsed.goals)) {
|
|
222
273
|
return migrate(parsed);
|
|
@@ -347,13 +398,13 @@ function migrate(state) {
|
|
|
347
398
|
}
|
|
348
399
|
async function writeAtomic(target, contents) {
|
|
349
400
|
const dir = path.dirname(target);
|
|
350
|
-
await
|
|
401
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
351
402
|
const temp = path.join(dir, `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
352
|
-
await
|
|
403
|
+
await fs2.writeFile(temp, contents, "utf8");
|
|
353
404
|
try {
|
|
354
405
|
for (let attempt = 0;attempt < 5; attempt++) {
|
|
355
406
|
try {
|
|
356
|
-
await
|
|
407
|
+
await fs2.rename(temp, target);
|
|
357
408
|
return;
|
|
358
409
|
} catch (error) {
|
|
359
410
|
if (error?.code === "EXDEV")
|
|
@@ -364,10 +415,10 @@ async function writeAtomic(target, contents) {
|
|
|
364
415
|
await delay(25 * (attempt + 1));
|
|
365
416
|
}
|
|
366
417
|
}
|
|
367
|
-
await
|
|
418
|
+
await fs2.copyFile(temp, target);
|
|
368
419
|
} finally {
|
|
369
420
|
try {
|
|
370
|
-
await
|
|
421
|
+
await fs2.rm(temp, { force: true });
|
|
371
422
|
} catch {}
|
|
372
423
|
}
|
|
373
424
|
}
|
|
@@ -388,20 +439,13 @@ async function mutateState(directory, description, fn) {
|
|
|
388
439
|
}
|
|
389
440
|
}
|
|
390
441
|
async function appendEvent(directory, event) {
|
|
391
|
-
await
|
|
442
|
+
await fs2.mkdir(loopDir(directory), { recursive: true });
|
|
392
443
|
const line = JSON.stringify(event) + `
|
|
393
444
|
`;
|
|
394
|
-
await
|
|
445
|
+
await fs2.appendFile(eventsFile(directory), line, "utf8");
|
|
395
446
|
}
|
|
396
447
|
async function readEvents(directory, limit = 50) {
|
|
397
|
-
|
|
398
|
-
const raw = await fs.readFile(eventsFile(directory), "utf8");
|
|
399
|
-
const lines = raw.trim().split(`
|
|
400
|
-
`).filter(Boolean);
|
|
401
|
-
return lines.slice(-limit).map((l) => JSON.parse(l));
|
|
402
|
-
} catch {
|
|
403
|
-
return [];
|
|
404
|
-
}
|
|
448
|
+
return readEventTail(eventsFile(directory), limit);
|
|
405
449
|
}
|
|
406
450
|
function controlDir(directory) {
|
|
407
451
|
return path.join(loopDir(directory), "control");
|
|
@@ -419,8 +463,8 @@ async function claimControlRequest(directory, requestID) {
|
|
|
419
463
|
const src = requestFile(directory, requestID);
|
|
420
464
|
const dst = processingFile(directory, requestID);
|
|
421
465
|
try {
|
|
422
|
-
await
|
|
423
|
-
await
|
|
466
|
+
await fs2.mkdir(path.dirname(dst), { recursive: true });
|
|
467
|
+
await fs2.rename(src, dst);
|
|
424
468
|
return true;
|
|
425
469
|
} catch {
|
|
426
470
|
return false;
|
|
@@ -428,15 +472,15 @@ async function claimControlRequest(directory, requestID) {
|
|
|
428
472
|
}
|
|
429
473
|
async function writeControlResponse(directory, response) {
|
|
430
474
|
const dir = path.join(controlDir(directory), "responses");
|
|
431
|
-
await
|
|
475
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
432
476
|
await writeAtomic(responseFile(directory, response.requestID), JSON.stringify(response, null, 2));
|
|
433
477
|
try {
|
|
434
|
-
await
|
|
478
|
+
await fs2.rm(processingFile(directory, response.requestID), { force: true });
|
|
435
479
|
} catch {}
|
|
436
480
|
}
|
|
437
481
|
async function readControlResponse(directory, requestID) {
|
|
438
482
|
try {
|
|
439
|
-
const raw = await
|
|
483
|
+
const raw = await fs2.readFile(responseFile(directory, requestID), "utf8");
|
|
440
484
|
return JSON.parse(raw);
|
|
441
485
|
} catch {
|
|
442
486
|
return;
|
|
@@ -445,13 +489,13 @@ async function readControlResponse(directory, requestID) {
|
|
|
445
489
|
async function listPendingRequests(directory) {
|
|
446
490
|
const dir = path.join(controlDir(directory), "requests");
|
|
447
491
|
try {
|
|
448
|
-
const files = await
|
|
492
|
+
const files = await fs2.readdir(dir);
|
|
449
493
|
const requests = [];
|
|
450
494
|
for (const file of files) {
|
|
451
495
|
if (!file.endsWith(".json"))
|
|
452
496
|
continue;
|
|
453
497
|
try {
|
|
454
|
-
const raw = await
|
|
498
|
+
const raw = await fs2.readFile(path.join(dir, file), "utf8");
|
|
455
499
|
requests.push(JSON.parse(raw));
|
|
456
500
|
} catch {}
|
|
457
501
|
}
|
|
@@ -465,7 +509,7 @@ function goalArtifactDir(directory, goalID) {
|
|
|
465
509
|
}
|
|
466
510
|
async function ensureGoalArtifactDir(directory, goalID) {
|
|
467
511
|
const dir = goalArtifactDir(directory, goalID);
|
|
468
|
-
await
|
|
512
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
469
513
|
return dir;
|
|
470
514
|
}
|
|
471
515
|
function inboxFile(directory, goalID) {
|
|
@@ -473,21 +517,21 @@ function inboxFile(directory, goalID) {
|
|
|
473
517
|
}
|
|
474
518
|
async function appendGoalInbox(directory, goalID, from, text) {
|
|
475
519
|
const dir = path.join(loopDir(directory), "inboxes");
|
|
476
|
-
await
|
|
520
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
477
521
|
const msg = { from, text, at: new Date().toISOString() };
|
|
478
|
-
await
|
|
522
|
+
await fs2.appendFile(inboxFile(directory, goalID), JSON.stringify(msg) + `
|
|
479
523
|
`, "utf8");
|
|
480
524
|
}
|
|
481
525
|
async function drainGoalInbox(directory, goalID) {
|
|
482
526
|
const file = inboxFile(directory, goalID);
|
|
483
527
|
try {
|
|
484
|
-
const raw = await
|
|
528
|
+
const raw = await fs2.readFile(file, "utf8");
|
|
485
529
|
const lines = raw.trim().split(`
|
|
486
530
|
`).filter(Boolean);
|
|
487
531
|
if (lines.length === 0)
|
|
488
532
|
return [];
|
|
489
533
|
const messages = lines.map((l) => JSON.parse(l));
|
|
490
|
-
await
|
|
534
|
+
await fs2.rm(file, { force: true });
|
|
491
535
|
return messages.map((m) => `[${m.from}] ${m.text}`);
|
|
492
536
|
} catch {
|
|
493
537
|
return [];
|
|
@@ -496,7 +540,7 @@ async function drainGoalInbox(directory, goalID) {
|
|
|
496
540
|
async function peekGoalInbox(directory, goalID) {
|
|
497
541
|
const file = inboxFile(directory, goalID);
|
|
498
542
|
try {
|
|
499
|
-
const raw = await
|
|
543
|
+
const raw = await fs2.readFile(file, "utf8");
|
|
500
544
|
const lines = raw.trim().split(`
|
|
501
545
|
`).filter(Boolean);
|
|
502
546
|
if (lines.length === 0)
|
|
@@ -512,18 +556,18 @@ function commandLogFile(directory, commandID) {
|
|
|
512
556
|
}
|
|
513
557
|
async function appendCommandLog(directory, commandID, chunk) {
|
|
514
558
|
const file = commandLogFile(directory, commandID);
|
|
515
|
-
await
|
|
516
|
-
await
|
|
559
|
+
await fs2.mkdir(path.dirname(file), { recursive: true });
|
|
560
|
+
await fs2.appendFile(file, chunk, "utf8");
|
|
517
561
|
}
|
|
518
562
|
async function readCommandLog(directory, commandID, opts) {
|
|
519
563
|
const file = commandLogFile(directory, commandID);
|
|
520
564
|
try {
|
|
521
|
-
const stat = await
|
|
565
|
+
const stat = await fs2.stat(file);
|
|
522
566
|
const totalBytes = stat.size;
|
|
523
567
|
const requestedStart = Math.max(0, opts?.offsetBytes ?? 0);
|
|
524
568
|
if (requestedStart >= totalBytes)
|
|
525
569
|
return { text: "", totalBytes, startByte: requestedStart };
|
|
526
|
-
const fh = await
|
|
570
|
+
const fh = await fs2.open(file, "r");
|
|
527
571
|
try {
|
|
528
572
|
const want = Math.min(opts?.limitBytes ?? 64 * 1024, totalBytes - requestedStart);
|
|
529
573
|
const buf = Buffer.alloc(want);
|
|
@@ -566,14 +610,16 @@ function decodeUtf8Window(buf, windowStart) {
|
|
|
566
610
|
}
|
|
567
611
|
async function removeCommandLog(directory, commandID) {
|
|
568
612
|
try {
|
|
569
|
-
await
|
|
613
|
+
await fs2.rm(commandLogFile(directory, commandID), { force: true });
|
|
570
614
|
} catch {}
|
|
571
615
|
}
|
|
572
616
|
function delay(ms) {
|
|
573
617
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
574
618
|
}
|
|
575
619
|
var CURRENT_VERSION = 9, LOCK_TIMEOUT_MS = 30000, LOCK_STALE_MS = 1e4;
|
|
576
|
-
var init_state_repository = () => {
|
|
620
|
+
var init_state_repository = __esm(() => {
|
|
621
|
+
init_event_tail();
|
|
622
|
+
});
|
|
577
623
|
|
|
578
624
|
// src/domain/command-await.ts
|
|
579
625
|
function isTerminalCommandStatus(status) {
|
|
@@ -1241,6 +1287,440 @@ var init_command_await2 = __esm(() => {
|
|
|
1241
1287
|
init_state_repository();
|
|
1242
1288
|
});
|
|
1243
1289
|
|
|
1290
|
+
// src/infrastructure/workspace-scope.ts
|
|
1291
|
+
import fs3 from "fs/promises";
|
|
1292
|
+
import path2 from "path";
|
|
1293
|
+
async function resolveExistingParent(target) {
|
|
1294
|
+
try {
|
|
1295
|
+
return await fs3.realpath(target);
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
if (error?.code !== "ENOENT")
|
|
1298
|
+
throw error;
|
|
1299
|
+
try {
|
|
1300
|
+
const stat = await fs3.lstat(target);
|
|
1301
|
+
if (stat.isSymbolicLink())
|
|
1302
|
+
throw new Error(`Dangling symlink in scope: ${target}`);
|
|
1303
|
+
} catch (missing) {
|
|
1304
|
+
if (missing?.code !== "ENOENT")
|
|
1305
|
+
throw missing;
|
|
1306
|
+
}
|
|
1307
|
+
const parent = path2.dirname(target);
|
|
1308
|
+
if (parent === target)
|
|
1309
|
+
throw error;
|
|
1310
|
+
return path2.join(await resolveExistingParent(parent), path2.basename(target));
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
async function canonicalScopePath(directory, input) {
|
|
1314
|
+
if (typeof input !== "string" || !input.trim() || input.includes("\x00") || /[*?\[\]{}]/.test(input)) {
|
|
1315
|
+
throw new Error("write_scope requires exact non-empty file paths, not glob patterns");
|
|
1316
|
+
}
|
|
1317
|
+
const root = await fs3.realpath(directory);
|
|
1318
|
+
const resolved = await resolveExistingParent(path2.resolve(root, input));
|
|
1319
|
+
const relative = path2.relative(root, resolved);
|
|
1320
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path2.sep}`) || path2.isAbsolute(relative)) {
|
|
1321
|
+
throw new Error(`Scope path is outside the workspace or names its root: ${input}`);
|
|
1322
|
+
}
|
|
1323
|
+
try {
|
|
1324
|
+
if ((await fs3.stat(resolved)).isDirectory())
|
|
1325
|
+
throw new Error(`Scope must name a file, not a directory: ${input}`);
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
if (error?.code !== "ENOENT")
|
|
1328
|
+
throw error;
|
|
1329
|
+
}
|
|
1330
|
+
return relative.split(path2.sep).join("/");
|
|
1331
|
+
}
|
|
1332
|
+
async function canonicalWriteScope(directory, inputs) {
|
|
1333
|
+
return [...new Set(await Promise.all(inputs.map((input) => canonicalScopePath(directory, input))))].sort();
|
|
1334
|
+
}
|
|
1335
|
+
function findScopeConflict(goals, candidate) {
|
|
1336
|
+
if (candidate.config.workspaceWrite === false || candidate.config.write_scope?.length === 0)
|
|
1337
|
+
return;
|
|
1338
|
+
for (const owner of goals) {
|
|
1339
|
+
if (owner.id === candidate.id || owner.status !== "active" || owner.config.workspaceWrite === false || owner.config.write_scope?.length === 0)
|
|
1340
|
+
continue;
|
|
1341
|
+
const requested = candidate.config.write_scope;
|
|
1342
|
+
const held = owner.config.write_scope;
|
|
1343
|
+
const overlap = requested === undefined || held === undefined ? requested?.[0] ?? held?.[0] ?? "*" : requested.find((file) => held.includes(file));
|
|
1344
|
+
if (overlap !== undefined)
|
|
1345
|
+
return { goalID: owner.id, ownerSessionID: owner.ownerSessionID, name: owner.name, path: overlap };
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
function scopeConflictMessage(conflict) {
|
|
1349
|
+
return `Write scope conflict on "${conflict.path}": goal "${conflict.name}" (${conflict.goalID}), owned by session ${conflict.ownerSessionID}. Coordinate with the owner or release the conflicting scope before retrying; do not poll claims.`;
|
|
1350
|
+
}
|
|
1351
|
+
var init_workspace_scope = () => {};
|
|
1352
|
+
|
|
1353
|
+
// src/application/workspace-execution.ts
|
|
1354
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1355
|
+
import path3 from "path";
|
|
1356
|
+
function structuredWritePaths(tool, args) {
|
|
1357
|
+
if (tool === "write" || tool === "edit") {
|
|
1358
|
+
if (typeof args.filePath !== "string")
|
|
1359
|
+
throw new Error(`Scope denied: ${tool} requires filePath.`);
|
|
1360
|
+
return [args.filePath];
|
|
1361
|
+
}
|
|
1362
|
+
if (tool !== "apply_patch")
|
|
1363
|
+
return;
|
|
1364
|
+
const source = args.patchText ?? args.patch;
|
|
1365
|
+
if (typeof source !== "string" || !source.startsWith(`*** Begin Patch
|
|
1366
|
+
`) || !source.trimEnd().endsWith("*** End Patch")) {
|
|
1367
|
+
throw new Error("Scope denied: unsupported apply_patch format.");
|
|
1368
|
+
}
|
|
1369
|
+
const paths = [];
|
|
1370
|
+
let section = false;
|
|
1371
|
+
for (const line of source.split(`
|
|
1372
|
+
`)) {
|
|
1373
|
+
const header = /^\*\*\* (?:Add File|Update File|Delete File): (.+)$/.exec(line);
|
|
1374
|
+
if (header) {
|
|
1375
|
+
paths.push(header[1]);
|
|
1376
|
+
section = true;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
const move = /^\*\*\* Move to: (.+)$/.exec(line);
|
|
1380
|
+
if (move) {
|
|
1381
|
+
if (!section)
|
|
1382
|
+
throw new Error("Scope denied: rename outside patch section.");
|
|
1383
|
+
paths.push(move[1]);
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
if (line.startsWith("*** ") && !["*** Begin Patch", "*** End Patch", "*** End of File"].includes(line)) {
|
|
1387
|
+
throw new Error("Scope denied: unknown patch operation.");
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (!paths.length)
|
|
1391
|
+
throw new Error("Scope denied: patch has no file operations.");
|
|
1392
|
+
return paths;
|
|
1393
|
+
}
|
|
1394
|
+
function callAgeMs(call, now) {
|
|
1395
|
+
if (!call.at)
|
|
1396
|
+
return Number.POSITIVE_INFINITY;
|
|
1397
|
+
const age = now - Date.parse(call.at);
|
|
1398
|
+
return Number.isFinite(age) ? age : Number.POSITIVE_INFINITY;
|
|
1399
|
+
}
|
|
1400
|
+
function runtimeActivityFresh(state, goalID, now) {
|
|
1401
|
+
if (!goalID)
|
|
1402
|
+
return false;
|
|
1403
|
+
const rt = state.runtimes.find((r) => r.goalID === goalID);
|
|
1404
|
+
if (!rt?.lastActivityAt)
|
|
1405
|
+
return false;
|
|
1406
|
+
const age = now - Date.parse(rt.lastActivityAt);
|
|
1407
|
+
return Number.isFinite(age) && age < STALE_WORKSPACE_CALL_TTL_MS;
|
|
1408
|
+
}
|
|
1409
|
+
function isGenerationSuperseded(state, call) {
|
|
1410
|
+
if (call.generation === undefined || !call.goalID)
|
|
1411
|
+
return false;
|
|
1412
|
+
const rt = state.runtimes.find((r) => r.goalID === call.goalID);
|
|
1413
|
+
return !!rt && call.generation < rt.runGeneration;
|
|
1414
|
+
}
|
|
1415
|
+
function isSyncStaleWorkspaceCall(state, call, now = Date.now()) {
|
|
1416
|
+
if (isGenerationSuperseded(state, call))
|
|
1417
|
+
return true;
|
|
1418
|
+
if (call.goalID && !state.goals.some((g) => g.id === call.goalID))
|
|
1419
|
+
return true;
|
|
1420
|
+
return callAgeMs(call, now) > CLEAR_PENDING_FORCE_TTL_MS;
|
|
1421
|
+
}
|
|
1422
|
+
function isStaleWorkspaceCall(state, call, liveness = {}) {
|
|
1423
|
+
if (isSyncStaleWorkspaceCall(state, call, liveness.now ?? Date.now()))
|
|
1424
|
+
return true;
|
|
1425
|
+
const now = liveness.now ?? Date.now();
|
|
1426
|
+
const age = callAgeMs(call, now);
|
|
1427
|
+
if (!call.goalID)
|
|
1428
|
+
return age > OWNER_CALL_TTL_MS;
|
|
1429
|
+
const goal = state.goals.find((g) => g.id === call.goalID);
|
|
1430
|
+
if (!goal)
|
|
1431
|
+
return true;
|
|
1432
|
+
if (age <= STALE_WORKSPACE_CALL_TTL_MS)
|
|
1433
|
+
return false;
|
|
1434
|
+
const live = liveness.isWorkerLive?.(goal.id, goal.workerSessionID) ?? false;
|
|
1435
|
+
if (live && runtimeActivityFresh(state, goal.id, now))
|
|
1436
|
+
return false;
|
|
1437
|
+
if (live) {
|
|
1438
|
+
return age > CLEAR_PENDING_FORCE_TTL_MS;
|
|
1439
|
+
}
|
|
1440
|
+
return true;
|
|
1441
|
+
}
|
|
1442
|
+
function sweepStaleWorkspaceState(state, liveness = {}) {
|
|
1443
|
+
const now = liveness.now ?? Date.now();
|
|
1444
|
+
const summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
|
|
1445
|
+
const clearPendingGoals = new Set(state.goals.filter((g) => g.scopeClearPending).map((g) => g.id));
|
|
1446
|
+
const before = state.workspaceCalls ?? [];
|
|
1447
|
+
const kept = before.filter((call) => {
|
|
1448
|
+
if (call.goalID && clearPendingGoals.has(call.goalID) && !isGenerationSuperseded(state, call) && state.goals.some((g) => g.id === call.goalID)) {
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
if (!isStaleWorkspaceCall(state, call, { ...liveness, now }))
|
|
1452
|
+
return true;
|
|
1453
|
+
summary.reapedCalls.push({
|
|
1454
|
+
callID: call.callID,
|
|
1455
|
+
sessionID: call.sessionID,
|
|
1456
|
+
goalID: call.goalID,
|
|
1457
|
+
reason: isGenerationSuperseded(state, call) ? "generation-superseded" : call.goalID && !state.goals.some((g) => g.id === call.goalID) ? "goal-gone" : "age-ttl-no-live-backing"
|
|
1458
|
+
});
|
|
1459
|
+
return false;
|
|
1460
|
+
});
|
|
1461
|
+
state.workspaceCalls = kept;
|
|
1462
|
+
for (const goal of state.goals) {
|
|
1463
|
+
const referenced = kept.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id;
|
|
1464
|
+
if (goal.scopeClosing && !referenced && !(liveness.isWorkerLive?.(goal.id, goal.workerSessionID) ?? false)) {
|
|
1465
|
+
goal.scopeClosing = false;
|
|
1466
|
+
goal.updatedAt = new Date(now).toISOString();
|
|
1467
|
+
summary.resetClosing.push(goal.id);
|
|
1468
|
+
}
|
|
1469
|
+
if (goal.scopeClearPending && !referenced) {
|
|
1470
|
+
state.goals = state.goals.filter((g) => g.id !== goal.id);
|
|
1471
|
+
state.runtimes = state.runtimes.filter((r) => r.goalID !== goal.id);
|
|
1472
|
+
state.commandAwaits = (state.commandAwaits ?? []).filter((a) => a.goalID !== goal.id);
|
|
1473
|
+
summary.finalizedClears.push(goal.id);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
for (const goal of [...state.goals]) {
|
|
1477
|
+
if (!goal.scopeClearPending)
|
|
1478
|
+
continue;
|
|
1479
|
+
const refs = (state.workspaceCalls ?? []).filter((call) => call.goalID === goal.id);
|
|
1480
|
+
if (!refs.length)
|
|
1481
|
+
continue;
|
|
1482
|
+
if (refs.every((call) => callAgeMs(call, now) > CLEAR_PENDING_FORCE_TTL_MS)) {
|
|
1483
|
+
for (const call of refs) {
|
|
1484
|
+
summary.reapedCalls.push({ callID: call.callID, sessionID: call.sessionID, goalID: call.goalID, reason: "clear-pending-force" });
|
|
1485
|
+
}
|
|
1486
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goal.id);
|
|
1487
|
+
if (!(state.workspaceCalls ?? []).some((call) => call.goalID === goal.id) && state.workspaceOperation?.goalID !== goal.id) {
|
|
1488
|
+
state.goals = state.goals.filter((g) => g.id !== goal.id);
|
|
1489
|
+
state.runtimes = state.runtimes.filter((r) => r.goalID !== goal.id);
|
|
1490
|
+
state.commandAwaits = (state.commandAwaits ?? []).filter((a) => a.goalID !== goal.id);
|
|
1491
|
+
summary.forceCleared.push(goal.id);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
return summary;
|
|
1496
|
+
}
|
|
1497
|
+
function stampCallGeneration(state, goalID) {
|
|
1498
|
+
if (!goalID)
|
|
1499
|
+
return;
|
|
1500
|
+
return state.runtimes.find((runtime) => runtime.goalID === goalID)?.runGeneration ?? 0;
|
|
1501
|
+
}
|
|
1502
|
+
function heldGoals(state) {
|
|
1503
|
+
const now = Date.now();
|
|
1504
|
+
return state.goals.filter((goal) => goal.status === "active" || goal.scopeClosing || (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now)) || state.workspaceOperation?.goalID === goal.id);
|
|
1505
|
+
}
|
|
1506
|
+
function mayBeAlive(pid) {
|
|
1507
|
+
if (!pid || pid < 1)
|
|
1508
|
+
return true;
|
|
1509
|
+
try {
|
|
1510
|
+
process.kill(pid, 0);
|
|
1511
|
+
return true;
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
return error?.code !== "ESRCH";
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
function assertScopeAvailable(state, goal) {
|
|
1517
|
+
const now = Date.now();
|
|
1518
|
+
const liveCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
|
|
1519
|
+
if (goal.scopeClosing || liveCalls.length) {
|
|
1520
|
+
throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
|
|
1521
|
+
}
|
|
1522
|
+
if (state.workspaceOperation)
|
|
1523
|
+
throw new Error(`Workspace operation ${state.workspaceOperation.id} is active.`);
|
|
1524
|
+
if (goal.config.write_scope !== undefined || goal.config.workspaceWrite === false) {
|
|
1525
|
+
const orphan = [...state.orphanedCommandProcesses || [], ...(state.commands || []).filter((command) => command.status === "missing").map((command) => ({ commandID: command.id, pid: command.pid }))].find((process2) => mayBeAlive(process2.pid));
|
|
1526
|
+
if (orphan)
|
|
1527
|
+
throw new Error(`Protected activation denied: command ${orphan.commandID} lost its handle and process exit is unproved. Stop/verify the orphan process externally; removing its log does not release this fence.`);
|
|
1528
|
+
if (state.workspaceCalls?.some((call) => call.uncontrolled))
|
|
1529
|
+
throw new Error("Protected goal activation denied: an uncontrolled tool/shell is still executing. Wait for its exit event, do not poll claims.");
|
|
1530
|
+
const running = state.commands?.find((command) => command.status === "running");
|
|
1531
|
+
if (running)
|
|
1532
|
+
throw new Error(`Protected goal activation denied: uncontrolled command ${running.id} is still running. Terminate it before starting scoped/read-only work.`);
|
|
1533
|
+
}
|
|
1534
|
+
const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), goal);
|
|
1535
|
+
if (conflict) {
|
|
1536
|
+
const owner = state.goals.find((item) => item.id === conflict.goalID);
|
|
1537
|
+
if (goal.config.write_scope === undefined && owner?.config.write_scope === undefined) {
|
|
1538
|
+
throw new Error(`Workspace-writing goal "${conflict.name}" (${conflict.goalID}) is already active (owned by session ${conflict.ownerSessionID}). Pause, block, complete, or clear it before activating another workspace-writing goal. Note: list_background_goals shows only this session's goals.`);
|
|
1539
|
+
}
|
|
1540
|
+
throw new Error(scopeConflictMessage(conflict));
|
|
1541
|
+
}
|
|
1542
|
+
if (goal.config.workspaceWrite !== false && goal.config.write_scope?.length !== 0) {
|
|
1543
|
+
const now2 = Date.now();
|
|
1544
|
+
const call = state.workspaceCalls?.find((call) => call.goalID !== goal.id && !isSyncStaleWorkspaceCall(state, call, now2) && (goal.config.write_scope === undefined || call.paths.some((file) => goal.config.write_scope.includes(file))));
|
|
1545
|
+
if (call)
|
|
1546
|
+
throw new Error(`Scope conflict with in-flight call ${call.callID}, session ${call.sessionID}, path ${call.paths[0]}.`);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function retireWorker(state, goal) {
|
|
1550
|
+
if (!goal.workerSessionID)
|
|
1551
|
+
return;
|
|
1552
|
+
state.retiredWorkerSessions = [...new Set([...state.retiredWorkerSessions || [], goal.workerSessionID])];
|
|
1553
|
+
}
|
|
1554
|
+
async function beforeWorkspaceTool(directory, input, args) {
|
|
1555
|
+
if (readTools.has(input.tool))
|
|
1556
|
+
return;
|
|
1557
|
+
await mutateState(directory, `workspace.before:${input.callID}`, async (state) => {
|
|
1558
|
+
const goal = state.goals.find((item) => item.workerSessionID === input.sessionID);
|
|
1559
|
+
if (state.retiredWorkerSessions?.includes(input.sessionID))
|
|
1560
|
+
throw new Error("Scope denied: retired worker session; late executions are fenced.");
|
|
1561
|
+
const held = heldGoals(state);
|
|
1562
|
+
const protectedGoals = held.filter((item) => item.config.write_scope !== undefined || item.config.workspaceWrite === false);
|
|
1563
|
+
if (!goal && ownerTools.has(input.tool) && (!protectedGoals.length || state.goals.some((item) => item.ownerSessionID === input.sessionID)))
|
|
1564
|
+
return state;
|
|
1565
|
+
if (!state.workspaceOperation && !protectedGoals.length && (!goal || goal.config.write_scope === undefined && goal.config.workspaceWrite !== false)) {
|
|
1566
|
+
let paths = [];
|
|
1567
|
+
let uncontrolled = false;
|
|
1568
|
+
try {
|
|
1569
|
+
const writes = structuredWritePaths(input.tool, args);
|
|
1570
|
+
if (writes)
|
|
1571
|
+
paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
|
|
1572
|
+
else
|
|
1573
|
+
uncontrolled = true;
|
|
1574
|
+
} catch {
|
|
1575
|
+
uncontrolled = true;
|
|
1576
|
+
}
|
|
1577
|
+
state.workspaceCalls ??= [];
|
|
1578
|
+
if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
|
|
1579
|
+
state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, generation: stampCallGeneration(state, goal?.id), paths, uncontrolled, at: new Date().toISOString() });
|
|
1580
|
+
}
|
|
1581
|
+
return state;
|
|
1582
|
+
}
|
|
1583
|
+
const writes = structuredWritePaths(input.tool, args);
|
|
1584
|
+
if (!writes)
|
|
1585
|
+
throw new Error(`Scope denied: uncontrolled tool "${input.tool}". Shell, commands, custom tools, batch and subagents are not allowed while protected goals exist. Use structured edits or run_goal_checks.`);
|
|
1586
|
+
if (state.workspaceOperation)
|
|
1587
|
+
throw new Error(`Workspace operation ${state.workspaceOperation.id} is active; source writes are denied until it finishes.`);
|
|
1588
|
+
if (goal && (goal.status !== "active" || goal.scopeClosing))
|
|
1589
|
+
throw new Error(`Scope denied: worker goal is ${goal.scopeClosing ? "closing" : goal.status}.`);
|
|
1590
|
+
if (!goal && !state.goals.some((item) => item.ownerSessionID === input.sessionID)) {
|
|
1591
|
+
throw new Error("Scope denied: unrecognized session/child. Subagent writes are unsupported.");
|
|
1592
|
+
}
|
|
1593
|
+
const paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
|
|
1594
|
+
const artifact = goal?.config.artifactDir ? path3.relative(directory, goal.config.artifactDir).split(path3.sep).join("/") : undefined;
|
|
1595
|
+
for (const file of paths) {
|
|
1596
|
+
const inArtifact = artifact && file.startsWith(`${artifact}/`);
|
|
1597
|
+
if (goal && !inArtifact && (goal.config.workspaceWrite === false || goal.config.write_scope !== undefined && !goal.config.write_scope.includes(file))) {
|
|
1598
|
+
throw new Error(`Scope denied: "${file}" is outside this goal's write_scope. Claim it before editing.`);
|
|
1599
|
+
}
|
|
1600
|
+
const owner = !inArtifact && held.find((item) => item.id !== goal?.id && item.config.workspaceWrite !== false && (item.config.write_scope === undefined || item.config.write_scope.includes(file)));
|
|
1601
|
+
if (owner)
|
|
1602
|
+
throw new Error(scopeConflictMessage({ goalID: owner.id, ownerSessionID: owner.ownerSessionID, name: owner.name, path: file }));
|
|
1603
|
+
const now3 = Date.now();
|
|
1604
|
+
const call = state.workspaceCalls?.find((item) => !isSyncStaleWorkspaceCall(state, item, now3) && item.paths.includes(file) && !(item.callID === input.callID && item.sessionID === input.sessionID));
|
|
1605
|
+
if (call)
|
|
1606
|
+
throw new Error(`Scope denied: "${file}" is being written by session ${call.sessionID}, call ${call.callID}.`);
|
|
1607
|
+
}
|
|
1608
|
+
if (!state.workspaceCalls)
|
|
1609
|
+
state.workspaceCalls = [];
|
|
1610
|
+
if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
|
|
1611
|
+
state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, generation: stampCallGeneration(state, goal?.id), paths, at: new Date().toISOString() });
|
|
1612
|
+
}
|
|
1613
|
+
return state;
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
async function afterWorkspaceTool(directory, sessionID, callID) {
|
|
1617
|
+
await mutateState(directory, `workspace.after:${callID}`, async (state) => {
|
|
1618
|
+
state.workspaceCalls = state.workspaceCalls?.filter((call) => call.sessionID !== sessionID || call.callID !== callID);
|
|
1619
|
+
const now = Date.now();
|
|
1620
|
+
state.goals = state.goals.filter((goal) => {
|
|
1621
|
+
const live = (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
|
|
1622
|
+
if (!goal.scopeClearPending || live || state.workspaceOperation?.goalID === goal.id)
|
|
1623
|
+
return true;
|
|
1624
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => !(call.goalID === goal.id && isSyncStaleWorkspaceCall(state, call, now)));
|
|
1625
|
+
state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
|
|
1626
|
+
return false;
|
|
1627
|
+
});
|
|
1628
|
+
return state;
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
function guardV2ToolEditor(directory, editor) {
|
|
1632
|
+
if (typeof editor.list !== "function" || typeof editor.update !== "function")
|
|
1633
|
+
throw new Error("Scoped execution requires the v2 tool editor list/update execution surface.");
|
|
1634
|
+
for (const entry of editor.list()) {
|
|
1635
|
+
editor.update(entry.id, (definition) => {
|
|
1636
|
+
const execute = definition.execute;
|
|
1637
|
+
definition.execute = async (args, context) => {
|
|
1638
|
+
await beforeWorkspaceTool(directory, { tool: entry.name, sessionID: context.sessionID, callID: context.id }, args);
|
|
1639
|
+
try {
|
|
1640
|
+
return await execute(args, context);
|
|
1641
|
+
} finally {
|
|
1642
|
+
await afterWorkspaceTool(directory, context.sessionID, context.id);
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
async function withWorkspaceOperation(directory, goalID, sessionID, run) {
|
|
1649
|
+
const id = randomUUID3();
|
|
1650
|
+
await mutateState(directory, `workspace.operation:${id}`, async (state) => {
|
|
1651
|
+
const now = Date.now();
|
|
1652
|
+
const live = (state.workspaceCalls ?? []).filter((call) => !isSyncStaleWorkspaceCall(state, call, now));
|
|
1653
|
+
if (state.workspaceOperation || live.length)
|
|
1654
|
+
throw new Error("Workspace is busy with an operation or in-flight writes; verification denied until quiescent.");
|
|
1655
|
+
state.workspaceOperation = { id, goalID, sessionID };
|
|
1656
|
+
return state;
|
|
1657
|
+
});
|
|
1658
|
+
try {
|
|
1659
|
+
return await run();
|
|
1660
|
+
} finally {
|
|
1661
|
+
await mutateState(directory, `workspace.operation-end:${id}`, async (state) => {
|
|
1662
|
+
if (state.workspaceOperation?.id === id)
|
|
1663
|
+
state.workspaceOperation = undefined;
|
|
1664
|
+
const now = Date.now();
|
|
1665
|
+
state.goals = state.goals.filter((goal) => {
|
|
1666
|
+
const live = (state.workspaceCalls ?? []).some((call) => call.goalID === goal.id && !isSyncStaleWorkspaceCall(state, call, now));
|
|
1667
|
+
if (!goal.scopeClearPending || live)
|
|
1668
|
+
return true;
|
|
1669
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => !(call.goalID === goal.id && isSyncStaleWorkspaceCall(state, call, now)));
|
|
1670
|
+
state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
|
|
1671
|
+
return false;
|
|
1672
|
+
});
|
|
1673
|
+
return state;
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
var readTools, ownerTools, STALE_WORKSPACE_CALL_TTL_MS, CLEAR_PENDING_FORCE_TTL_MS, OWNER_CALL_TTL_MS;
|
|
1678
|
+
var init_workspace_execution = __esm(() => {
|
|
1679
|
+
init_state_repository();
|
|
1680
|
+
init_workspace_scope();
|
|
1681
|
+
readTools = new Set([
|
|
1682
|
+
"read",
|
|
1683
|
+
"glob",
|
|
1684
|
+
"grep",
|
|
1685
|
+
"list",
|
|
1686
|
+
"webfetch",
|
|
1687
|
+
"websearch",
|
|
1688
|
+
"todowrite",
|
|
1689
|
+
"skill",
|
|
1690
|
+
"get_goal",
|
|
1691
|
+
"claim_goal_scope",
|
|
1692
|
+
"report_goal_progress",
|
|
1693
|
+
"complete_goal",
|
|
1694
|
+
"block_goal",
|
|
1695
|
+
"list_background_goals",
|
|
1696
|
+
"inspect_background_goal",
|
|
1697
|
+
"read_goal_transcript",
|
|
1698
|
+
"loopd_command_get",
|
|
1699
|
+
"loopd_command_list",
|
|
1700
|
+
"loopd_command_await",
|
|
1701
|
+
"loopd_command_interrupt",
|
|
1702
|
+
"loopd_command_terminate",
|
|
1703
|
+
"loopd_command_remove",
|
|
1704
|
+
"loopd_list_agents",
|
|
1705
|
+
"loopd_list_models",
|
|
1706
|
+
"run_goal_checks",
|
|
1707
|
+
"codegraph_codegraph_context",
|
|
1708
|
+
"codegraph_codegraph_search",
|
|
1709
|
+
"codegraph_codegraph_node",
|
|
1710
|
+
"codegraph_codegraph_explore",
|
|
1711
|
+
"codegraph_codegraph_files",
|
|
1712
|
+
"codegraph_codegraph_trace",
|
|
1713
|
+
"codegraph_codegraph_callers",
|
|
1714
|
+
"codegraph_codegraph_callees",
|
|
1715
|
+
"codegraph_codegraph_impact",
|
|
1716
|
+
"codegraph_codegraph_status"
|
|
1717
|
+
]);
|
|
1718
|
+
ownerTools = new Set(["loopd_create_goal", "switch_goal_identity", "pause_goal", "resume_goal", "clear_goal", "abort_goal_worker", "nudge_goal", "send_goal_input", "force_block_goal", "force_complete_goal"]);
|
|
1719
|
+
STALE_WORKSPACE_CALL_TTL_MS = 10 * 60000;
|
|
1720
|
+
CLEAR_PENDING_FORCE_TTL_MS = 30 * 60000;
|
|
1721
|
+
OWNER_CALL_TTL_MS = 30 * 60000;
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1244
1724
|
// src/server/plugin.ts
|
|
1245
1725
|
import { tool as v1Tool } from "@opencode-ai/plugin/tool";
|
|
1246
1726
|
|
|
@@ -1404,12 +1884,24 @@ function createControlWorker(options) {
|
|
|
1404
1884
|
continue;
|
|
1405
1885
|
processing.add(request.requestID);
|
|
1406
1886
|
options.onRequest?.(request);
|
|
1887
|
+
logServerEvent(directory, "control.request.claimed", {
|
|
1888
|
+
requestID: request.requestID,
|
|
1889
|
+
command: request.command,
|
|
1890
|
+
goalID: request.goalID
|
|
1891
|
+
}).catch(() => {});
|
|
1892
|
+
runClaimed(request).finally(() => {
|
|
1893
|
+
processing.delete(request.requestID);
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
async function runClaimed(request) {
|
|
1898
|
+
try {
|
|
1899
|
+
const response = await handleRequest(request);
|
|
1900
|
+
await writeControlResponse(directory, response);
|
|
1901
|
+
options.onResponse?.(response);
|
|
1902
|
+
} catch (error) {
|
|
1903
|
+
const detail = describeError(error);
|
|
1407
1904
|
try {
|
|
1408
|
-
const response = await handleRequest(request);
|
|
1409
|
-
await writeControlResponse(directory, response);
|
|
1410
|
-
options.onResponse?.(response);
|
|
1411
|
-
} catch (error) {
|
|
1412
|
-
const detail = describeError(error);
|
|
1413
1905
|
await logServerEvent(directory, "control.request.failed", {
|
|
1414
1906
|
requestID: request.requestID,
|
|
1415
1907
|
command: request.command,
|
|
@@ -1425,9 +1917,7 @@ function createControlWorker(options) {
|
|
|
1425
1917
|
};
|
|
1426
1918
|
await writeControlResponse(directory, response);
|
|
1427
1919
|
options.onResponse?.(response);
|
|
1428
|
-
}
|
|
1429
|
-
processing.delete(request.requestID);
|
|
1430
|
-
}
|
|
1920
|
+
} catch {}
|
|
1431
1921
|
}
|
|
1432
1922
|
}
|
|
1433
1923
|
async function handleRequest(request) {
|
|
@@ -1679,7 +2169,21 @@ function createControlWorker(options) {
|
|
|
1679
2169
|
runtime.lastError = undefined;
|
|
1680
2170
|
runtime.updatedAt = new Date().toISOString();
|
|
1681
2171
|
}
|
|
1682
|
-
await
|
|
2172
|
+
await mutateState(directory, `goal.force-complete:${goal.id}`, async (s) => {
|
|
2173
|
+
const current = s.goals.find((item) => item.id === goal.id);
|
|
2174
|
+
if (!current)
|
|
2175
|
+
return s;
|
|
2176
|
+
current.status = "complete";
|
|
2177
|
+
current.updatedAt = goal.updatedAt;
|
|
2178
|
+
current.completionEvidence = goal.completionEvidence;
|
|
2179
|
+
const rt = s.runtimes.find((item) => item.goalID === goal.id);
|
|
2180
|
+
if (rt) {
|
|
2181
|
+
Object.assign(rt, releaseLease(rt));
|
|
2182
|
+
rt.activeRunID = undefined;
|
|
2183
|
+
rt.lastError = undefined;
|
|
2184
|
+
}
|
|
2185
|
+
return s;
|
|
2186
|
+
});
|
|
1683
2187
|
await appendEvent(directory, {
|
|
1684
2188
|
version: 1,
|
|
1685
2189
|
eventID: randomUUID2(),
|
|
@@ -1720,7 +2224,21 @@ function createControlWorker(options) {
|
|
|
1720
2224
|
runtime.lastError = undefined;
|
|
1721
2225
|
runtime.updatedAt = new Date().toISOString();
|
|
1722
2226
|
}
|
|
1723
|
-
await
|
|
2227
|
+
await mutateState(directory, `goal.force-block:${goal.id}`, async (s) => {
|
|
2228
|
+
const current = s.goals.find((item) => item.id === goal.id);
|
|
2229
|
+
if (!current)
|
|
2230
|
+
return s;
|
|
2231
|
+
current.status = "blocked";
|
|
2232
|
+
current.updatedAt = goal.updatedAt;
|
|
2233
|
+
current.blocker = goal.blocker;
|
|
2234
|
+
const rt = s.runtimes.find((item) => item.goalID === goal.id);
|
|
2235
|
+
if (rt) {
|
|
2236
|
+
Object.assign(rt, releaseLease(rt));
|
|
2237
|
+
rt.activeRunID = undefined;
|
|
2238
|
+
rt.lastError = undefined;
|
|
2239
|
+
}
|
|
2240
|
+
return s;
|
|
2241
|
+
});
|
|
1724
2242
|
await appendEvent(directory, {
|
|
1725
2243
|
version: 1,
|
|
1726
2244
|
eventID: randomUUID2(),
|
|
@@ -1891,7 +2409,7 @@ function createControlWorker(options) {
|
|
|
1891
2409
|
|
|
1892
2410
|
// src/application/loop-engine.ts
|
|
1893
2411
|
init_state_repository();
|
|
1894
|
-
import { randomUUID as
|
|
2412
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1895
2413
|
|
|
1896
2414
|
// src/domain/goal.ts
|
|
1897
2415
|
var MODEL_TRANSITIONS = {
|
|
@@ -2173,7 +2691,7 @@ function createLoopEngine(options) {
|
|
|
2173
2691
|
if (newlyStamped) {
|
|
2174
2692
|
await appendEvent(directory, {
|
|
2175
2693
|
version: 1,
|
|
2176
|
-
eventID:
|
|
2694
|
+
eventID: randomUUID4(),
|
|
2177
2695
|
goalID,
|
|
2178
2696
|
type: "idle.confirm-failed",
|
|
2179
2697
|
reason: failureReason,
|
|
@@ -2218,7 +2736,7 @@ function createLoopEngine(options) {
|
|
|
2218
2736
|
if (completedRunID) {
|
|
2219
2737
|
await appendEvent(directory, {
|
|
2220
2738
|
version: 1,
|
|
2221
|
-
eventID:
|
|
2739
|
+
eventID: randomUUID4(),
|
|
2222
2740
|
goalID,
|
|
2223
2741
|
type: "run.completed",
|
|
2224
2742
|
runID: completedRunID,
|
|
@@ -2283,7 +2801,7 @@ function createLoopEngine(options) {
|
|
|
2283
2801
|
});
|
|
2284
2802
|
await appendEvent(directory, {
|
|
2285
2803
|
version: 1,
|
|
2286
|
-
eventID:
|
|
2804
|
+
eventID: randomUUID4(),
|
|
2287
2805
|
goalID,
|
|
2288
2806
|
type: "goal.blocked",
|
|
2289
2807
|
reason: limitResult.reason + " (force-finish ignored)",
|
|
@@ -2307,7 +2825,7 @@ function createLoopEngine(options) {
|
|
|
2307
2825
|
});
|
|
2308
2826
|
await appendEvent(directory, {
|
|
2309
2827
|
version: 1,
|
|
2310
|
-
eventID:
|
|
2828
|
+
eventID: randomUUID4(),
|
|
2311
2829
|
goalID,
|
|
2312
2830
|
type: "goal.status_changed",
|
|
2313
2831
|
from: "active",
|
|
@@ -2409,7 +2927,7 @@ function createLoopEngine(options) {
|
|
|
2409
2927
|
});
|
|
2410
2928
|
await appendEvent(directory, {
|
|
2411
2929
|
version: 1,
|
|
2412
|
-
eventID:
|
|
2930
|
+
eventID: randomUUID4(),
|
|
2413
2931
|
goalID: goal.id,
|
|
2414
2932
|
type: "run.failed",
|
|
2415
2933
|
runID: runtime.activeRunID || "unknown",
|
|
@@ -2423,7 +2941,7 @@ function createLoopEngine(options) {
|
|
|
2423
2941
|
if (updatedGoal?.status === "blocked") {
|
|
2424
2942
|
await appendEvent(directory, {
|
|
2425
2943
|
version: 1,
|
|
2426
|
-
eventID:
|
|
2944
|
+
eventID: randomUUID4(),
|
|
2427
2945
|
goalID: goal.id,
|
|
2428
2946
|
type: "goal.blocked",
|
|
2429
2947
|
reason: `Failed ${runtime.consecutiveFailures + 1} times`,
|
|
@@ -2451,7 +2969,7 @@ function createLoopEngine(options) {
|
|
|
2451
2969
|
});
|
|
2452
2970
|
await appendEvent(directory, {
|
|
2453
2971
|
version: 1,
|
|
2454
|
-
eventID:
|
|
2972
|
+
eventID: randomUUID4(),
|
|
2455
2973
|
goalID: goal.id,
|
|
2456
2974
|
type: "compaction.completed",
|
|
2457
2975
|
timestamp: new Date().toISOString(),
|
|
@@ -2521,7 +3039,7 @@ function createLoopEngine(options) {
|
|
|
2521
3039
|
});
|
|
2522
3040
|
await appendEvent(directory, {
|
|
2523
3041
|
version: 1,
|
|
2524
|
-
eventID:
|
|
3042
|
+
eventID: randomUUID4(),
|
|
2525
3043
|
goalID: goal.id,
|
|
2526
3044
|
type: "compaction.started",
|
|
2527
3045
|
timestamp: new Date().toISOString(),
|
|
@@ -2579,7 +3097,7 @@ function createLoopEngine(options) {
|
|
|
2579
3097
|
return false;
|
|
2580
3098
|
await appendEvent(directory, {
|
|
2581
3099
|
version: 1,
|
|
2582
|
-
eventID:
|
|
3100
|
+
eventID: randomUUID4(),
|
|
2583
3101
|
goalID: goal.id,
|
|
2584
3102
|
type: "goal.status_changed",
|
|
2585
3103
|
from: "active",
|
|
@@ -2601,7 +3119,33 @@ function createLoopEngine(options) {
|
|
|
2601
3119
|
function hasExecutionLeak(runtime) {
|
|
2602
3120
|
return runtime.phase !== "idle" || Boolean(runtime.activeRunID) || Boolean(runtime.leaseExpiresAt) || Boolean(runtime.turnStartedAt) || Boolean(runtime.activePromptMessageID) || Boolean(runtime.activePromptObservedAt) || Boolean(runtime.activeAssistantMessageID) || Boolean(runtime.activeAssistantCompletedAt) || Boolean(runtime.idleCandidateAt) || (runtime.activeToolCallIDs?.length ?? 0) > 0;
|
|
2603
3121
|
}
|
|
3122
|
+
async function sweepStaleWorkspaceOwnership() {
|
|
3123
|
+
try {
|
|
3124
|
+
const snapshot = await readState(directory);
|
|
3125
|
+
if (!snapshot.workspaceCalls?.length && !snapshot.goals.some((g) => g.scopeClosing || g.scopeClearPending)) {
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
await Promise.resolve().then(() => init_workspace_execution());
|
|
3129
|
+
let summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
|
|
3130
|
+
await mutateState(directory, "maintenance.sweep-stale-workspace", async (s) => {
|
|
3131
|
+
summary = sweepStaleWorkspaceState(s, {
|
|
3132
|
+
isWorkerLive: () => true,
|
|
3133
|
+
now: Date.now()
|
|
3134
|
+
});
|
|
3135
|
+
return s;
|
|
3136
|
+
});
|
|
3137
|
+
if (summary.reapedCalls.length || summary.resetClosing.length || summary.finalizedClears.length || summary.forceCleared.length) {
|
|
3138
|
+
await logServerEvent(directory, "maintenance.sweep-stale-workspace", {
|
|
3139
|
+
reapedCalls: summary.reapedCalls,
|
|
3140
|
+
resetClosing: summary.resetClosing,
|
|
3141
|
+
finalizedClears: summary.finalizedClears,
|
|
3142
|
+
forceCleared: summary.forceCleared
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
3145
|
+
} catch {}
|
|
3146
|
+
}
|
|
2604
3147
|
async function maintenance() {
|
|
3148
|
+
await sweepStaleWorkspaceOwnership();
|
|
2605
3149
|
syncWorkerSessionsFromService();
|
|
2606
3150
|
if (knownWorkerSessions.size === 0)
|
|
2607
3151
|
return;
|
|
@@ -2756,7 +3300,7 @@ function createLoopEngine(options) {
|
|
|
2756
3300
|
if (recovered) {
|
|
2757
3301
|
await appendEvent(directory, {
|
|
2758
3302
|
version: 1,
|
|
2759
|
-
eventID:
|
|
3303
|
+
eventID: randomUUID4(),
|
|
2760
3304
|
goalID: goal.id,
|
|
2761
3305
|
type: "run.recovered",
|
|
2762
3306
|
runID: stalledRunID ?? "unknown",
|
|
@@ -2790,7 +3334,7 @@ function createLoopEngine(options) {
|
|
|
2790
3334
|
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
2791
3335
|
await appendEvent(directory, {
|
|
2792
3336
|
version: 1,
|
|
2793
|
-
eventID:
|
|
3337
|
+
eventID: randomUUID4(),
|
|
2794
3338
|
goalID: goal.id,
|
|
2795
3339
|
type: "run.stuck",
|
|
2796
3340
|
runID: runtime.activeRunID ?? "unknown",
|
|
@@ -2830,7 +3374,7 @@ function createLoopEngine(options) {
|
|
|
2830
3374
|
});
|
|
2831
3375
|
await appendEvent(directory, {
|
|
2832
3376
|
version: 1,
|
|
2833
|
-
eventID:
|
|
3377
|
+
eventID: randomUUID4(),
|
|
2834
3378
|
goalID: goal.id,
|
|
2835
3379
|
type: "run.stuck",
|
|
2836
3380
|
runID: runtime.activeRunID,
|
|
@@ -2853,14 +3397,14 @@ function createLoopEngine(options) {
|
|
|
2853
3397
|
}
|
|
2854
3398
|
|
|
2855
3399
|
// src/application/goal-service.ts
|
|
2856
|
-
import { randomUUID as
|
|
3400
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
2857
3401
|
init_state_repository();
|
|
2858
3402
|
init_command_await2();
|
|
2859
|
-
import * as
|
|
2860
|
-
import { promises as
|
|
3403
|
+
import * as path4 from "path";
|
|
3404
|
+
import { promises as fs4 } from "fs";
|
|
2861
3405
|
|
|
2862
3406
|
// src/server/host-adapter.ts
|
|
2863
|
-
import { randomUUID as
|
|
3407
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2864
3408
|
|
|
2865
3409
|
// src/v2/native-rpc.ts
|
|
2866
3410
|
var NATIVE_RPC_ID = "loopd.native";
|
|
@@ -3086,7 +3630,7 @@ function parseModelRef(value) {
|
|
|
3086
3630
|
return { providerID, modelID };
|
|
3087
3631
|
}
|
|
3088
3632
|
function newPromptMessageID() {
|
|
3089
|
-
return `msg_${
|
|
3633
|
+
return `msg_${randomUUID5()}`;
|
|
3090
3634
|
}
|
|
3091
3635
|
function isV2PromptMessageID(messageID) {
|
|
3092
3636
|
return messageID.startsWith("msg_");
|
|
@@ -3195,7 +3739,7 @@ function createRealHost(client, directory) {
|
|
|
3195
3739
|
},
|
|
3196
3740
|
async readSession(sessionID) {
|
|
3197
3741
|
try {
|
|
3198
|
-
const result = await client.session.get({ path: { id: sessionID } });
|
|
3742
|
+
const result = await withTimeout(client.session.get({ path: { id: sessionID } }), 1e4, "OpenCode session.get");
|
|
3199
3743
|
if (result?.error)
|
|
3200
3744
|
return;
|
|
3201
3745
|
const data = result?.data;
|
|
@@ -3219,7 +3763,7 @@ function createRealHost(client, directory) {
|
|
|
3219
3763
|
},
|
|
3220
3764
|
async sessionStatus(sessionID) {
|
|
3221
3765
|
try {
|
|
3222
|
-
const result = await client.session.status({});
|
|
3766
|
+
const result = await withTimeout(client.session.status({}), 1e4, "OpenCode session.status");
|
|
3223
3767
|
if (result?.error)
|
|
3224
3768
|
return "unknown";
|
|
3225
3769
|
const data = result?.data;
|
|
@@ -3242,15 +3786,15 @@ function createRealHost(client, directory) {
|
|
|
3242
3786
|
},
|
|
3243
3787
|
async abortSession(sessionID) {
|
|
3244
3788
|
try {
|
|
3245
|
-
await client.session.abort({ path: { id: sessionID } });
|
|
3789
|
+
await withTimeout(client.session.abort({ path: { id: sessionID } }), 1e4, "OpenCode session.abort");
|
|
3246
3790
|
} catch {}
|
|
3247
3791
|
},
|
|
3248
3792
|
async readMessages(sessionID, limit = 10) {
|
|
3249
3793
|
try {
|
|
3250
|
-
const result = await client.session.messages({
|
|
3794
|
+
const result = await withTimeout(client.session.messages({
|
|
3251
3795
|
path: { id: sessionID },
|
|
3252
3796
|
query: { limit }
|
|
3253
|
-
});
|
|
3797
|
+
}), 1e4, "OpenCode session.messages");
|
|
3254
3798
|
const data = result?.data;
|
|
3255
3799
|
if (!Array.isArray(data))
|
|
3256
3800
|
return [];
|
|
@@ -3286,7 +3830,7 @@ function createRealHost(client, directory) {
|
|
|
3286
3830
|
const identity = model ?? (await this.readSession(sessionID))?.model;
|
|
3287
3831
|
if (!identity || typeof client.session.summarize !== "function")
|
|
3288
3832
|
return;
|
|
3289
|
-
await client.session.summarize({ path: { id: sessionID }, body: identity });
|
|
3833
|
+
await withTimeout(client.session.summarize({ path: { id: sessionID }, body: identity }), 1e4, "OpenCode session.summarize");
|
|
3290
3834
|
} catch {}
|
|
3291
3835
|
},
|
|
3292
3836
|
async notifyOwner(ownerSessionID, message, agent) {
|
|
@@ -3297,7 +3841,7 @@ function createRealHost(client, directory) {
|
|
|
3297
3841
|
let resolvedAgent = agent?.trim() || undefined;
|
|
3298
3842
|
if (!resolvedAgent) {
|
|
3299
3843
|
try {
|
|
3300
|
-
const sess = await client.session.get({ path: { id: ownerSessionID } });
|
|
3844
|
+
const sess = await withTimeout(client.session.get({ path: { id: ownerSessionID } }), 1e4, "OpenCode session.get (parent identity)");
|
|
3301
3845
|
const liveAgent = sess?.data?.agent;
|
|
3302
3846
|
if (typeof liveAgent === "string" && liveAgent.trim())
|
|
3303
3847
|
resolvedAgent = liveAgent.trim();
|
|
@@ -3332,6 +3876,7 @@ function toWorkerCreation(result) {
|
|
|
3332
3876
|
function createV2Host(context, statuses, options = {}) {
|
|
3333
3877
|
const directory = context.location.directory;
|
|
3334
3878
|
return {
|
|
3879
|
+
scopedExecution: true,
|
|
3335
3880
|
async listAgents() {
|
|
3336
3881
|
const switching = typeof context.session.switchAgent === "function" ? "session" : "unsupported";
|
|
3337
3882
|
if (typeof context.agent?.list !== "function")
|
|
@@ -3346,7 +3891,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3346
3891
|
async switchSessionAgent(sessionID, agent) {
|
|
3347
3892
|
if (typeof context.session.switchAgent !== "function")
|
|
3348
3893
|
return "unsupported";
|
|
3349
|
-
await context.session.switchAgent({ sessionID, agent });
|
|
3894
|
+
await withTimeout(context.session.switchAgent({ sessionID, agent }), 1e4, "OpenCode v2 session.switchAgent");
|
|
3350
3895
|
return "applied";
|
|
3351
3896
|
},
|
|
3352
3897
|
async listModels() {
|
|
@@ -3369,7 +3914,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3369
3914
|
async switchSessionModel(sessionID, model) {
|
|
3370
3915
|
if (typeof context.session.switchModel !== "function")
|
|
3371
3916
|
return "unsupported";
|
|
3372
|
-
await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
|
|
3917
|
+
await withTimeout(context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } }), 1e4, "OpenCode v2 session.switchModel");
|
|
3373
3918
|
return "applied";
|
|
3374
3919
|
},
|
|
3375
3920
|
async createWorker({ parentID, title, agent, model, goalID }) {
|
|
@@ -3394,7 +3939,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3394
3939
|
}
|
|
3395
3940
|
if (outcome.kind === "native-child") {
|
|
3396
3941
|
const childID = outcome.childSessionID;
|
|
3397
|
-
const child = await context.session.get({ sessionID: childID });
|
|
3942
|
+
const child = await withTimeout(context.session.get({ sessionID: childID }), 1e4, "OpenCode v2 session.get (native parent verify)");
|
|
3398
3943
|
const actualParent = resolveNativeParentID(child);
|
|
3399
3944
|
if (actualParent !== parentID) {
|
|
3400
3945
|
const detail = `resolved-parent=${JSON.stringify(actualParent)} expected=${JSON.stringify(parentID)}`;
|
|
@@ -3402,16 +3947,16 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3402
3947
|
throw new Error(`loopd native worker creation failed for parent "${parentID}" (parent-mismatch): ${detail}`);
|
|
3403
3948
|
}
|
|
3404
3949
|
if (agent)
|
|
3405
|
-
await context.session.switchAgent({ sessionID: childID, agent });
|
|
3950
|
+
await withTimeout(context.session.switchAgent({ sessionID: childID, agent }), 1e4, "OpenCode v2 session.switchAgent (native re-apply)");
|
|
3406
3951
|
if (model) {
|
|
3407
|
-
await context.session.switchModel({
|
|
3952
|
+
await withTimeout(context.session.switchModel({
|
|
3408
3953
|
sessionID: childID,
|
|
3409
3954
|
model: { id: model.modelID, providerID: model.providerID }
|
|
3410
|
-
});
|
|
3955
|
+
}), 1e4, "OpenCode v2 session.switchModel (native re-apply)");
|
|
3411
3956
|
}
|
|
3412
3957
|
const rename = context.session.update;
|
|
3413
3958
|
if (rename)
|
|
3414
|
-
await rename({ sessionID: childID, title });
|
|
3959
|
+
await withTimeout(rename({ sessionID: childID, title }), 1e4, "OpenCode v2 session.update (native title)");
|
|
3415
3960
|
statuses.set(childID, "idle");
|
|
3416
3961
|
await logServerEvent(directory, "worker.created", {
|
|
3417
3962
|
parentID,
|
|
@@ -3428,13 +3973,13 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3428
3973
|
reason: outcome.kind === "fallback-safe" ? outcome.reason : "unclaimed"
|
|
3429
3974
|
});
|
|
3430
3975
|
}
|
|
3431
|
-
const session = await context.session.create({
|
|
3976
|
+
const session = await withTimeout(context.session.create({
|
|
3432
3977
|
title,
|
|
3433
3978
|
...agent !== undefined ? { agent } : {},
|
|
3434
3979
|
...model !== undefined ? { model: { id: model.modelID, providerID: model.providerID } } : {},
|
|
3435
3980
|
location: { directory },
|
|
3436
3981
|
metadata: { "loopd.parentID": parentID }
|
|
3437
|
-
});
|
|
3982
|
+
}), 1e4, "OpenCode v2 session.create");
|
|
3438
3983
|
statuses.set(session.id, "idle");
|
|
3439
3984
|
await logServerEvent(directory, "worker.created", {
|
|
3440
3985
|
parentID,
|
|
@@ -3446,28 +3991,28 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3446
3991
|
},
|
|
3447
3992
|
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
3448
3993
|
if (agent)
|
|
3449
|
-
await context.session.switchAgent({ sessionID, agent });
|
|
3994
|
+
await withTimeout(context.session.switchAgent({ sessionID, agent }), 1e4, "OpenCode v2 session.switchAgent (prompt)");
|
|
3450
3995
|
if (model) {
|
|
3451
|
-
await context.session.switchModel({
|
|
3996
|
+
await withTimeout(context.session.switchModel({
|
|
3452
3997
|
sessionID,
|
|
3453
3998
|
model: { id: model.modelID, providerID: model.providerID }
|
|
3454
|
-
});
|
|
3999
|
+
}), 1e4, "OpenCode v2 session.switchModel (prompt)");
|
|
3455
4000
|
}
|
|
3456
4001
|
if (messageID !== undefined && !isV2PromptMessageID(messageID)) {
|
|
3457
4002
|
throw new Error(`loopd prompt ID "${messageID}" is invalid for OpenCode v2: must start with "msg_". ` + `Generate IDs with newPromptMessageID() so persisted, delivered, and correlated IDs agree.`);
|
|
3458
4003
|
}
|
|
3459
|
-
const result = await context.session.prompt({
|
|
4004
|
+
const result = await withTimeout(context.session.prompt({
|
|
3460
4005
|
sessionID,
|
|
3461
4006
|
id: messageID,
|
|
3462
4007
|
text: prompt
|
|
3463
|
-
});
|
|
4008
|
+
}), 30000, "OpenCode v2 session.prompt (acceptance)");
|
|
3464
4009
|
statuses.set(sessionID, "busy");
|
|
3465
4010
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
3466
4011
|
return { messageID: result?.id };
|
|
3467
4012
|
},
|
|
3468
4013
|
async readSession(sessionID) {
|
|
3469
4014
|
try {
|
|
3470
|
-
const session = await context.session.get({ sessionID });
|
|
4015
|
+
const session = await withTimeout(context.session.get({ sessionID }), 1e4, "OpenCode v2 session.get");
|
|
3471
4016
|
const model = session.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined;
|
|
3472
4017
|
if (!session.agent && !model)
|
|
3473
4018
|
return;
|
|
@@ -3487,7 +4032,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3487
4032
|
},
|
|
3488
4033
|
async readMessages(sessionID, limit = 10) {
|
|
3489
4034
|
try {
|
|
3490
|
-
const messages = await context.session.context({ sessionID });
|
|
4035
|
+
const messages = await withTimeout(context.session.context({ sessionID }), 1e4, "OpenCode v2 session.context");
|
|
3491
4036
|
let parentMessageID;
|
|
3492
4037
|
return messages.flatMap((message) => {
|
|
3493
4038
|
if (message.type === "user") {
|
|
@@ -3531,14 +4076,14 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3531
4076
|
if (model) {
|
|
3532
4077
|
if (typeof context.session.switchModel !== "function")
|
|
3533
4078
|
return;
|
|
3534
|
-
await context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } });
|
|
4079
|
+
await withTimeout(context.session.switchModel({ sessionID, model: { id: model.modelID, providerID: model.providerID } }), 1e4, "OpenCode v2 session.switchModel (compact)");
|
|
3535
4080
|
}
|
|
3536
|
-
await context.session.command({ sessionID, name: "compact", text: "" });
|
|
4081
|
+
await withTimeout(context.session.command({ sessionID, name: "compact", text: "" }), 1e4, "OpenCode v2 session.command (compact)");
|
|
3537
4082
|
} catch {}
|
|
3538
4083
|
},
|
|
3539
4084
|
async notifyOwner(ownerSessionID, message) {
|
|
3540
4085
|
try {
|
|
3541
|
-
await context.session.prompt({ sessionID: ownerSessionID, text: message });
|
|
4086
|
+
await withTimeout(context.session.prompt({ sessionID: ownerSessionID, text: message }), 1e4, "OpenCode v2 parent notify (acceptance)");
|
|
3542
4087
|
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
3543
4088
|
return true;
|
|
3544
4089
|
} catch (error) {
|
|
@@ -3650,6 +4195,20 @@ function createWorkerManager(host) {
|
|
|
3650
4195
|
}
|
|
3651
4196
|
function buildContinuationSteering(goal, runtime, context) {
|
|
3652
4197
|
const parts = [];
|
|
4198
|
+
const scopeInstructions = goal.config.write_scope !== undefined ? [
|
|
4199
|
+
``,
|
|
4200
|
+
`## FILE OWNERSHIP`,
|
|
4201
|
+
`write_scope: ${JSON.stringify(goal.config.write_scope)} (exact files; [] means exploration-only).`,
|
|
4202
|
+
`Explore with reads/search \u2192 claim_goal_scope({ paths, runGeneration from get_goal }) \u2192 structured edit/write/apply_patch \u2192 run_goal_checks \u2192 report progress or complete_goal.`,
|
|
4203
|
+
`Claim only the minimum files needed. Request expansion BEFORE editing new paths; rename requires both endpoints. No globs.`,
|
|
4204
|
+
`Conflicts identify owning goal/session/path. Initial and expansion conflicts fail immediately with no partial claim and no automatic queue. Coordinate with the owner, then retry once; never sleep, poll claims, or repeatedly prompt waiting workers.`,
|
|
4205
|
+
`Arbitrary shell, loopd_command_start/write, custom tools, batch, and subagent writes are denied. Configured checks are trusted commands under a workspace-wide operation lock, not an OS sandbox.`,
|
|
4206
|
+
`Output-location exceptions do not grant extra source files. Only this goal's artifact directory is writable without source claims.`
|
|
4207
|
+
] : goal.config.workspaceWrite === false ? [
|
|
4208
|
+
``,
|
|
4209
|
+
`## READ-ONLY OWNERSHIP`,
|
|
4210
|
+
`Shared source writes and arbitrary shell/custom/subagent tools are denied. Write artifacts only under this goal's artifact directory. Use run_goal_checks for trusted configured verification.`
|
|
4211
|
+
] : [];
|
|
3653
4212
|
const artifactDir = goal.config.artifactDir;
|
|
3654
4213
|
function outputLocationBlock() {
|
|
3655
4214
|
if (!artifactDir)
|
|
@@ -3733,11 +4292,14 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
3733
4292
|
parts.push(`- ${msg}`);
|
|
3734
4293
|
}
|
|
3735
4294
|
}
|
|
4295
|
+
parts.push(...scopeInstructions);
|
|
3736
4296
|
return parts.join(`
|
|
3737
4297
|
`);
|
|
3738
4298
|
}
|
|
3739
4299
|
|
|
3740
4300
|
// src/application/goal-service.ts
|
|
4301
|
+
init_workspace_execution();
|
|
4302
|
+
|
|
3741
4303
|
class GoalStartError extends Error {
|
|
3742
4304
|
goalID;
|
|
3743
4305
|
workerSessionID;
|
|
@@ -3772,13 +4334,9 @@ function createGoalService(host) {
|
|
|
3772
4334
|
}
|
|
3773
4335
|
}
|
|
3774
4336
|
function assertWorkspaceWriteAvailable(state, goal, requesterSessionID) {
|
|
3775
|
-
if (
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
if (activeWriter) {
|
|
3779
|
-
const ownedElsewhere = requesterSessionID !== undefined && activeWriter.ownerSessionID !== requesterSessionID;
|
|
3780
|
-
throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active` + (ownedElsewhere ? ` (owned by session ${activeWriter.ownerSessionID}, not this session)` : "") + ". Pause, block, complete, or clear it before activating another workspace-writing goal." + (ownedElsewhere ? " Note: list_background_goals shows only this session's goals; clear/pause it from its owning session." : ""));
|
|
3781
|
-
}
|
|
4337
|
+
if (goal.config.write_scope !== undefined && !host.scopedExecution)
|
|
4338
|
+
throw new Error("Enforced file scopes are unsupported on this host. Use the guarded v2 tool execution surface; v1 retains legacy exclusivity only.");
|
|
4339
|
+
assertScopeAvailable(state, goal);
|
|
3782
4340
|
}
|
|
3783
4341
|
async function listModels() {
|
|
3784
4342
|
return host.listModels ? host.listModels() : emptyCatalog("host catalog capability absent", "unsupported");
|
|
@@ -3838,6 +4396,8 @@ function createGoalService(host) {
|
|
|
3838
4396
|
throw new Error("Cannot switch a completed goal.");
|
|
3839
4397
|
if (opts?.resume && goal.status !== "blocked")
|
|
3840
4398
|
throw new Error("resume is only valid for a blocked goal.");
|
|
4399
|
+
if (opts?.resume && goal.config.write_scope !== undefined)
|
|
4400
|
+
throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
|
|
3841
4401
|
if (opts?.resume)
|
|
3842
4402
|
assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
|
|
3843
4403
|
const { agent, catalog } = await validateAgent(value);
|
|
@@ -3969,6 +4529,8 @@ function createGoalService(host) {
|
|
|
3969
4529
|
if (opts?.resume && (goal.status !== "blocked" || goal.blocker?.kind !== "provider-limit")) {
|
|
3970
4530
|
throw new Error("resume is only valid for a provider-limit-blocked goal.");
|
|
3971
4531
|
}
|
|
4532
|
+
if (opts?.resume && goal.config.write_scope !== undefined)
|
|
4533
|
+
throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
|
|
3972
4534
|
if (opts?.resume)
|
|
3973
4535
|
assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
|
|
3974
4536
|
const { model, catalog } = await validateModel(value);
|
|
@@ -4027,6 +4589,8 @@ function createGoalService(host) {
|
|
|
4027
4589
|
throw new Error("Cannot switch a completed goal.");
|
|
4028
4590
|
if (opts.resume && goal.status !== "blocked")
|
|
4029
4591
|
throw new Error("resume is only valid for a blocked goal.");
|
|
4592
|
+
if (opts.resume && goal.config.write_scope !== undefined)
|
|
4593
|
+
throw new Error("Scoped identity-resume is unsupported. Use resume_goal to reacquire and fence the session, then switch identity.");
|
|
4030
4594
|
if (opts.resume)
|
|
4031
4595
|
assertWorkspaceWriteAvailable(state, goal, ownerSessionID);
|
|
4032
4596
|
const { model, catalog: models } = await validateModel(opts.model);
|
|
@@ -4240,17 +4804,22 @@ function createGoalService(host) {
|
|
|
4240
4804
|
await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
|
|
4241
4805
|
});
|
|
4242
4806
|
}
|
|
4243
|
-
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
4807
|
+
async function recordPromptFailure(directory, goalID, error, blockImmediately = false, expectedRunID) {
|
|
4244
4808
|
const detail = describeError(error);
|
|
4245
4809
|
let runID = "unknown";
|
|
4246
4810
|
let failureCount = 0;
|
|
4247
4811
|
let blocked = false;
|
|
4812
|
+
let superseded = false;
|
|
4248
4813
|
let blockerNeeded = "Retry after the OpenCode worker/session API is available.";
|
|
4249
4814
|
const state = await mutateState(directory, `turn.prompt-failed:${goalID}`, async (s) => {
|
|
4250
4815
|
const goal = s.goals.find((item) => item.id === goalID);
|
|
4251
4816
|
const rt = s.runtimes.find((item) => item.goalID === goalID);
|
|
4252
4817
|
if (!goal || !rt)
|
|
4253
4818
|
return s;
|
|
4819
|
+
if (expectedRunID !== undefined && rt.activeRunID !== expectedRunID) {
|
|
4820
|
+
superseded = true;
|
|
4821
|
+
return s;
|
|
4822
|
+
}
|
|
4254
4823
|
runID = rt.activeRunID || "unknown";
|
|
4255
4824
|
failureCount = rt.consecutiveFailures + 1;
|
|
4256
4825
|
Object.assign(rt, releaseLease(rt));
|
|
@@ -4275,9 +4844,14 @@ function createGoalService(host) {
|
|
|
4275
4844
|
rt.updatedAt = new Date().toISOString();
|
|
4276
4845
|
return s;
|
|
4277
4846
|
});
|
|
4847
|
+
if (superseded) {
|
|
4848
|
+
await logServerEvent(directory, "turn.prompt-failed-superseded", { goalID, expectedRunID }).catch(() => {});
|
|
4849
|
+
await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
|
|
4850
|
+
return;
|
|
4851
|
+
}
|
|
4278
4852
|
await appendEvent(directory, {
|
|
4279
4853
|
version: 1,
|
|
4280
|
-
eventID:
|
|
4854
|
+
eventID: randomUUID6(),
|
|
4281
4855
|
goalID,
|
|
4282
4856
|
type: "run.failed",
|
|
4283
4857
|
runID,
|
|
@@ -4289,7 +4863,7 @@ function createGoalService(host) {
|
|
|
4289
4863
|
if (blocked) {
|
|
4290
4864
|
await appendEvent(directory, {
|
|
4291
4865
|
version: 1,
|
|
4292
|
-
eventID:
|
|
4866
|
+
eventID: randomUUID6(),
|
|
4293
4867
|
goalID,
|
|
4294
4868
|
type: "goal.blocked",
|
|
4295
4869
|
reason: `Worker prompt delivery failed: ${detail}`,
|
|
@@ -4300,6 +4874,41 @@ function createGoalService(host) {
|
|
|
4300
4874
|
}
|
|
4301
4875
|
await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
|
|
4302
4876
|
}
|
|
4877
|
+
function quotaHoldMessage(goal) {
|
|
4878
|
+
const observation = goal.lastProviderLimit;
|
|
4879
|
+
const retryMs = observation?.retryAt ? Date.parse(observation.retryAt) : NaN;
|
|
4880
|
+
if (!observation || !Number.isFinite(retryMs) || retryMs <= Date.now())
|
|
4881
|
+
return;
|
|
4882
|
+
if (observation.model && observation.model !== goal.config.model)
|
|
4883
|
+
return;
|
|
4884
|
+
const kind = observation.kind === "quota" ? "quota exhausted" : "rate-limited";
|
|
4885
|
+
const pending = goal.modelSwitch?.pending?.reason === "quota" ? ` A quota fallback to "${goal.modelSwitch.pending.model}" is prepared and applies on the next idle turn.` : "";
|
|
4886
|
+
return `Model "${goal.config.model ?? "default"}" is provider-${kind} ` + `(observed ${observation.observedAt}; provider says retry after ${observation.retryAt}).` + `${pending} Not re-prompting: nudging a limited model burns quota without helping. ` + `Wait for the retry time, switch model, or use retry_goal afterwards.`;
|
|
4887
|
+
}
|
|
4888
|
+
async function wasPromptAccepted(directory, workerSessionID, promptMessageID) {
|
|
4889
|
+
if (!promptMessageID)
|
|
4890
|
+
return false;
|
|
4891
|
+
try {
|
|
4892
|
+
const tail = await withStageTimeout(host.readMessages(workerSessionID, 10), PROMPT_VERIFY_TIMEOUT_MS, `late prompt-acceptance verify for session "${workerSessionID}"`);
|
|
4893
|
+
const found = tail.some((m) => m.messageID === promptMessageID || m.parentMessageID === promptMessageID);
|
|
4894
|
+
if (found) {
|
|
4895
|
+
await logServerEvent(directory, "worker.prompted-late", { workerSessionID, promptMessageID }).catch(() => {});
|
|
4896
|
+
}
|
|
4897
|
+
return found;
|
|
4898
|
+
} catch {
|
|
4899
|
+
return false;
|
|
4900
|
+
}
|
|
4901
|
+
}
|
|
4902
|
+
async function dispatchOrRecordFailure(directory, goalID, dispatch, run, workerSessionID, blockImmediately = false) {
|
|
4903
|
+
try {
|
|
4904
|
+
await dispatch();
|
|
4905
|
+
} catch (error) {
|
|
4906
|
+
if (await wasPromptAccepted(directory, workerSessionID, run.activePromptMessageID))
|
|
4907
|
+
return;
|
|
4908
|
+
await recordPromptFailure(directory, goalID, error, blockImmediately, run.activeRunID);
|
|
4909
|
+
throw error;
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4303
4912
|
async function ensureWorkerSession(directory, goal) {
|
|
4304
4913
|
let session = sessions.get(goal.id);
|
|
4305
4914
|
if (session)
|
|
@@ -4329,10 +4938,12 @@ function createGoalService(host) {
|
|
|
4329
4938
|
return session;
|
|
4330
4939
|
}
|
|
4331
4940
|
async function start(directory, input) {
|
|
4332
|
-
const id =
|
|
4941
|
+
const id = randomUUID6();
|
|
4333
4942
|
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
4334
4943
|
}
|
|
4335
4944
|
async function startUnlocked(directory, input, id) {
|
|
4945
|
+
if (input.config?.write_scope !== undefined && !host.scopedExecution)
|
|
4946
|
+
throw new Error("Enforced file scopes are unsupported on this host. Use the guarded v2 tool execution surface; v1 retains legacy exclusivity only.");
|
|
4336
4947
|
if (input.config?.fallbackModels?.length) {
|
|
4337
4948
|
if (input.config.fallbackModels.length > 16)
|
|
4338
4949
|
throw new Error("At most 16 ordered fallback models are allowed.");
|
|
@@ -4370,6 +4981,15 @@ function createGoalService(host) {
|
|
|
4370
4981
|
...input.config
|
|
4371
4982
|
}
|
|
4372
4983
|
});
|
|
4984
|
+
if (goal.config.write_scope !== undefined) {
|
|
4985
|
+
if (!Array.isArray(goal.config.write_scope))
|
|
4986
|
+
throw new Error("write_scope must be an array of exact file paths");
|
|
4987
|
+
if (!goal.config.workspaceWrite && goal.config.write_scope.length > 0) {
|
|
4988
|
+
throw new Error("Read-only/artifact goals cannot claim shared workspace files.");
|
|
4989
|
+
}
|
|
4990
|
+
await Promise.resolve().then(() => init_workspace_scope());
|
|
4991
|
+
goal.config.write_scope = await canonicalWriteScope(directory, goal.config.write_scope);
|
|
4992
|
+
}
|
|
4373
4993
|
if (typeof input.costBudget === "number")
|
|
4374
4994
|
goal.costBudget = input.costBudget;
|
|
4375
4995
|
if (input.interactive === true)
|
|
@@ -4381,7 +5001,7 @@ function createGoalService(host) {
|
|
|
4381
5001
|
const artifactDir = goalArtifactDir(directory, id);
|
|
4382
5002
|
goal.config.artifactDir = artifactDir;
|
|
4383
5003
|
if (!goal.config.progressFile)
|
|
4384
|
-
goal.config.progressFile =
|
|
5004
|
+
goal.config.progressFile = path4.join(artifactDir, "progress.md");
|
|
4385
5005
|
await ensureGoalArtifactDir(directory, id);
|
|
4386
5006
|
const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
|
|
4387
5007
|
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
@@ -4425,7 +5045,7 @@ function createGoalService(host) {
|
|
|
4425
5045
|
});
|
|
4426
5046
|
await appendEvent(directory, {
|
|
4427
5047
|
version: 1,
|
|
4428
|
-
eventID:
|
|
5048
|
+
eventID: randomUUID6(),
|
|
4429
5049
|
goalID: id,
|
|
4430
5050
|
type: "goal.blocked",
|
|
4431
5051
|
reason: detail,
|
|
@@ -4452,7 +5072,7 @@ function createGoalService(host) {
|
|
|
4452
5072
|
const rt = state.runtimes.find((item) => item.goalID === id);
|
|
4453
5073
|
if (rt) {
|
|
4454
5074
|
Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
|
|
4455
|
-
rt.activeRunID =
|
|
5075
|
+
rt.activeRunID = randomUUID6();
|
|
4456
5076
|
rt.activePromptMessageID = newPromptMessageID();
|
|
4457
5077
|
rt.runCount = 1;
|
|
4458
5078
|
rt.budgetTurnCount = 1;
|
|
@@ -4463,7 +5083,7 @@ function createGoalService(host) {
|
|
|
4463
5083
|
runtime = state2.runtimes.find((r) => r.goalID === id);
|
|
4464
5084
|
await appendEvent(directory, {
|
|
4465
5085
|
version: 1,
|
|
4466
|
-
eventID:
|
|
5086
|
+
eventID: randomUUID6(),
|
|
4467
5087
|
goalID: id,
|
|
4468
5088
|
type: "goal.created",
|
|
4469
5089
|
name: input.name,
|
|
@@ -4475,7 +5095,7 @@ function createGoalService(host) {
|
|
|
4475
5095
|
if (runtime) {
|
|
4476
5096
|
await appendEvent(directory, {
|
|
4477
5097
|
version: 1,
|
|
4478
|
-
eventID:
|
|
5098
|
+
eventID: randomUUID6(),
|
|
4479
5099
|
goalID: id,
|
|
4480
5100
|
type: "run.started",
|
|
4481
5101
|
runID: runtime.activeRunID,
|
|
@@ -4484,9 +5104,8 @@ function createGoalService(host) {
|
|
|
4484
5104
|
revision: state2.revision
|
|
4485
5105
|
});
|
|
4486
5106
|
try {
|
|
4487
|
-
await workers.continueWorker(worker, goal, runtime);
|
|
5107
|
+
await dispatchOrRecordFailure(directory, id, () => workers.continueWorker(worker, goal, runtime), runtime, worker.workerSessionID, true);
|
|
4488
5108
|
} catch (error) {
|
|
4489
|
-
await recordPromptFailure(directory, id, error, true);
|
|
4490
5109
|
throw new GoalStartError(describeError(error), {
|
|
4491
5110
|
goalID: id,
|
|
4492
5111
|
workerSessionID: worker.workerSessionID,
|
|
@@ -4558,12 +5177,23 @@ function createGoalService(host) {
|
|
|
4558
5177
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
4559
5178
|
if (!goal || goal.status !== "active")
|
|
4560
5179
|
return;
|
|
5180
|
+
if (goal.config.write_scope !== undefined && !host.scopedExecution)
|
|
5181
|
+
return;
|
|
5182
|
+
{
|
|
5183
|
+
const now = Date.now();
|
|
5184
|
+
if (goal.scopeClosing || (preState.workspaceCalls ?? []).some((call) => call.goalID === goalID && !isSyncStaleWorkspaceCall(preState, call, now)) || preState.workspaceOperation)
|
|
5185
|
+
return;
|
|
5186
|
+
}
|
|
4561
5187
|
const runtime = preState.runtimes.find((r) => r.goalID === goalID);
|
|
4562
5188
|
if (!runtime)
|
|
4563
5189
|
return;
|
|
4564
5190
|
if (runtime.phase === "running" && leaseIsValid(runtime))
|
|
4565
5191
|
return;
|
|
4566
5192
|
let session = sessions.get(goalID);
|
|
5193
|
+
if (session && session.workerSessionID !== goal.workerSessionID) {
|
|
5194
|
+
sessions.delete(goalID);
|
|
5195
|
+
session = undefined;
|
|
5196
|
+
}
|
|
4567
5197
|
if (!session && goal.workerSessionID) {
|
|
4568
5198
|
session = {
|
|
4569
5199
|
goalID: goal.id,
|
|
@@ -4572,6 +5202,8 @@ function createGoalService(host) {
|
|
|
4572
5202
|
};
|
|
4573
5203
|
sessions.set(goalID, session);
|
|
4574
5204
|
}
|
|
5205
|
+
if (!session && !goal.workerSessionID)
|
|
5206
|
+
session = await ensureWorkerSession(directory, goal);
|
|
4575
5207
|
if (!session)
|
|
4576
5208
|
return;
|
|
4577
5209
|
if (!opts?.force && !await workers.isIdle(session.workerSessionID))
|
|
@@ -4595,7 +5227,7 @@ function createGoalService(host) {
|
|
|
4595
5227
|
return s;
|
|
4596
5228
|
const timeoutMs = g.config.timeoutMs || 300000;
|
|
4597
5229
|
Object.assign(rt, acquireLease(rt, timeoutMs));
|
|
4598
|
-
rt.activeRunID =
|
|
5230
|
+
rt.activeRunID = randomUUID6();
|
|
4599
5231
|
rt.activePromptMessageID = newPromptMessageID();
|
|
4600
5232
|
rt.runCount += 1;
|
|
4601
5233
|
if (rt.freeRetryPending) {
|
|
@@ -4613,7 +5245,7 @@ function createGoalService(host) {
|
|
|
4613
5245
|
return;
|
|
4614
5246
|
await appendEvent(directory, {
|
|
4615
5247
|
version: 1,
|
|
4616
|
-
eventID:
|
|
5248
|
+
eventID: randomUUID6(),
|
|
4617
5249
|
goalID,
|
|
4618
5250
|
type: "run.started",
|
|
4619
5251
|
runID: freshRuntime.activeRunID,
|
|
@@ -4626,12 +5258,7 @@ function createGoalService(host) {
|
|
|
4626
5258
|
const bareText = bareWords.join(`
|
|
4627
5259
|
`).trim();
|
|
4628
5260
|
if (bareText) {
|
|
4629
|
-
|
|
4630
|
-
await workers.sendBare(session, freshGoal, freshRuntime, bareText);
|
|
4631
|
-
} catch (error) {
|
|
4632
|
-
await recordPromptFailure(directory, goalID, error);
|
|
4633
|
-
throw error;
|
|
4634
|
-
}
|
|
5261
|
+
await dispatchOrRecordFailure(directory, goalID, () => workers.sendBare(session, freshGoal, freshRuntime, bareText), freshRuntime, session.workerSessionID);
|
|
4635
5262
|
return;
|
|
4636
5263
|
}
|
|
4637
5264
|
}
|
|
@@ -4654,7 +5281,7 @@ function createGoalService(host) {
|
|
|
4654
5281
|
const artifactDir = freshGoal.config.artifactDir;
|
|
4655
5282
|
if (artifactDir) {
|
|
4656
5283
|
try {
|
|
4657
|
-
const files = await
|
|
5284
|
+
const files = await fs4.readdir(artifactDir);
|
|
4658
5285
|
verification = { artifactSummary: files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet" };
|
|
4659
5286
|
} catch {
|
|
4660
5287
|
verification = { artifactSummary: "no artifacts yet" };
|
|
@@ -4678,12 +5305,7 @@ function createGoalService(host) {
|
|
|
4678
5305
|
forceFinish: opts?.forceFinish || undefined,
|
|
4679
5306
|
verification
|
|
4680
5307
|
};
|
|
4681
|
-
|
|
4682
|
-
await workers.continueWorker(session, freshGoal, freshRuntime, context);
|
|
4683
|
-
} catch (error) {
|
|
4684
|
-
await recordPromptFailure(directory, goalID, error);
|
|
4685
|
-
throw error;
|
|
4686
|
-
}
|
|
5308
|
+
await dispatchOrRecordFailure(directory, goalID, () => workers.continueWorker(session, freshGoal, freshRuntime, context), freshRuntime, session.workerSessionID);
|
|
4687
5309
|
}
|
|
4688
5310
|
async function setInteractiveUnlocked(directory, goalID, interactive) {
|
|
4689
5311
|
const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
|
|
@@ -4702,7 +5324,7 @@ function createGoalService(host) {
|
|
|
4702
5324
|
return { ok: false, message: "Goal not found." };
|
|
4703
5325
|
await appendEvent(directory, {
|
|
4704
5326
|
version: 1,
|
|
4705
|
-
eventID:
|
|
5327
|
+
eventID: randomUUID6(),
|
|
4706
5328
|
goalID,
|
|
4707
5329
|
type: "goal.interactive_changed",
|
|
4708
5330
|
interactive,
|
|
@@ -4724,11 +5346,25 @@ function createGoalService(host) {
|
|
|
4724
5346
|
return;
|
|
4725
5347
|
if (!canTransition(goal.status, "paused", "user"))
|
|
4726
5348
|
return;
|
|
5349
|
+
if (goal.config.write_scope !== undefined) {
|
|
5350
|
+
await mutateState(directory, `goal.pause-closing:${goalID}`, async (s) => {
|
|
5351
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
5352
|
+
if (g) {
|
|
5353
|
+
g.scopeClosing = true;
|
|
5354
|
+
retireWorker(s, g);
|
|
5355
|
+
}
|
|
5356
|
+
return s;
|
|
5357
|
+
});
|
|
5358
|
+
if (goal.workerSessionID)
|
|
5359
|
+
await workers.abortWorker(goal.workerSessionID);
|
|
5360
|
+
sessions.delete(goalID);
|
|
5361
|
+
}
|
|
4727
5362
|
const state = await mutateState(directory, `goal.pause:${goalID}`, async (s) => {
|
|
4728
5363
|
const g = s.goals.find((item) => item.id === goalID);
|
|
4729
5364
|
if (!g)
|
|
4730
5365
|
return s;
|
|
4731
5366
|
g.status = "paused";
|
|
5367
|
+
g.scopeClosing = false;
|
|
4732
5368
|
g.updatedAt = new Date().toISOString();
|
|
4733
5369
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
4734
5370
|
if (rt) {
|
|
@@ -4742,14 +5378,14 @@ function createGoalService(host) {
|
|
|
4742
5378
|
workerSessionID: goal.workerSessionID,
|
|
4743
5379
|
startedAt: goal.createdAt
|
|
4744
5380
|
} : undefined);
|
|
4745
|
-
if (session) {
|
|
5381
|
+
if (session && goal.config.write_scope === undefined) {
|
|
4746
5382
|
await workers.abortWorker(session.workerSessionID);
|
|
4747
5383
|
sessions.delete(goalID);
|
|
4748
5384
|
}
|
|
4749
5385
|
await cancelAwaitsForGoal(directory, goalID, "goal paused").catch(() => {});
|
|
4750
5386
|
await appendEvent(directory, {
|
|
4751
5387
|
version: 1,
|
|
4752
|
-
eventID:
|
|
5388
|
+
eventID: randomUUID6(),
|
|
4753
5389
|
goalID,
|
|
4754
5390
|
type: "goal.status_changed",
|
|
4755
5391
|
from: "active",
|
|
@@ -4758,7 +5394,36 @@ function createGoalService(host) {
|
|
|
4758
5394
|
revision: state.revision
|
|
4759
5395
|
});
|
|
4760
5396
|
}
|
|
5397
|
+
async function withStageTimeout(promise, ms, stage) {
|
|
5398
|
+
let timer;
|
|
5399
|
+
try {
|
|
5400
|
+
return await Promise.race([
|
|
5401
|
+
promise,
|
|
5402
|
+
new Promise((_, reject) => {
|
|
5403
|
+
timer = setTimeout(() => reject(new Error(`${stage} timed out after ${ms}ms`)), ms);
|
|
5404
|
+
})
|
|
5405
|
+
]);
|
|
5406
|
+
} finally {
|
|
5407
|
+
if (timer)
|
|
5408
|
+
clearTimeout(timer);
|
|
5409
|
+
}
|
|
5410
|
+
}
|
|
5411
|
+
const PROBE_TIMEOUT_MS = 15000;
|
|
5412
|
+
const PROMPT_VERIFY_TIMEOUT_MS = 12000;
|
|
5413
|
+
async function probeWorkerLiveness(workerSessionID) {
|
|
5414
|
+
if (!workerSessionID)
|
|
5415
|
+
return "dead";
|
|
5416
|
+
try {
|
|
5417
|
+
const status = await withStageTimeout(host.sessionStatus(workerSessionID), PROBE_TIMEOUT_MS, `worker liveness probe for session "${workerSessionID}"`);
|
|
5418
|
+
return status === "unknown" ? "unknown" : "live";
|
|
5419
|
+
} catch {
|
|
5420
|
+
return "unknown";
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
4761
5423
|
async function resumeUnlocked(directory, goalID) {
|
|
5424
|
+
const preState = await readState(directory);
|
|
5425
|
+
const preGoal = preState.goals.find((g) => g.id === goalID);
|
|
5426
|
+
const workerLiveness = await probeWorkerLiveness(preGoal?.workerSessionID);
|
|
4762
5427
|
let resumed = false;
|
|
4763
5428
|
const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
|
|
4764
5429
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
@@ -4766,6 +5431,29 @@ function createGoalService(host) {
|
|
|
4766
5431
|
return state;
|
|
4767
5432
|
if (!canTransition(goal.status, "active", "user"))
|
|
4768
5433
|
return state;
|
|
5434
|
+
const liveness = { isWorkerLive: (_gid, _wsid) => _gid === goalID ? workerLiveness !== "dead" : false, now: Date.now() };
|
|
5435
|
+
sweepStaleWorkspaceState(state, liveness);
|
|
5436
|
+
const rt = state.runtimes.find((r) => r.goalID === goalID);
|
|
5437
|
+
const liveCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID === goalID && !isStaleWorkspaceCall(state, call, liveness));
|
|
5438
|
+
const blocking = liveCalls.filter((call) => call.generation === undefined || !rt || call.generation >= rt.runGeneration);
|
|
5439
|
+
if (blocking.length) {
|
|
5440
|
+
if (workerLiveness === "unknown") {
|
|
5441
|
+
throw new Error(`Worker liveness unknown for session "${goal.workerSessionID}" (probe timed out or unreachable); keeping ${blocking.length} write reservation(s) \u2014 nothing reaped, nothing rotated. ` + `The worker may still be running. Retry resume (a flaky probe recovers on retry); if the worker is gone its quiet reservations reap automatically past the force TTL.`);
|
|
5442
|
+
}
|
|
5443
|
+
throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
|
|
5444
|
+
}
|
|
5445
|
+
if (goal.config.write_scope !== undefined) {
|
|
5446
|
+
retireWorker(state, goal);
|
|
5447
|
+
goal.workerSessionID = undefined;
|
|
5448
|
+
sessions.delete(goalID);
|
|
5449
|
+
if (rt) {
|
|
5450
|
+
rt.runGeneration += 1;
|
|
5451
|
+
rt.updatedAt = new Date().toISOString();
|
|
5452
|
+
}
|
|
5453
|
+
}
|
|
5454
|
+
goal.scopeClosing = false;
|
|
5455
|
+
goal.scopeClearPending = false;
|
|
5456
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
|
|
4769
5457
|
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
4770
5458
|
goal.status = "active";
|
|
4771
5459
|
goal.updatedAt = new Date().toISOString();
|
|
@@ -4788,7 +5476,7 @@ function createGoalService(host) {
|
|
|
4788
5476
|
}
|
|
4789
5477
|
await appendEvent(directory, {
|
|
4790
5478
|
version: 1,
|
|
4791
|
-
eventID:
|
|
5479
|
+
eventID: randomUUID6(),
|
|
4792
5480
|
goalID,
|
|
4793
5481
|
type: "goal.status_changed",
|
|
4794
5482
|
from: "paused",
|
|
@@ -4799,11 +5487,36 @@ function createGoalService(host) {
|
|
|
4799
5487
|
await continueTurnUnlocked(directory, goalID);
|
|
4800
5488
|
}
|
|
4801
5489
|
async function retryUnlocked(directory, goalID) {
|
|
5490
|
+
const preState = await readState(directory);
|
|
5491
|
+
const workerLiveness = await probeWorkerLiveness(preState.goals.find((g) => g.id === goalID)?.workerSessionID);
|
|
4802
5492
|
let retried = false;
|
|
4803
5493
|
const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
|
|
4804
5494
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
4805
5495
|
if (!goal || goal.status !== "blocked")
|
|
4806
5496
|
return state;
|
|
5497
|
+
const liveness = { isWorkerLive: (_gid, _wsid) => _gid === goalID ? workerLiveness !== "dead" : false, now: Date.now() };
|
|
5498
|
+
sweepStaleWorkspaceState(state, liveness);
|
|
5499
|
+
const rt0 = state.runtimes.find((r) => r.goalID === goalID);
|
|
5500
|
+
const blocking = (state.workspaceCalls ?? []).filter((call) => call.goalID === goalID && !isStaleWorkspaceCall(state, call, liveness) && (call.generation === undefined || !rt0 || call.generation >= rt0.runGeneration));
|
|
5501
|
+
if (blocking.length) {
|
|
5502
|
+
if (workerLiveness === "unknown") {
|
|
5503
|
+
throw new Error(`Worker liveness unknown for session "${goal.workerSessionID}" (probe timed out or unreachable); keeping ${blocking.length} write reservation(s) \u2014 nothing reaped, nothing rotated. ` + `The worker may still be running. Retry (a flaky probe recovers on retry); if the worker is gone its quiet reservations reap automatically past the force TTL.`);
|
|
5504
|
+
}
|
|
5505
|
+
throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
|
|
5506
|
+
}
|
|
5507
|
+
if (goal.config.write_scope !== undefined) {
|
|
5508
|
+
retireWorker(state, goal);
|
|
5509
|
+
goal.workerSessionID = undefined;
|
|
5510
|
+
sessions.delete(goalID);
|
|
5511
|
+
const rt = state.runtimes.find((r) => r.goalID === goalID);
|
|
5512
|
+
if (rt) {
|
|
5513
|
+
rt.runGeneration += 1;
|
|
5514
|
+
rt.updatedAt = new Date().toISOString();
|
|
5515
|
+
}
|
|
5516
|
+
}
|
|
5517
|
+
goal.scopeClosing = false;
|
|
5518
|
+
goal.scopeClearPending = false;
|
|
5519
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
|
|
4807
5520
|
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
4808
5521
|
goal.status = "active";
|
|
4809
5522
|
goal.updatedAt = new Date().toISOString();
|
|
@@ -4840,7 +5553,7 @@ function createGoalService(host) {
|
|
|
4840
5553
|
}
|
|
4841
5554
|
await appendEvent(directory, {
|
|
4842
5555
|
version: 1,
|
|
4843
|
-
eventID:
|
|
5556
|
+
eventID: randomUUID6(),
|
|
4844
5557
|
goalID,
|
|
4845
5558
|
type: "goal.status_changed",
|
|
4846
5559
|
from: "blocked",
|
|
@@ -4851,33 +5564,36 @@ function createGoalService(host) {
|
|
|
4851
5564
|
await continueTurnUnlocked(directory, goalID);
|
|
4852
5565
|
}
|
|
4853
5566
|
async function clearUnlocked(directory, goalID) {
|
|
4854
|
-
const
|
|
4855
|
-
const
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
if (session) {
|
|
4864
|
-
await workers.abortWorker(session.workerSessionID);
|
|
4865
|
-
sessions.delete(goalID);
|
|
4866
|
-
}
|
|
4867
|
-
await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
5567
|
+
const pre = await readState(directory);
|
|
5568
|
+
const workerID = sessions.get(goalID)?.workerSessionID || pre.goals.find((g) => g.id === goalID)?.workerSessionID;
|
|
5569
|
+
const state = await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
5570
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
5571
|
+
if (g)
|
|
5572
|
+
retireWorker(s, g);
|
|
5573
|
+
s.workspaceCalls = (s.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
|
|
5574
|
+
if (s.workspaceOperation?.goalID === goalID)
|
|
5575
|
+
s.workspaceOperation = undefined;
|
|
4868
5576
|
s.goals = s.goals.filter((g) => g.id !== goalID);
|
|
4869
5577
|
s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
|
|
4870
5578
|
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
4871
5579
|
return s;
|
|
4872
5580
|
});
|
|
5581
|
+
sessions.delete(goalID);
|
|
5582
|
+
if (workerID) {
|
|
5583
|
+
try {
|
|
5584
|
+
await workers.abortWorker(workerID);
|
|
5585
|
+
} catch {}
|
|
5586
|
+
}
|
|
5587
|
+
await cancelAwaitsForGoal(directory, goalID, "goal cleared").catch(() => {});
|
|
4873
5588
|
await appendEvent(directory, {
|
|
4874
5589
|
version: 1,
|
|
4875
|
-
eventID:
|
|
5590
|
+
eventID: randomUUID6(),
|
|
4876
5591
|
goalID,
|
|
4877
5592
|
type: "goal.cleared",
|
|
4878
5593
|
timestamp: new Date().toISOString(),
|
|
4879
5594
|
revision: state.revision
|
|
4880
5595
|
});
|
|
5596
|
+
await logServerEvent(directory, "goal.cleared-terminal", { goalID }).catch(() => {});
|
|
4881
5597
|
}
|
|
4882
5598
|
function getWorker(goalID) {
|
|
4883
5599
|
return sessions.get(goalID);
|
|
@@ -4886,12 +5602,46 @@ function createGoalService(host) {
|
|
|
4886
5602
|
return new Map(sessions);
|
|
4887
5603
|
}
|
|
4888
5604
|
async function reconcile(directory) {
|
|
5605
|
+
try {
|
|
5606
|
+
let summary = { reapedCalls: [], resetClosing: [], finalizedClears: [], forceCleared: [] };
|
|
5607
|
+
const pre = await readState(directory);
|
|
5608
|
+
if (pre.workspaceCalls?.length || pre.goals.some((g) => g.scopeClosing || g.scopeClearPending)) {
|
|
5609
|
+
await mutateState(directory, "reconcile.sweep-stale-workspace", async (s) => {
|
|
5610
|
+
summary = sweepStaleWorkspaceState(s);
|
|
5611
|
+
return s;
|
|
5612
|
+
});
|
|
5613
|
+
}
|
|
5614
|
+
if (summary.reapedCalls.length || summary.resetClosing.length || summary.finalizedClears.length || summary.forceCleared.length) {
|
|
5615
|
+
await logServerEvent(directory, "reconcile.sweep-stale-workspace", {
|
|
5616
|
+
reapedCalls: summary.reapedCalls,
|
|
5617
|
+
resetClosing: summary.resetClosing,
|
|
5618
|
+
finalizedClears: summary.finalizedClears,
|
|
5619
|
+
forceCleared: summary.forceCleared
|
|
5620
|
+
}).catch(() => {});
|
|
5621
|
+
}
|
|
5622
|
+
} catch {}
|
|
4889
5623
|
const state = await readState(directory);
|
|
4890
5624
|
for (const goal of state.goals) {
|
|
4891
5625
|
if (isTerminal(goal.status))
|
|
4892
5626
|
continue;
|
|
4893
5627
|
if (goal.status === "paused")
|
|
4894
5628
|
continue;
|
|
5629
|
+
if (goal.config.write_scope !== undefined && !host.scopedExecution) {
|
|
5630
|
+
await mutateState(directory, `reconcile.unsupported-scope:${goal.id}`, async (s) => {
|
|
5631
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
5632
|
+
if (g) {
|
|
5633
|
+
g.scopeClosing = true;
|
|
5634
|
+
retireWorker(s, g);
|
|
5635
|
+
g.status = "blocked";
|
|
5636
|
+
g.blocker = { reason: "Host lacks audited scoped execution guard.", needed: "Load guarded v2 execution surface; do not resume scoped goals on v1.", at: new Date().toISOString() };
|
|
5637
|
+
}
|
|
5638
|
+
return s;
|
|
5639
|
+
});
|
|
5640
|
+
if (goal.workerSessionID)
|
|
5641
|
+
await workers.abortWorker(goal.workerSessionID);
|
|
5642
|
+
sessions.delete(goal.id);
|
|
5643
|
+
continue;
|
|
5644
|
+
}
|
|
4895
5645
|
if (!goal.workerSessionID) {
|
|
4896
5646
|
let worker;
|
|
4897
5647
|
try {
|
|
@@ -4967,6 +5717,14 @@ function createGoalService(host) {
|
|
|
4967
5717
|
if (goal.status !== "active") {
|
|
4968
5718
|
return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
|
|
4969
5719
|
}
|
|
5720
|
+
const quotaHold = quotaHoldMessage(goal);
|
|
5721
|
+
if (quotaHold)
|
|
5722
|
+
return { ok: false, message: quotaHold };
|
|
5723
|
+
if (goal.config.write_scope !== undefined) {
|
|
5724
|
+
await pauseUnlocked(directory, goalID);
|
|
5725
|
+
await resumeUnlocked(directory, goalID);
|
|
5726
|
+
return { ok: true, message: `Re-prompted protected worker for "${goal.name}" in a freshly fenced session.` };
|
|
5727
|
+
}
|
|
4970
5728
|
const cleared = await mutateState(directory, `goal.nudge:${goalID}`, async (s) => {
|
|
4971
5729
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
4972
5730
|
if (!rt)
|
|
@@ -4998,6 +5756,10 @@ function createGoalService(host) {
|
|
|
4998
5756
|
if (goal.status !== "active") {
|
|
4999
5757
|
return { ok: true, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
|
|
5000
5758
|
}
|
|
5759
|
+
if (goal.config.write_scope !== undefined) {
|
|
5760
|
+
await continueTurnUnlocked(directory, goalID, { bare: true });
|
|
5761
|
+
return { ok: true, message: `Queued for "${goal.name}"; protected workers receive it on the next quiescent turn.` };
|
|
5762
|
+
}
|
|
5001
5763
|
const cleared = await mutateState(directory, `goal.send:${goalID}`, async (s) => {
|
|
5002
5764
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
5003
5765
|
if (!rt)
|
|
@@ -5043,6 +5805,11 @@ function createGoalService(host) {
|
|
|
5043
5805
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
5044
5806
|
if (!goal)
|
|
5045
5807
|
return { ok: false, message: "Goal not found." };
|
|
5808
|
+
if (goal.config.write_scope !== undefined && goal.status === "active") {
|
|
5809
|
+
await pauseUnlocked(directory, goalID);
|
|
5810
|
+
await resumeUnlocked(directory, goalID);
|
|
5811
|
+
return { ok: true, message: `Protected worker for "${goal.name}" aborted and replaced with a fenced session.` };
|
|
5812
|
+
}
|
|
5046
5813
|
const workerID = sessions.get(goalID)?.workerSessionID || goal.workerSessionID;
|
|
5047
5814
|
if (!workerID)
|
|
5048
5815
|
return { ok: false, message: `Goal "${goal.name}" has no worker session to abort.` };
|
|
@@ -5079,12 +5846,13 @@ function createGoalService(host) {
|
|
|
5079
5846
|
function accountUsage(directory, goalID) {
|
|
5080
5847
|
return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
|
|
5081
5848
|
}
|
|
5082
|
-
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
|
|
5849
|
+
return { scopedExecution: host.scopedExecution === true, start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage, setInteractive, listModels, switchModel, listAgents, switchAgent, switchIdentity, observeProviderError, compact };
|
|
5083
5850
|
}
|
|
5084
5851
|
|
|
5085
5852
|
// src/application/schedule-worker.ts
|
|
5086
5853
|
init_state_repository();
|
|
5087
|
-
import { randomUUID as
|
|
5854
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
5855
|
+
init_workspace_execution();
|
|
5088
5856
|
function createScheduleWorker(options) {
|
|
5089
5857
|
const { directory, goalService } = options;
|
|
5090
5858
|
const intervalMs = options.intervalMs ?? 5000;
|
|
@@ -5128,14 +5896,6 @@ function createScheduleWorker(options) {
|
|
|
5128
5896
|
continue;
|
|
5129
5897
|
if (Date.now() < Date.parse(nextAt))
|
|
5130
5898
|
continue;
|
|
5131
|
-
const activeWriter = state.goals.find((g) => g.id !== goal.id && g.status === "active" && g.config.workspaceWrite);
|
|
5132
|
-
if (goal.config.workspaceWrite && activeWriter) {
|
|
5133
|
-
await logServerEvent(directory, "schedule.skipped-writer-active", {
|
|
5134
|
-
goalID: goal.id,
|
|
5135
|
-
activeWriter: activeWriter.id
|
|
5136
|
-
});
|
|
5137
|
-
continue;
|
|
5138
|
-
}
|
|
5139
5899
|
if (runtime.phase === "running" || runtime.phase === "queued" || runtime.phase === "compacting")
|
|
5140
5900
|
continue;
|
|
5141
5901
|
if (leaseIsValid(runtime))
|
|
@@ -5145,6 +5905,8 @@ function createScheduleWorker(options) {
|
|
|
5145
5905
|
const rt = s.runtimes.find((x) => x.goalID === goal.id);
|
|
5146
5906
|
if (!g || !rt)
|
|
5147
5907
|
return s;
|
|
5908
|
+
if (g.config.write_scope !== undefined && !goalService.scopedExecution)
|
|
5909
|
+
return s;
|
|
5148
5910
|
if (g.status !== "complete")
|
|
5149
5911
|
return s;
|
|
5150
5912
|
const curCount = rt.scheduleRunCount ?? 0;
|
|
@@ -5153,6 +5915,15 @@ function createScheduleWorker(options) {
|
|
|
5153
5915
|
const curNext = rt.nextRunAt;
|
|
5154
5916
|
if (!curNext || Date.now() < Date.parse(curNext))
|
|
5155
5917
|
return s;
|
|
5918
|
+
try {
|
|
5919
|
+
assertScopeAvailable(s, g);
|
|
5920
|
+
} catch {
|
|
5921
|
+
return s;
|
|
5922
|
+
}
|
|
5923
|
+
if (g.config.write_scope !== undefined) {
|
|
5924
|
+
retireWorker(s, g);
|
|
5925
|
+
g.workerSessionID = undefined;
|
|
5926
|
+
}
|
|
5156
5927
|
g.status = "active";
|
|
5157
5928
|
g.updatedAt = new Date().toISOString();
|
|
5158
5929
|
g.blocker = undefined;
|
|
@@ -5172,7 +5943,7 @@ function createScheduleWorker(options) {
|
|
|
5172
5943
|
continue;
|
|
5173
5944
|
await appendEvent(directory, {
|
|
5174
5945
|
version: 1,
|
|
5175
|
-
eventID:
|
|
5946
|
+
eventID: randomUUID7(),
|
|
5176
5947
|
goalID: goal.id,
|
|
5177
5948
|
type: "schedule.tick",
|
|
5178
5949
|
scheduleRunCount: count,
|
|
@@ -5198,7 +5969,7 @@ function createScheduleWorker(options) {
|
|
|
5198
5969
|
}
|
|
5199
5970
|
|
|
5200
5971
|
// src/v2/native-server.ts
|
|
5201
|
-
import { randomUUID as
|
|
5972
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
5202
5973
|
|
|
5203
5974
|
// src/v2/native-bridge.ts
|
|
5204
5975
|
class NativeBridgeError extends Error {
|
|
@@ -5340,7 +6111,7 @@ async function setupNativeServer(context) {
|
|
|
5340
6111
|
bridge,
|
|
5341
6112
|
requestWorker: async (input) => {
|
|
5342
6113
|
const request = {
|
|
5343
|
-
requestID: `req_${
|
|
6114
|
+
requestID: `req_${randomUUID8()}`,
|
|
5344
6115
|
goalID: input.goalID ?? "",
|
|
5345
6116
|
parentSessionID: input.parentSessionID,
|
|
5346
6117
|
title: input.title,
|
|
@@ -5609,9 +6380,9 @@ function createCommandHost(opts) {
|
|
|
5609
6380
|
}
|
|
5610
6381
|
|
|
5611
6382
|
// src/application/command-service.ts
|
|
5612
|
-
import { randomUUID as
|
|
5613
|
-
import { promises as
|
|
5614
|
-
import
|
|
6383
|
+
import { randomUUID as randomUUID9 } from "crypto";
|
|
6384
|
+
import { promises as fs5 } from "fs";
|
|
6385
|
+
import path5 from "path";
|
|
5615
6386
|
|
|
5616
6387
|
// src/domain/command-session.ts
|
|
5617
6388
|
function normalizeTimeoutSeconds(timeoutSeconds) {
|
|
@@ -5865,6 +6636,7 @@ function validateCommandStreamMessage(value) {
|
|
|
5865
6636
|
}
|
|
5866
6637
|
|
|
5867
6638
|
// src/application/command-service.ts
|
|
6639
|
+
init_workspace_execution();
|
|
5868
6640
|
init_command_await2();
|
|
5869
6641
|
init_state_repository();
|
|
5870
6642
|
var ANSI_PATTERN2 = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
|
|
@@ -5899,7 +6671,7 @@ function shellJoin(command, args) {
|
|
|
5899
6671
|
return [command, ...args].map(shellQuote).join(" ");
|
|
5900
6672
|
}
|
|
5901
6673
|
function loopCommandsDir(directory) {
|
|
5902
|
-
return
|
|
6674
|
+
return path5.join(directory, ".opencode", "loopd", "commands");
|
|
5903
6675
|
}
|
|
5904
6676
|
function createCommandService(host, opts) {
|
|
5905
6677
|
const live = new Map;
|
|
@@ -5973,7 +6745,7 @@ function createCommandService(host, opts) {
|
|
|
5973
6745
|
function watchLedger(directory, commandID, type, extra) {
|
|
5974
6746
|
return appendEvent(directory, {
|
|
5975
6747
|
version: 1,
|
|
5976
|
-
eventID:
|
|
6748
|
+
eventID: randomUUID9(),
|
|
5977
6749
|
commandID,
|
|
5978
6750
|
type,
|
|
5979
6751
|
timestamp: new Date().toISOString(),
|
|
@@ -6297,15 +7069,15 @@ function createCommandService(host, opts) {
|
|
|
6297
7069
|
await appendCommandLog(directory, id, chunk);
|
|
6298
7070
|
let retainedBytes = 0;
|
|
6299
7071
|
let truncated = false;
|
|
6300
|
-
const file =
|
|
6301
|
-
const stat = await
|
|
7072
|
+
const file = path5.join(loopCommandsDir(directory), `${id}.log`);
|
|
7073
|
+
const stat = await fs5.stat(file);
|
|
6302
7074
|
retainedBytes = stat.size;
|
|
6303
7075
|
if (stat.size > MAX_COMMAND_OUTPUT_BYTES) {
|
|
6304
|
-
const fh = await
|
|
7076
|
+
const fh = await fs5.open(file, "r");
|
|
6305
7077
|
try {
|
|
6306
7078
|
const buf = Buffer.alloc(MAX_COMMAND_OUTPUT_BYTES);
|
|
6307
7079
|
await fh.read(buf, 0, buf.length, stat.size - buf.length);
|
|
6308
|
-
await
|
|
7080
|
+
await fs5.writeFile(file, buf);
|
|
6309
7081
|
} finally {
|
|
6310
7082
|
await fh.close();
|
|
6311
7083
|
}
|
|
@@ -6450,7 +7222,7 @@ function createCommandService(host, opts) {
|
|
|
6450
7222
|
}).catch(() => {});
|
|
6451
7223
|
await appendEvent(directory, {
|
|
6452
7224
|
version: 1,
|
|
6453
|
-
eventID:
|
|
7225
|
+
eventID: randomUUID9(),
|
|
6454
7226
|
commandID: id,
|
|
6455
7227
|
type: "command.exited",
|
|
6456
7228
|
exitCode: info.exitCode,
|
|
@@ -6495,90 +7267,96 @@ function createCommandService(host, opts) {
|
|
|
6495
7267
|
watchState = initialWatchState();
|
|
6496
7268
|
}
|
|
6497
7269
|
const startedAt = new Date;
|
|
6498
|
-
const id =
|
|
7270
|
+
const id = randomUUID9();
|
|
6499
7271
|
const cwd = input.cwd || directory;
|
|
6500
7272
|
const useShell = input.shell === true;
|
|
6501
7273
|
const spawnOpts = useShell ? { command: "/bin/sh", args: ["-c", shellJoin(command, input.args ?? [])], cwd, cols: input.cols, rows: input.rows, env: input.env } : { command, args: input.args ?? [], cwd, cols: input.cols, rows: input.rows, env: input.env };
|
|
6502
7274
|
const pending = [];
|
|
6503
7275
|
let ready = false;
|
|
6504
|
-
const
|
|
6505
|
-
|
|
6506
|
-
pending.push({ type: "output", chunk });
|
|
6507
|
-
else
|
|
6508
|
-
enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
|
|
6509
|
-
}, (info) => {
|
|
6510
|
-
if (!ready)
|
|
6511
|
-
pending.push({ type: "exit", info });
|
|
6512
|
-
else
|
|
6513
|
-
enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
|
|
6514
|
-
});
|
|
6515
|
-
const session = createCommandSession({
|
|
6516
|
-
id,
|
|
6517
|
-
title,
|
|
6518
|
-
command,
|
|
6519
|
-
args: input.args ?? [],
|
|
6520
|
-
cwd,
|
|
6521
|
-
ownerSessionID: input.ownerSessionID,
|
|
6522
|
-
goalID: input.goalID,
|
|
6523
|
-
notifyOnExit: input.notifyOnExit,
|
|
6524
|
-
notifySuccessfulExit: true,
|
|
6525
|
-
pid: handle.pid,
|
|
6526
|
-
cols: input.cols,
|
|
6527
|
-
rows: input.rows,
|
|
6528
|
-
shell: useShell ? true : undefined,
|
|
6529
|
-
timeoutSeconds,
|
|
6530
|
-
deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
|
|
6531
|
-
envKeys: input.env ? Object.keys(input.env) : undefined,
|
|
6532
|
-
watchFilter,
|
|
6533
|
-
watchUntil,
|
|
6534
|
-
watchIgnoreCase: input.watchIgnoreCase,
|
|
6535
|
-
watchUntilAction: watcher?.untilAction,
|
|
6536
|
-
watchState
|
|
6537
|
-
});
|
|
7276
|
+
const scopeCallID = `command-start:${id}`;
|
|
7277
|
+
await beforeWorkspaceTool(directory, { tool: "loopd_command_start", sessionID: input.ownerSessionID, callID: scopeCallID }, {});
|
|
6538
7278
|
try {
|
|
6539
|
-
|
|
6540
|
-
|
|
6541
|
-
|
|
7279
|
+
const handle = host.spawn(spawnOpts, (chunk) => {
|
|
7280
|
+
if (!ready)
|
|
7281
|
+
pending.push({ type: "output", chunk });
|
|
7282
|
+
else
|
|
7283
|
+
enqueue(id, () => persistOutput(directory, id, chunk)).catch(() => {});
|
|
7284
|
+
}, (info) => {
|
|
7285
|
+
if (!ready)
|
|
7286
|
+
pending.push({ type: "exit", info });
|
|
7287
|
+
else
|
|
7288
|
+
enqueue(id, () => persistExit(directory, id, info)).catch(() => {});
|
|
6542
7289
|
});
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
7290
|
+
const session = createCommandSession({
|
|
7291
|
+
id,
|
|
7292
|
+
title,
|
|
7293
|
+
command,
|
|
7294
|
+
args: input.args ?? [],
|
|
7295
|
+
cwd,
|
|
7296
|
+
ownerSessionID: input.ownerSessionID,
|
|
7297
|
+
goalID: input.goalID,
|
|
7298
|
+
notifyOnExit: input.notifyOnExit,
|
|
7299
|
+
notifySuccessfulExit: true,
|
|
7300
|
+
pid: handle.pid,
|
|
7301
|
+
cols: input.cols,
|
|
7302
|
+
rows: input.rows,
|
|
7303
|
+
shell: useShell ? true : undefined,
|
|
7304
|
+
timeoutSeconds,
|
|
7305
|
+
deadlineAt: timeoutSeconds !== undefined ? computeDeadlineAt(startedAt, timeoutSeconds) : undefined,
|
|
7306
|
+
envKeys: input.env ? Object.keys(input.env) : undefined,
|
|
7307
|
+
watchFilter,
|
|
7308
|
+
watchUntil,
|
|
7309
|
+
watchIgnoreCase: input.watchIgnoreCase,
|
|
7310
|
+
watchUntilAction: watcher?.untilAction,
|
|
7311
|
+
watchState
|
|
7312
|
+
});
|
|
7313
|
+
try {
|
|
7314
|
+
await mutateState(directory, `cmd.start:${id}`, async (s) => {
|
|
7315
|
+
s.commands = [...s.commands ?? [], session];
|
|
7316
|
+
return s;
|
|
7317
|
+
});
|
|
7318
|
+
} catch (error) {
|
|
7319
|
+
handle.terminate();
|
|
7320
|
+
const exited = await Promise.race([
|
|
7321
|
+
handle.exited().then(() => true, () => true),
|
|
7322
|
+
new Promise((resolve) => setTimeout(() => resolve(false), 1000))
|
|
7323
|
+
]);
|
|
7324
|
+
if (!exited && handle.isAlive())
|
|
7325
|
+
handle.kill();
|
|
7326
|
+
throw error;
|
|
7327
|
+
}
|
|
7328
|
+
live.set(id, { handle, buffers: [], bufferedBytes: 0 });
|
|
7329
|
+
if (watcher)
|
|
7330
|
+
watchers.set(id, watcher);
|
|
7331
|
+
if (timeoutSeconds !== undefined)
|
|
7332
|
+
scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
|
|
7333
|
+
rememberDir(id, directory);
|
|
7334
|
+
await appendEvent(directory, {
|
|
7335
|
+
version: 1,
|
|
7336
|
+
eventID: randomUUID9(),
|
|
7337
|
+
...input.goalID ? { goalID: input.goalID } : {},
|
|
7338
|
+
commandID: id,
|
|
7339
|
+
type: "command.started",
|
|
7340
|
+
title,
|
|
7341
|
+
timestamp: new Date().toISOString(),
|
|
7342
|
+
revision: 0
|
|
7343
|
+
}).catch(() => {});
|
|
7344
|
+
try {
|
|
7345
|
+
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
7346
|
+
if (fresh)
|
|
7347
|
+
emitBroker(id, { type: "status", command: fresh });
|
|
7348
|
+
} catch {}
|
|
7349
|
+
const initialEvents = pending.splice(0);
|
|
7350
|
+
ready = true;
|
|
7351
|
+
const initialOperations = [];
|
|
7352
|
+
for (const event of initialEvents) {
|
|
7353
|
+
initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
|
|
7354
|
+
}
|
|
7355
|
+
await Promise.all(initialOperations);
|
|
7356
|
+
return await service.get(directory, id, input.ownerSessionID) ?? session;
|
|
7357
|
+
} finally {
|
|
7358
|
+
await afterWorkspaceTool(directory, input.ownerSessionID, scopeCallID);
|
|
6552
7359
|
}
|
|
6553
|
-
live.set(id, { handle, buffers: [], bufferedBytes: 0 });
|
|
6554
|
-
if (watcher)
|
|
6555
|
-
watchers.set(id, watcher);
|
|
6556
|
-
if (timeoutSeconds !== undefined)
|
|
6557
|
-
scheduleTimeout(directory, id, input.ownerSessionID, timeoutSeconds);
|
|
6558
|
-
rememberDir(id, directory);
|
|
6559
|
-
await appendEvent(directory, {
|
|
6560
|
-
version: 1,
|
|
6561
|
-
eventID: randomUUID8(),
|
|
6562
|
-
...input.goalID ? { goalID: input.goalID } : {},
|
|
6563
|
-
commandID: id,
|
|
6564
|
-
type: "command.started",
|
|
6565
|
-
title,
|
|
6566
|
-
timestamp: new Date().toISOString(),
|
|
6567
|
-
revision: 0
|
|
6568
|
-
}).catch(() => {});
|
|
6569
|
-
try {
|
|
6570
|
-
const fresh = await readState(directory).then((s) => (s.commands ?? []).find((x) => x.id === id));
|
|
6571
|
-
if (fresh)
|
|
6572
|
-
emitBroker(id, { type: "status", command: fresh });
|
|
6573
|
-
} catch {}
|
|
6574
|
-
const initialEvents = pending.splice(0);
|
|
6575
|
-
ready = true;
|
|
6576
|
-
const initialOperations = [];
|
|
6577
|
-
for (const event of initialEvents) {
|
|
6578
|
-
initialOperations.push(event.type === "output" ? enqueue(id, () => persistOutput(directory, id, event.chunk)) : enqueue(id, () => persistExit(directory, id, event.info)));
|
|
6579
|
-
}
|
|
6580
|
-
await Promise.all(initialOperations);
|
|
6581
|
-
return await service.get(directory, id, input.ownerSessionID) ?? session;
|
|
6582
7360
|
},
|
|
6583
7361
|
async list(directory, ownerSessionID) {
|
|
6584
7362
|
const s = await readState(directory);
|
|
@@ -6655,16 +7433,22 @@ function createCommandService(host, opts) {
|
|
|
6655
7433
|
return { session, text: file.text, totalBytes: session.outputBytes, startByte: file.startByte, live: session.status === "running" };
|
|
6656
7434
|
},
|
|
6657
7435
|
async write(directory, id, ownerSessionID, data) {
|
|
6658
|
-
const
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
7436
|
+
const scopeCallID = `command-write:${randomUUID9()}`;
|
|
7437
|
+
await beforeWorkspaceTool(directory, { tool: "loopd_command_write", sessionID: ownerSessionID, callID: scopeCallID }, {});
|
|
7438
|
+
try {
|
|
7439
|
+
const session = await service.get(directory, id, ownerSessionID);
|
|
7440
|
+
if (!session)
|
|
7441
|
+
return { ok: false, message: "Command not found." };
|
|
7442
|
+
if (session.status !== "running")
|
|
7443
|
+
return { ok: false, message: `Command is ${session.status}; only running commands accept input.` };
|
|
7444
|
+
const entry = live.get(id);
|
|
7445
|
+
const ok = entry ? entry.handle.write(data) : false;
|
|
7446
|
+
if (!ok)
|
|
7447
|
+
return { ok: false, message: "Process input unavailable (no live handle \u2014 host may have restarted; reconcile marks it honestly)." };
|
|
7448
|
+
return { ok: true, message: `Sent ${Buffer.byteLength(data)} byte(s) to "${session.title}".` };
|
|
7449
|
+
} finally {
|
|
7450
|
+
await afterWorkspaceTool(directory, ownerSessionID, scopeCallID);
|
|
7451
|
+
}
|
|
6668
7452
|
},
|
|
6669
7453
|
async resize(directory, id, ownerSessionID, cols, rows) {
|
|
6670
7454
|
const session = await service.get(directory, id, ownerSessionID);
|
|
@@ -7051,6 +7835,9 @@ function createCommandService(host, opts) {
|
|
|
7051
7835
|
const x = (s.commands ?? []).find((y) => y.id === c.id);
|
|
7052
7836
|
if (x && x.status === "running") {
|
|
7053
7837
|
x.status = "missing";
|
|
7838
|
+
s.orphanedCommandProcesses ??= [];
|
|
7839
|
+
if (!s.orphanedCommandProcesses.some((process2) => process2.commandID === x.id))
|
|
7840
|
+
s.orphanedCommandProcesses.push({ commandID: x.id, pid: x.pid });
|
|
7054
7841
|
x.endReason = "missing";
|
|
7055
7842
|
x.lastError = deadlinePassed ? `Host restarted or handle lost \u2014 no live execution found (timeoutSeconds=${x.timeoutSeconds ?? "?"}s deadline ${x.deadlineAt} already passed; treated as missing, timeout elapsed). Output log retained; remove to clean up.` : "Host restarted or handle lost \u2014 no live execution found. Output log retained; remove to clean up.";
|
|
7056
7843
|
x.updatedAt = new Date().toISOString();
|
|
@@ -7102,7 +7889,7 @@ function createCommandService(host, opts) {
|
|
|
7102
7889
|
}
|
|
7103
7890
|
|
|
7104
7891
|
// src/application/command-event-broker.ts
|
|
7105
|
-
import { randomUUID as
|
|
7892
|
+
import { randomUUID as randomUUID10 } from "crypto";
|
|
7106
7893
|
function safeDeliver(sink, msg) {
|
|
7107
7894
|
try {
|
|
7108
7895
|
sink(msg);
|
|
@@ -7145,7 +7932,7 @@ function createCommandEventBroker(resolver) {
|
|
|
7145
7932
|
throw new Error("ownerSessionID is required.");
|
|
7146
7933
|
if (typeof sink !== "function")
|
|
7147
7934
|
throw new Error("sink must be a function.");
|
|
7148
|
-
const sinkID =
|
|
7935
|
+
const sinkID = randomUUID10();
|
|
7149
7936
|
const entry = { sinkID, sink, state: "subscribing", buffer: [] };
|
|
7150
7937
|
entriesFor(commandID).set(sinkID, entry);
|
|
7151
7938
|
try {
|
|
@@ -7234,11 +8021,11 @@ function createCommandEventBroker(resolver) {
|
|
|
7234
8021
|
}
|
|
7235
8022
|
|
|
7236
8023
|
// src/server/command-stream-server.ts
|
|
7237
|
-
import { randomBytes, randomUUID as
|
|
7238
|
-
import { promises as
|
|
7239
|
-
import
|
|
8024
|
+
import { randomBytes, randomUUID as randomUUID11, timingSafeEqual } from "crypto";
|
|
8025
|
+
import { promises as fs6 } from "fs";
|
|
8026
|
+
import path6 from "path";
|
|
7240
8027
|
function streamEndpointPath(directory) {
|
|
7241
|
-
return
|
|
8028
|
+
return path6.join(directory, ".opencode", "loopd", "commands", ".stream-endpoint.json");
|
|
7242
8029
|
}
|
|
7243
8030
|
function isPidAlive(pid) {
|
|
7244
8031
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -7268,7 +8055,7 @@ var MAX_BUFFERED_BYTES = 512 * 1024;
|
|
|
7268
8055
|
function createCommandStreamServer(directory, commandService, broker) {
|
|
7269
8056
|
const endpointPath = streamEndpointPath(directory);
|
|
7270
8057
|
const token = randomBytes(32).toString("hex");
|
|
7271
|
-
const generation =
|
|
8058
|
+
const generation = randomUUID11();
|
|
7272
8059
|
const startedAt = new Date().toISOString();
|
|
7273
8060
|
let server;
|
|
7274
8061
|
let serverURL;
|
|
@@ -7484,10 +8271,10 @@ function createCommandStreamServer(directory, commandService, broker) {
|
|
|
7484
8271
|
generation,
|
|
7485
8272
|
startedAt
|
|
7486
8273
|
};
|
|
7487
|
-
await
|
|
7488
|
-
await
|
|
8274
|
+
await fs6.mkdir(path6.dirname(endpointPath), { recursive: true });
|
|
8275
|
+
await fs6.writeFile(endpointPath, JSON.stringify(endpoint, null, 2) + `
|
|
7489
8276
|
`, { mode: 384 });
|
|
7490
|
-
await
|
|
8277
|
+
await fs6.chmod(endpointPath, 384);
|
|
7491
8278
|
}
|
|
7492
8279
|
return {
|
|
7493
8280
|
get url() {
|
|
@@ -7501,7 +8288,7 @@ function createCommandStreamServer(directory, commandService, broker) {
|
|
|
7501
8288
|
return;
|
|
7502
8289
|
started = true;
|
|
7503
8290
|
try {
|
|
7504
|
-
const previous = await
|
|
8291
|
+
const previous = await fs6.readFile(endpointPath, "utf8").then((text) => JSON.parse(text), () => {
|
|
7505
8292
|
return;
|
|
7506
8293
|
});
|
|
7507
8294
|
if (previous && typeof previous.pid === "number" && isPidAlive(previous.pid) && previous.pid !== process.pid) {
|
|
@@ -7571,10 +8358,10 @@ function createCommandStreamServer(directory, commandService, broker) {
|
|
|
7571
8358
|
server = undefined;
|
|
7572
8359
|
serverURL = undefined;
|
|
7573
8360
|
try {
|
|
7574
|
-
const text = await
|
|
8361
|
+
const text = await fs6.readFile(endpointPath, "utf8");
|
|
7575
8362
|
const current = JSON.parse(text);
|
|
7576
8363
|
if (current?.generation === generation) {
|
|
7577
|
-
await
|
|
8364
|
+
await fs6.unlink(endpointPath);
|
|
7578
8365
|
} else {
|
|
7579
8366
|
logServerEvent(directory, "command-stream.keep-endpoint", {
|
|
7580
8367
|
reason: "generation mismatch \u2014 another server owns the file"
|
|
@@ -7588,7 +8375,7 @@ function createCommandStreamServer(directory, commandService, broker) {
|
|
|
7588
8375
|
|
|
7589
8376
|
// src/server/goal-tools.ts
|
|
7590
8377
|
init_state_repository();
|
|
7591
|
-
import { randomUUID as
|
|
8378
|
+
import { randomUUID as randomUUID12 } from "crypto";
|
|
7592
8379
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
7593
8380
|
// src/domain/verification.ts
|
|
7594
8381
|
var MAX_RECENT_ATTEMPTS = 10;
|
|
@@ -7603,11 +8390,57 @@ function appendVerificationAttempt(recent, attempt) {
|
|
|
7603
8390
|
// src/server/goal-tools.ts
|
|
7604
8391
|
import { exec as execChild } from "child_process";
|
|
7605
8392
|
import { promisify } from "util";
|
|
8393
|
+
|
|
8394
|
+
// src/application/goal-scope.ts
|
|
8395
|
+
init_state_repository();
|
|
8396
|
+
init_workspace_scope();
|
|
8397
|
+
init_workspace_execution();
|
|
8398
|
+
|
|
8399
|
+
class GoalScopeConflictError extends Error {
|
|
8400
|
+
conflict;
|
|
8401
|
+
kind;
|
|
8402
|
+
constructor(conflict, kind) {
|
|
8403
|
+
super(scopeConflictMessage(conflict));
|
|
8404
|
+
this.conflict = conflict;
|
|
8405
|
+
this.kind = kind;
|
|
8406
|
+
this.name = "GoalScopeConflictError";
|
|
8407
|
+
}
|
|
8408
|
+
}
|
|
8409
|
+
async function claimGoalScope(directory, goalID, workerSessionID, generation, paths) {
|
|
8410
|
+
let claimed = [];
|
|
8411
|
+
await mutateState(directory, `goal.claim-scope:${goalID}`, async (state) => {
|
|
8412
|
+
const goal = state.goals.find((item) => item.id === goalID);
|
|
8413
|
+
const runtime = state.runtimes.find((item) => item.goalID === goalID);
|
|
8414
|
+
if (!goal || goal.workerSessionID !== workerSessionID || runtime?.runGeneration !== generation) {
|
|
8415
|
+
throw new Error("Scope claim denied: stale worker session or run generation. Read get_goal before claiming.");
|
|
8416
|
+
}
|
|
8417
|
+
if (goal.status !== "active")
|
|
8418
|
+
throw new Error(`Scope claim denied: goal is ${goal.status}, not active.`);
|
|
8419
|
+
if (goal.config.workspaceWrite === false)
|
|
8420
|
+
throw new Error("Read-only/artifact goals cannot claim shared workspace files.");
|
|
8421
|
+
if (goal.config.write_scope === undefined)
|
|
8422
|
+
throw new Error("Legacy whole-workspace goals already own the workspace; explicit scopes must be selected at creation.");
|
|
8423
|
+
const additions = await canonicalWriteScope(directory, paths);
|
|
8424
|
+
claimed = [...new Set([...goal.config.write_scope, ...additions])].sort();
|
|
8425
|
+
const candidate = { ...goal, config: { ...goal.config, write_scope: claimed } };
|
|
8426
|
+
const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), candidate);
|
|
8427
|
+
if (conflict)
|
|
8428
|
+
throw new GoalScopeConflictError(conflict, goal.config.write_scope.length === 0 ? "initial" : "expansion");
|
|
8429
|
+
assertScopeAvailable(state, candidate);
|
|
8430
|
+
goal.config.write_scope = claimed;
|
|
8431
|
+
goal.updatedAt = new Date().toISOString();
|
|
8432
|
+
return state;
|
|
8433
|
+
});
|
|
8434
|
+
return claimed;
|
|
8435
|
+
}
|
|
8436
|
+
|
|
8437
|
+
// src/server/goal-tools.ts
|
|
8438
|
+
init_workspace_execution();
|
|
7606
8439
|
var execAsync = promisify(execChild);
|
|
7607
8440
|
function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
7608
8441
|
return {
|
|
7609
8442
|
loopd_create_goal: tool({
|
|
7610
|
-
description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals
|
|
8443
|
+
description: "Create a new background loop GOAL: an autonomous AI worker that loops turn-by-turn on a multi-step objective until deterministic checks pass or it needs you (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Use this for AI reasoning work spanning multiple turns (implement a feature, fix a failing suite, research and write a report) \u2014 NOT for running a single process you just want to start, watch, and type into. " + "For that (dev servers, `npm test --watch`, REPLs, log tails, one-off scripts, interactive shells with a fullscreen terminal UI), use loopd_command_start instead: it is lighter-weight, has no agent/checks/turn loop, and is a raw OS process, not an AI worker. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Omit both by default \u2014 when omitted, both inherit the CALLING session\'s live agent/model (read at creation), then plugin defaultAgent/defaultModel. ' + "Only pass agent/model when the caller explicitly requests a different identity or the task needs it; explicit values freeze identity and break session upgrades. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals require checks. OMIT write_scope entirely for ordinary exclusive coding goals. [] explicitly opts into scoped exploration, not legacy mode. " + (goalService.scopedExecution ? "CURRENT HOST: enforced file scopes supported. Opt in with exact disjoint write_scope files for parallel editing, or [] to explore then claim_goal_scope. Scoped workers use structured edits and run_goal_checks, not arbitrary shell. " : "CURRENT HOST: enforced file scopes UNSUPPORTED. Do not supply write_scope, including []; ordinary exclusive goals still work. Do not tell legacy workers to call claim_goal_scope. If enforced parallelism was explicitly required, explain the limitation rather than silently downgrade. ") + "Monitor with the /loop dashboard's Goals tab (Tab/h to switch there if Commands is focused).",
|
|
7611
8444
|
args: {
|
|
7612
8445
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
7613
8446
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
@@ -7618,6 +8451,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
7618
8451
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
7619
8452
|
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
7620
8453
|
workspaceWrite: tool.schema.boolean().optional().describe("Whether this goal edits the shared project workspace. Defaults to true; explicitly set false for artifact-only/read-only work."),
|
|
8454
|
+
write_scope: tool.schema.array(tool.schema.string()).optional().describe("Opt-in enforced scopes ONLY when the CURRENT HOST description says supported. Omit the key for ordinary exclusive goals; [] requests scoped exploration and is also rejected on unsupported hosts. Exact workspace-relative files, no globs. Never substitute workspaceWrite:false for coding to bypass protection."),
|
|
7621
8455
|
progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
|
|
7622
8456
|
maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
|
|
7623
8457
|
maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
|
|
@@ -7654,6 +8488,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
7654
8488
|
config.checkCwd = args.checkCwd;
|
|
7655
8489
|
if (args.workspaceWrite !== undefined)
|
|
7656
8490
|
config.workspaceWrite = args.workspaceWrite;
|
|
8491
|
+
if (args.write_scope !== undefined)
|
|
8492
|
+
config.write_scope = args.write_scope;
|
|
7657
8493
|
if (args.progressFile)
|
|
7658
8494
|
config.progressFile = args.progressFile;
|
|
7659
8495
|
if (args.maxTurns !== undefined)
|
|
@@ -7741,6 +8577,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
7741
8577
|
interactive: goal.interactive === true,
|
|
7742
8578
|
checks: resolution.config.checks || [],
|
|
7743
8579
|
workspaceWrite: resolution.config.workspaceWrite,
|
|
8580
|
+
write_scope: goal.config.write_scope,
|
|
8581
|
+
enforcedScopedExecution: goalService.scopedExecution,
|
|
7744
8582
|
defaultsApplied: resolution.defaultsApplied,
|
|
7745
8583
|
name: args.name,
|
|
7746
8584
|
message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>o).`
|
|
@@ -7786,10 +8624,54 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
7786
8624
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
7787
8625
|
return {
|
|
7788
8626
|
title: `Goal: ${goal.name}`,
|
|
7789
|
-
output: formatGoalStructured(goal, runtime)
|
|
8627
|
+
output: formatGoalStructured(goal, runtime, goalService.scopedExecution)
|
|
7790
8628
|
};
|
|
7791
8629
|
}
|
|
7792
8630
|
}),
|
|
8631
|
+
claim_goal_scope: tool({
|
|
8632
|
+
description: "Only for an existing scoped worker on an enforcing host; NOT required for ordinary legacy goals created without write_scope. Atomically add exact files before editing. Conflicts return owner/path without partial acquisition. Coordinate; never sleep or poll claims. Read-only and legacy goals cannot claim. " + (goalService.scopedExecution ? "CURRENT HOST: scoped claims supported." : "CURRENT HOST: scoped claims UNSUPPORTED; create ordinary exclusive goals with write_scope omitted."),
|
|
8633
|
+
args: {
|
|
8634
|
+
paths: tool.schema.array(tool.schema.string()).describe("Exact workspace-relative files to add (no globs)."),
|
|
8635
|
+
runGeneration: tool.schema.number().describe("Current runGeneration from get_goal; stale workers are denied.")
|
|
8636
|
+
},
|
|
8637
|
+
execute: async (args, context) => {
|
|
8638
|
+
if (!goalService.scopedExecution)
|
|
8639
|
+
return { title: "Scope denied", output: JSON.stringify({ ok: false, errorCode: "unsupported_scope_host", message: "Use the guarded v2 execution surface; v1 scoped claims are unsupported." }) };
|
|
8640
|
+
const workerID = context?.sessionID || hostSessionID;
|
|
8641
|
+
const goal = findGoalByWorkerSession(await readState(dir), workerID);
|
|
8642
|
+
if (!goal || !workerID)
|
|
8643
|
+
return { title: "Scope denied", output: JSON.stringify({ ok: false, errorCode: "worker_not_found" }) };
|
|
8644
|
+
try {
|
|
8645
|
+
const write_scope = await claimGoalScope(dir, goal.id, workerID, args.runGeneration, args.paths);
|
|
8646
|
+
return { title: "Scope claimed", output: JSON.stringify({ ok: true, goalID: goal.id, write_scope }) };
|
|
8647
|
+
} catch (error) {
|
|
8648
|
+
return {
|
|
8649
|
+
title: "Scope denied",
|
|
8650
|
+
output: JSON.stringify({
|
|
8651
|
+
ok: false,
|
|
8652
|
+
message: error instanceof Error ? error.message : String(error),
|
|
8653
|
+
...error instanceof GoalScopeConflictError ? { errorCode: "scope_conflict", kind: error.kind, conflict: error.conflict, retry: "coordinate_then_retry" } : { errorCode: "scope_denied" }
|
|
8654
|
+
})
|
|
8655
|
+
};
|
|
8656
|
+
}
|
|
8657
|
+
}
|
|
8658
|
+
}),
|
|
8659
|
+
run_goal_checks: tool({
|
|
8660
|
+
description: "Run only this goal's configured trusted checks under the workspace-wide exclusive operation lock. Source edits are denied while checks run; checks cannot start over in-flight writes. No arbitrary command argument. This is coordination, not an OS/filesystem sandbox. Use instead of shell for scoped verification.",
|
|
8661
|
+
args: {},
|
|
8662
|
+
execute: async (_args, context) => {
|
|
8663
|
+
const workerID = context?.sessionID || hostSessionID;
|
|
8664
|
+
const goal = findGoalByWorkerSession(await readState(dir), workerID);
|
|
8665
|
+
if (!goal || !workerID || goal.status !== "active")
|
|
8666
|
+
return { title: "Verification denied", output: JSON.stringify({ ok: false, message: "An active exact worker session is required." }) };
|
|
8667
|
+
try {
|
|
8668
|
+
const checks = await withWorkspaceOperation(dir, goal.id, workerID, () => runCompletionChecks(goal.config.checks || [], goal.config.checkCwd || dir));
|
|
8669
|
+
return { title: checks.passed ? "Checks passed" : "Checks failed", output: JSON.stringify({ ok: checks.passed, ...checks }) };
|
|
8670
|
+
} catch (error) {
|
|
8671
|
+
return { title: "Verification denied", output: JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) }) };
|
|
8672
|
+
}
|
|
8673
|
+
}
|
|
8674
|
+
}),
|
|
7793
8675
|
report_goal_progress: tool({
|
|
7794
8676
|
description: "Report meaningful progress (resets consecutiveFailures/noProgressCount). Call after durable state changes (file writes, verifications) \u2014 not after thinking. The engine uses this to avoid force-finish.",
|
|
7795
8677
|
args: {
|
|
@@ -7818,10 +8700,10 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
7818
8700
|
next: args.next,
|
|
7819
8701
|
at: new Date().toISOString()
|
|
7820
8702
|
};
|
|
7821
|
-
await
|
|
8703
|
+
state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active"])).revision;
|
|
7822
8704
|
const event = {
|
|
7823
8705
|
version: 1,
|
|
7824
|
-
eventID:
|
|
8706
|
+
eventID: randomUUID12(),
|
|
7825
8707
|
goalID: goal.id,
|
|
7826
8708
|
type: "goal.progress",
|
|
7827
8709
|
summary: args.summary,
|
|
@@ -7859,7 +8741,7 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
|
7859
8741
|
}
|
|
7860
8742
|
if (goal.config.checks?.length) {
|
|
7861
8743
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
7862
|
-
const checkResults = await runCompletionChecks(goal.config.checks, cwd);
|
|
8744
|
+
const checkResults = await withWorkspaceOperation(dir, goal.id, goal.workerSessionID, () => runCompletionChecks(goal.config.checks, cwd));
|
|
7863
8745
|
if (!checkResults.passed) {
|
|
7864
8746
|
const failureDetails = checkResults.failures.map((f) => {
|
|
7865
8747
|
const stdoutSnippet = f.stdout ? `
|
|
@@ -7886,7 +8768,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
|
7886
8768
|
Working directory: ${cwd}
|
|
7887
8769
|
|
|
7888
8770
|
${failureDetails}`;
|
|
7889
|
-
attemptID =
|
|
8771
|
+
attemptID = randomUUID12();
|
|
7890
8772
|
const verificationAttempt = {
|
|
7891
8773
|
id: attemptID,
|
|
7892
8774
|
sequence: runtime.evaluatorRejectionCount,
|
|
@@ -7931,7 +8813,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
7931
8813
|
if (attemptID) {
|
|
7932
8814
|
await appendEvent(dir, {
|
|
7933
8815
|
version: 1,
|
|
7934
|
-
eventID:
|
|
8816
|
+
eventID: randomUUID12(),
|
|
7935
8817
|
goalID: goal.id,
|
|
7936
8818
|
type: "goal.completion_rejected",
|
|
7937
8819
|
attemptID,
|
|
@@ -7949,7 +8831,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
7949
8831
|
if (blocked && rejectedGoal?.blocker) {
|
|
7950
8832
|
await appendEvent(dir, {
|
|
7951
8833
|
version: 1,
|
|
7952
|
-
eventID:
|
|
8834
|
+
eventID: randomUUID12(),
|
|
7953
8835
|
goalID: goal.id,
|
|
7954
8836
|
type: "goal.blocked",
|
|
7955
8837
|
reason: rejectedGoal.blocker.reason,
|
|
@@ -8009,7 +8891,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
8009
8891
|
}
|
|
8010
8892
|
runtime.updatedAt = new Date().toISOString();
|
|
8011
8893
|
}
|
|
8012
|
-
const attemptID =
|
|
8894
|
+
const attemptID = randomUUID12();
|
|
8013
8895
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
8014
8896
|
const checks = (goal.config.checks || []).map((cmd) => ({
|
|
8015
8897
|
command: cmd,
|
|
@@ -8030,10 +8912,10 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
8030
8912
|
runtime.lastVerificationAttempt = verificationAttempt;
|
|
8031
8913
|
runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
|
|
8032
8914
|
}
|
|
8033
|
-
await
|
|
8915
|
+
state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
|
|
8034
8916
|
const event = {
|
|
8035
8917
|
version: 1,
|
|
8036
|
-
eventID:
|
|
8918
|
+
eventID: randomUUID12(),
|
|
8037
8919
|
goalID: goal.id,
|
|
8038
8920
|
type: "goal.completed",
|
|
8039
8921
|
summary: args.summary,
|
|
@@ -8090,10 +8972,10 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
8090
8972
|
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
8091
8973
|
runtime.updatedAt = new Date().toISOString();
|
|
8092
8974
|
}
|
|
8093
|
-
await
|
|
8975
|
+
state.revision = (await persistWorkerMutation(dir, goal, runtime, ["active", "budget_limited"])).revision;
|
|
8094
8976
|
const event = {
|
|
8095
8977
|
version: 1,
|
|
8096
|
-
eventID:
|
|
8978
|
+
eventID: randomUUID12(),
|
|
8097
8979
|
goalID: goal.id,
|
|
8098
8980
|
type: "goal.blocked",
|
|
8099
8981
|
reason: args.reason,
|
|
@@ -8149,7 +9031,7 @@ function findGoalByWorkerSession(state, sessionID) {
|
|
|
8149
9031
|
return;
|
|
8150
9032
|
return state.goals.find((g) => g.workerSessionID === sessionID && (g.status === "active" || g.status === "blocked"));
|
|
8151
9033
|
}
|
|
8152
|
-
function formatGoalStructured(goal, runtime) {
|
|
9034
|
+
function formatGoalStructured(goal, runtime, enforcedScopedExecution = false) {
|
|
8153
9035
|
const output = {
|
|
8154
9036
|
id: goal.id,
|
|
8155
9037
|
name: goal.name,
|
|
@@ -8164,6 +9046,11 @@ function formatGoalStructured(goal, runtime) {
|
|
|
8164
9046
|
checks: goal.config.checks,
|
|
8165
9047
|
checkCwd: goal.config.checkCwd,
|
|
8166
9048
|
workspaceWrite: goal.config.workspaceWrite,
|
|
9049
|
+
write_scope: goal.config.write_scope,
|
|
9050
|
+
scopeClosing: goal.scopeClosing === true,
|
|
9051
|
+
scopeClearPending: goal.scopeClearPending === true,
|
|
9052
|
+
scopeRetryPolicy: "coordinate_then_retry_no_automatic_queue",
|
|
9053
|
+
enforcedScopedExecution,
|
|
8167
9054
|
agent: goal.config.agent,
|
|
8168
9055
|
model: goal.config.model,
|
|
8169
9056
|
maxTurns: goal.config.maxTurns,
|
|
@@ -8214,6 +9101,25 @@ function formatGoalStructured(goal, runtime) {
|
|
|
8214
9101
|
}
|
|
8215
9102
|
return JSON.stringify(output, null, 2);
|
|
8216
9103
|
}
|
|
9104
|
+
async function persistWorkerMutation(dir, goal, runtime, from) {
|
|
9105
|
+
return mutateState(dir, `worker.transition:${goal.id}`, async (state) => {
|
|
9106
|
+
const current = state.goals.find((item) => item.id === goal.id);
|
|
9107
|
+
const rt = state.runtimes.find((item) => item.goalID === goal.id);
|
|
9108
|
+
if (!current || current.workerSessionID !== goal.workerSessionID || !from.includes(current.status) || current.scopeClosing || runtime && rt?.runGeneration !== runtime.runGeneration) {
|
|
9109
|
+
throw new Error("Worker transition denied: stale session/generation or closing goal.");
|
|
9110
|
+
}
|
|
9111
|
+
current.status = goal.status;
|
|
9112
|
+
current.updatedAt = goal.updatedAt;
|
|
9113
|
+
current.lastProgress = goal.lastProgress;
|
|
9114
|
+
current.completionEvidence = goal.completionEvidence;
|
|
9115
|
+
current.blocker = goal.blocker;
|
|
9116
|
+
if (rt && runtime) {
|
|
9117
|
+
const { activeToolCallIDs, accountedMessageIDs, turnTokensUsed, ...transition } = runtime;
|
|
9118
|
+
Object.assign(rt, transition);
|
|
9119
|
+
}
|
|
9120
|
+
return state;
|
|
9121
|
+
});
|
|
9122
|
+
}
|
|
8217
9123
|
async function runCompletionChecks(checks, cwd) {
|
|
8218
9124
|
const failures = [];
|
|
8219
9125
|
for (const cmd of checks) {
|
|
@@ -8273,14 +9179,14 @@ function describeGoalState(status, phase) {
|
|
|
8273
9179
|
}
|
|
8274
9180
|
|
|
8275
9181
|
// src/server/owner-tools.ts
|
|
8276
|
-
import { promises as
|
|
9182
|
+
import { promises as fs7 } from "fs";
|
|
8277
9183
|
function withTimeout2(promise, ms) {
|
|
8278
9184
|
return Promise.race([
|
|
8279
9185
|
promise,
|
|
8280
9186
|
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))
|
|
8281
9187
|
]);
|
|
8282
9188
|
}
|
|
8283
|
-
function
|
|
9189
|
+
function ownerTools2(options) {
|
|
8284
9190
|
const { directory, host, goalService } = options;
|
|
8285
9191
|
return {
|
|
8286
9192
|
loopd_list_models: tool2({
|
|
@@ -8448,7 +9354,7 @@ function ownerTools(options) {
|
|
|
8448
9354
|
if (!dir)
|
|
8449
9355
|
return;
|
|
8450
9356
|
try {
|
|
8451
|
-
const files = await
|
|
9357
|
+
const files = await fs7.readdir(dir);
|
|
8452
9358
|
return files.length ? `${files.length} file(s): ${files.slice(0, 8).join(", ")}` : "no artifacts yet";
|
|
8453
9359
|
} catch {
|
|
8454
9360
|
return "no artifacts yet";
|
|
@@ -8480,6 +9386,10 @@ function ownerTools(options) {
|
|
|
8480
9386
|
checks: goal.config.checks,
|
|
8481
9387
|
checkCwd: goal.config.checkCwd,
|
|
8482
9388
|
workspaceWrite: goal.config.workspaceWrite,
|
|
9389
|
+
write_scope: goal.config.write_scope,
|
|
9390
|
+
scopeClosing: goal.scopeClosing === true,
|
|
9391
|
+
scopeClearPending: goal.scopeClearPending === true,
|
|
9392
|
+
scopeRetryPolicy: "coordinate_then_retry_no_automatic_queue",
|
|
8483
9393
|
agent: goal.config.agent,
|
|
8484
9394
|
model: goal.config.model,
|
|
8485
9395
|
fallbackModels: goal.config.fallbackModels ?? [],
|
|
@@ -8624,7 +9534,7 @@ function ownerTools(options) {
|
|
|
8624
9534
|
}
|
|
8625
9535
|
}),
|
|
8626
9536
|
pause_goal: tool2({
|
|
8627
|
-
description: "Pause an active goal
|
|
9537
|
+
description: "Pause an active goal under its operation mutex. Protected writes are fenced before abort; ownership remains held until in-flight tools drain. Use to investigate or release file claims after quiescence.",
|
|
8628
9538
|
args: {
|
|
8629
9539
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to pause the first active goal.")
|
|
8630
9540
|
},
|
|
@@ -8668,7 +9578,7 @@ function ownerTools(options) {
|
|
|
8668
9578
|
}
|
|
8669
9579
|
}),
|
|
8670
9580
|
resume_goal: tool2({
|
|
8671
|
-
description: "Resume a paused
|
|
9581
|
+
description: "Resume a paused or retry a blocked goal. Atomically reacquires scope; overlapping ownership/in-flight writes deny activation. Explicit scoped workers rotate sessions to fence late tools; legacy workers retain their session. Per-goal mutex.",
|
|
8672
9582
|
args: {
|
|
8673
9583
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to resume the first paused/blocked goal.")
|
|
8674
9584
|
},
|
|
@@ -9236,9 +10146,10 @@ function commandTools(options) {
|
|
|
9236
10146
|
// src/server/plugin.ts
|
|
9237
10147
|
init_state_repository();
|
|
9238
10148
|
// package.json
|
|
9239
|
-
var version = "1.
|
|
10149
|
+
var version = "1.13.0";
|
|
9240
10150
|
|
|
9241
10151
|
// src/server/plugin.ts
|
|
10152
|
+
init_workspace_execution();
|
|
9242
10153
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
9243
10154
|
var server = async ({ client, directory }, pluginOptions) => {
|
|
9244
10155
|
const defaults = parsePluginDefaults(pluginOptions);
|
|
@@ -9341,8 +10252,9 @@ function createServerHooks(directory, host, defaults) {
|
|
|
9341
10252
|
if (type?.startsWith("session."))
|
|
9342
10253
|
reconcileInBackground();
|
|
9343
10254
|
},
|
|
9344
|
-
tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...
|
|
10255
|
+
tool: { ...goalTools(directory, goalService, undefined, defaults, host), ...ownerTools2({ directory, host, goalService }), ...commandTools({ directory, commandService, capabilities: commandHost.capabilities, goalService }) },
|
|
9345
10256
|
"tool.execute.before": async (input, _output) => {
|
|
10257
|
+
await beforeWorkspaceTool(directory, input, _output.args);
|
|
9346
10258
|
const activeWorkers = goalService.getActiveWorkers();
|
|
9347
10259
|
let matchedGoalID;
|
|
9348
10260
|
for (const [goalID, worker] of activeWorkers) {
|
|
@@ -9363,6 +10275,7 @@ function createServerHooks(directory, host, defaults) {
|
|
|
9363
10275
|
} catch {}
|
|
9364
10276
|
},
|
|
9365
10277
|
"tool.execute.after": async (input, output) => {
|
|
10278
|
+
await afterWorkspaceTool(directory, input.sessionID, input.callID);
|
|
9366
10279
|
if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
|
|
9367
10280
|
ensureStarted();
|
|
9368
10281
|
reconcileInBackground();
|
|
@@ -9474,6 +10387,7 @@ var v2 = {
|
|
|
9474
10387
|
for (const [id, definition] of Object.entries(hooks.tool ?? {})) {
|
|
9475
10388
|
editor.add(toV2Tool(id, definition, directory));
|
|
9476
10389
|
}
|
|
10390
|
+
guardV2ToolEditor(directory, editor);
|
|
9477
10391
|
}));
|
|
9478
10392
|
registrations.push(await context.tool.hook("execute.before", async (input) => {
|
|
9479
10393
|
await hooks["tool.execute.before"]?.({
|