@bojackduy/opencode-loopd 1.12.4 → 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/dist/server.js +711 -429
- package/dist/tui.js +24 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1350,6 +1350,377 @@ function scopeConflictMessage(conflict) {
|
|
|
1350
1350
|
}
|
|
1351
1351
|
var init_workspace_scope = () => {};
|
|
1352
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
|
+
|
|
1353
1724
|
// src/server/plugin.ts
|
|
1354
1725
|
import { tool as v1Tool } from "@opencode-ai/plugin/tool";
|
|
1355
1726
|
|
|
@@ -1513,12 +1884,24 @@ function createControlWorker(options) {
|
|
|
1513
1884
|
continue;
|
|
1514
1885
|
processing.add(request.requestID);
|
|
1515
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);
|
|
1516
1904
|
try {
|
|
1517
|
-
const response = await handleRequest(request);
|
|
1518
|
-
await writeControlResponse(directory, response);
|
|
1519
|
-
options.onResponse?.(response);
|
|
1520
|
-
} catch (error) {
|
|
1521
|
-
const detail = describeError(error);
|
|
1522
1905
|
await logServerEvent(directory, "control.request.failed", {
|
|
1523
1906
|
requestID: request.requestID,
|
|
1524
1907
|
command: request.command,
|
|
@@ -1534,9 +1917,7 @@ function createControlWorker(options) {
|
|
|
1534
1917
|
};
|
|
1535
1918
|
await writeControlResponse(directory, response);
|
|
1536
1919
|
options.onResponse?.(response);
|
|
1537
|
-
}
|
|
1538
|
-
processing.delete(request.requestID);
|
|
1539
|
-
}
|
|
1920
|
+
} catch {}
|
|
1540
1921
|
}
|
|
1541
1922
|
}
|
|
1542
1923
|
async function handleRequest(request) {
|
|
@@ -2028,7 +2409,7 @@ function createControlWorker(options) {
|
|
|
2028
2409
|
|
|
2029
2410
|
// src/application/loop-engine.ts
|
|
2030
2411
|
init_state_repository();
|
|
2031
|
-
import { randomUUID as
|
|
2412
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2032
2413
|
|
|
2033
2414
|
// src/domain/goal.ts
|
|
2034
2415
|
var MODEL_TRANSITIONS = {
|
|
@@ -2310,7 +2691,7 @@ function createLoopEngine(options) {
|
|
|
2310
2691
|
if (newlyStamped) {
|
|
2311
2692
|
await appendEvent(directory, {
|
|
2312
2693
|
version: 1,
|
|
2313
|
-
eventID:
|
|
2694
|
+
eventID: randomUUID4(),
|
|
2314
2695
|
goalID,
|
|
2315
2696
|
type: "idle.confirm-failed",
|
|
2316
2697
|
reason: failureReason,
|
|
@@ -2355,7 +2736,7 @@ function createLoopEngine(options) {
|
|
|
2355
2736
|
if (completedRunID) {
|
|
2356
2737
|
await appendEvent(directory, {
|
|
2357
2738
|
version: 1,
|
|
2358
|
-
eventID:
|
|
2739
|
+
eventID: randomUUID4(),
|
|
2359
2740
|
goalID,
|
|
2360
2741
|
type: "run.completed",
|
|
2361
2742
|
runID: completedRunID,
|
|
@@ -2420,7 +2801,7 @@ function createLoopEngine(options) {
|
|
|
2420
2801
|
});
|
|
2421
2802
|
await appendEvent(directory, {
|
|
2422
2803
|
version: 1,
|
|
2423
|
-
eventID:
|
|
2804
|
+
eventID: randomUUID4(),
|
|
2424
2805
|
goalID,
|
|
2425
2806
|
type: "goal.blocked",
|
|
2426
2807
|
reason: limitResult.reason + " (force-finish ignored)",
|
|
@@ -2444,7 +2825,7 @@ function createLoopEngine(options) {
|
|
|
2444
2825
|
});
|
|
2445
2826
|
await appendEvent(directory, {
|
|
2446
2827
|
version: 1,
|
|
2447
|
-
eventID:
|
|
2828
|
+
eventID: randomUUID4(),
|
|
2448
2829
|
goalID,
|
|
2449
2830
|
type: "goal.status_changed",
|
|
2450
2831
|
from: "active",
|
|
@@ -2546,7 +2927,7 @@ function createLoopEngine(options) {
|
|
|
2546
2927
|
});
|
|
2547
2928
|
await appendEvent(directory, {
|
|
2548
2929
|
version: 1,
|
|
2549
|
-
eventID:
|
|
2930
|
+
eventID: randomUUID4(),
|
|
2550
2931
|
goalID: goal.id,
|
|
2551
2932
|
type: "run.failed",
|
|
2552
2933
|
runID: runtime.activeRunID || "unknown",
|
|
@@ -2560,7 +2941,7 @@ function createLoopEngine(options) {
|
|
|
2560
2941
|
if (updatedGoal?.status === "blocked") {
|
|
2561
2942
|
await appendEvent(directory, {
|
|
2562
2943
|
version: 1,
|
|
2563
|
-
eventID:
|
|
2944
|
+
eventID: randomUUID4(),
|
|
2564
2945
|
goalID: goal.id,
|
|
2565
2946
|
type: "goal.blocked",
|
|
2566
2947
|
reason: `Failed ${runtime.consecutiveFailures + 1} times`,
|
|
@@ -2588,7 +2969,7 @@ function createLoopEngine(options) {
|
|
|
2588
2969
|
});
|
|
2589
2970
|
await appendEvent(directory, {
|
|
2590
2971
|
version: 1,
|
|
2591
|
-
eventID:
|
|
2972
|
+
eventID: randomUUID4(),
|
|
2592
2973
|
goalID: goal.id,
|
|
2593
2974
|
type: "compaction.completed",
|
|
2594
2975
|
timestamp: new Date().toISOString(),
|
|
@@ -2658,7 +3039,7 @@ function createLoopEngine(options) {
|
|
|
2658
3039
|
});
|
|
2659
3040
|
await appendEvent(directory, {
|
|
2660
3041
|
version: 1,
|
|
2661
|
-
eventID:
|
|
3042
|
+
eventID: randomUUID4(),
|
|
2662
3043
|
goalID: goal.id,
|
|
2663
3044
|
type: "compaction.started",
|
|
2664
3045
|
timestamp: new Date().toISOString(),
|
|
@@ -2716,7 +3097,7 @@ function createLoopEngine(options) {
|
|
|
2716
3097
|
return false;
|
|
2717
3098
|
await appendEvent(directory, {
|
|
2718
3099
|
version: 1,
|
|
2719
|
-
eventID:
|
|
3100
|
+
eventID: randomUUID4(),
|
|
2720
3101
|
goalID: goal.id,
|
|
2721
3102
|
type: "goal.status_changed",
|
|
2722
3103
|
from: "active",
|
|
@@ -2738,7 +3119,33 @@ function createLoopEngine(options) {
|
|
|
2738
3119
|
function hasExecutionLeak(runtime) {
|
|
2739
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;
|
|
2740
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
|
+
}
|
|
2741
3147
|
async function maintenance() {
|
|
3148
|
+
await sweepStaleWorkspaceOwnership();
|
|
2742
3149
|
syncWorkerSessionsFromService();
|
|
2743
3150
|
if (knownWorkerSessions.size === 0)
|
|
2744
3151
|
return;
|
|
@@ -2893,7 +3300,7 @@ function createLoopEngine(options) {
|
|
|
2893
3300
|
if (recovered) {
|
|
2894
3301
|
await appendEvent(directory, {
|
|
2895
3302
|
version: 1,
|
|
2896
|
-
eventID:
|
|
3303
|
+
eventID: randomUUID4(),
|
|
2897
3304
|
goalID: goal.id,
|
|
2898
3305
|
type: "run.recovered",
|
|
2899
3306
|
runID: stalledRunID ?? "unknown",
|
|
@@ -2927,7 +3334,7 @@ function createLoopEngine(options) {
|
|
|
2927
3334
|
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
2928
3335
|
await appendEvent(directory, {
|
|
2929
3336
|
version: 1,
|
|
2930
|
-
eventID:
|
|
3337
|
+
eventID: randomUUID4(),
|
|
2931
3338
|
goalID: goal.id,
|
|
2932
3339
|
type: "run.stuck",
|
|
2933
3340
|
runID: runtime.activeRunID ?? "unknown",
|
|
@@ -2967,7 +3374,7 @@ function createLoopEngine(options) {
|
|
|
2967
3374
|
});
|
|
2968
3375
|
await appendEvent(directory, {
|
|
2969
3376
|
version: 1,
|
|
2970
|
-
eventID:
|
|
3377
|
+
eventID: randomUUID4(),
|
|
2971
3378
|
goalID: goal.id,
|
|
2972
3379
|
type: "run.stuck",
|
|
2973
3380
|
runID: runtime.activeRunID,
|
|
@@ -2997,7 +3404,7 @@ import * as path4 from "path";
|
|
|
2997
3404
|
import { promises as fs4 } from "fs";
|
|
2998
3405
|
|
|
2999
3406
|
// src/server/host-adapter.ts
|
|
3000
|
-
import { randomUUID as
|
|
3407
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
3001
3408
|
|
|
3002
3409
|
// src/v2/native-rpc.ts
|
|
3003
3410
|
var NATIVE_RPC_ID = "loopd.native";
|
|
@@ -3223,7 +3630,7 @@ function parseModelRef(value) {
|
|
|
3223
3630
|
return { providerID, modelID };
|
|
3224
3631
|
}
|
|
3225
3632
|
function newPromptMessageID() {
|
|
3226
|
-
return `msg_${
|
|
3633
|
+
return `msg_${randomUUID5()}`;
|
|
3227
3634
|
}
|
|
3228
3635
|
function isV2PromptMessageID(messageID) {
|
|
3229
3636
|
return messageID.startsWith("msg_");
|
|
@@ -3332,7 +3739,7 @@ function createRealHost(client, directory) {
|
|
|
3332
3739
|
},
|
|
3333
3740
|
async readSession(sessionID) {
|
|
3334
3741
|
try {
|
|
3335
|
-
const result = await client.session.get({ path: { id: sessionID } });
|
|
3742
|
+
const result = await withTimeout(client.session.get({ path: { id: sessionID } }), 1e4, "OpenCode session.get");
|
|
3336
3743
|
if (result?.error)
|
|
3337
3744
|
return;
|
|
3338
3745
|
const data = result?.data;
|
|
@@ -3356,7 +3763,7 @@ function createRealHost(client, directory) {
|
|
|
3356
3763
|
},
|
|
3357
3764
|
async sessionStatus(sessionID) {
|
|
3358
3765
|
try {
|
|
3359
|
-
const result = await client.session.status({});
|
|
3766
|
+
const result = await withTimeout(client.session.status({}), 1e4, "OpenCode session.status");
|
|
3360
3767
|
if (result?.error)
|
|
3361
3768
|
return "unknown";
|
|
3362
3769
|
const data = result?.data;
|
|
@@ -3379,15 +3786,15 @@ function createRealHost(client, directory) {
|
|
|
3379
3786
|
},
|
|
3380
3787
|
async abortSession(sessionID) {
|
|
3381
3788
|
try {
|
|
3382
|
-
await client.session.abort({ path: { id: sessionID } });
|
|
3789
|
+
await withTimeout(client.session.abort({ path: { id: sessionID } }), 1e4, "OpenCode session.abort");
|
|
3383
3790
|
} catch {}
|
|
3384
3791
|
},
|
|
3385
3792
|
async readMessages(sessionID, limit = 10) {
|
|
3386
3793
|
try {
|
|
3387
|
-
const result = await client.session.messages({
|
|
3794
|
+
const result = await withTimeout(client.session.messages({
|
|
3388
3795
|
path: { id: sessionID },
|
|
3389
3796
|
query: { limit }
|
|
3390
|
-
});
|
|
3797
|
+
}), 1e4, "OpenCode session.messages");
|
|
3391
3798
|
const data = result?.data;
|
|
3392
3799
|
if (!Array.isArray(data))
|
|
3393
3800
|
return [];
|
|
@@ -3423,7 +3830,7 @@ function createRealHost(client, directory) {
|
|
|
3423
3830
|
const identity = model ?? (await this.readSession(sessionID))?.model;
|
|
3424
3831
|
if (!identity || typeof client.session.summarize !== "function")
|
|
3425
3832
|
return;
|
|
3426
|
-
await client.session.summarize({ path: { id: sessionID }, body: identity });
|
|
3833
|
+
await withTimeout(client.session.summarize({ path: { id: sessionID }, body: identity }), 1e4, "OpenCode session.summarize");
|
|
3427
3834
|
} catch {}
|
|
3428
3835
|
},
|
|
3429
3836
|
async notifyOwner(ownerSessionID, message, agent) {
|
|
@@ -3434,7 +3841,7 @@ function createRealHost(client, directory) {
|
|
|
3434
3841
|
let resolvedAgent = agent?.trim() || undefined;
|
|
3435
3842
|
if (!resolvedAgent) {
|
|
3436
3843
|
try {
|
|
3437
|
-
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)");
|
|
3438
3845
|
const liveAgent = sess?.data?.agent;
|
|
3439
3846
|
if (typeof liveAgent === "string" && liveAgent.trim())
|
|
3440
3847
|
resolvedAgent = liveAgent.trim();
|
|
@@ -3484,7 +3891,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3484
3891
|
async switchSessionAgent(sessionID, agent) {
|
|
3485
3892
|
if (typeof context.session.switchAgent !== "function")
|
|
3486
3893
|
return "unsupported";
|
|
3487
|
-
await context.session.switchAgent({ sessionID, agent });
|
|
3894
|
+
await withTimeout(context.session.switchAgent({ sessionID, agent }), 1e4, "OpenCode v2 session.switchAgent");
|
|
3488
3895
|
return "applied";
|
|
3489
3896
|
},
|
|
3490
3897
|
async listModels() {
|
|
@@ -3507,7 +3914,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3507
3914
|
async switchSessionModel(sessionID, model) {
|
|
3508
3915
|
if (typeof context.session.switchModel !== "function")
|
|
3509
3916
|
return "unsupported";
|
|
3510
|
-
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");
|
|
3511
3918
|
return "applied";
|
|
3512
3919
|
},
|
|
3513
3920
|
async createWorker({ parentID, title, agent, model, goalID }) {
|
|
@@ -3532,7 +3939,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3532
3939
|
}
|
|
3533
3940
|
if (outcome.kind === "native-child") {
|
|
3534
3941
|
const childID = outcome.childSessionID;
|
|
3535
|
-
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)");
|
|
3536
3943
|
const actualParent = resolveNativeParentID(child);
|
|
3537
3944
|
if (actualParent !== parentID) {
|
|
3538
3945
|
const detail = `resolved-parent=${JSON.stringify(actualParent)} expected=${JSON.stringify(parentID)}`;
|
|
@@ -3540,16 +3947,16 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3540
3947
|
throw new Error(`loopd native worker creation failed for parent "${parentID}" (parent-mismatch): ${detail}`);
|
|
3541
3948
|
}
|
|
3542
3949
|
if (agent)
|
|
3543
|
-
await context.session.switchAgent({ sessionID: childID, agent });
|
|
3950
|
+
await withTimeout(context.session.switchAgent({ sessionID: childID, agent }), 1e4, "OpenCode v2 session.switchAgent (native re-apply)");
|
|
3544
3951
|
if (model) {
|
|
3545
|
-
await context.session.switchModel({
|
|
3952
|
+
await withTimeout(context.session.switchModel({
|
|
3546
3953
|
sessionID: childID,
|
|
3547
3954
|
model: { id: model.modelID, providerID: model.providerID }
|
|
3548
|
-
});
|
|
3955
|
+
}), 1e4, "OpenCode v2 session.switchModel (native re-apply)");
|
|
3549
3956
|
}
|
|
3550
3957
|
const rename = context.session.update;
|
|
3551
3958
|
if (rename)
|
|
3552
|
-
await rename({ sessionID: childID, title });
|
|
3959
|
+
await withTimeout(rename({ sessionID: childID, title }), 1e4, "OpenCode v2 session.update (native title)");
|
|
3553
3960
|
statuses.set(childID, "idle");
|
|
3554
3961
|
await logServerEvent(directory, "worker.created", {
|
|
3555
3962
|
parentID,
|
|
@@ -3566,13 +3973,13 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3566
3973
|
reason: outcome.kind === "fallback-safe" ? outcome.reason : "unclaimed"
|
|
3567
3974
|
});
|
|
3568
3975
|
}
|
|
3569
|
-
const session = await context.session.create({
|
|
3976
|
+
const session = await withTimeout(context.session.create({
|
|
3570
3977
|
title,
|
|
3571
3978
|
...agent !== undefined ? { agent } : {},
|
|
3572
3979
|
...model !== undefined ? { model: { id: model.modelID, providerID: model.providerID } } : {},
|
|
3573
3980
|
location: { directory },
|
|
3574
3981
|
metadata: { "loopd.parentID": parentID }
|
|
3575
|
-
});
|
|
3982
|
+
}), 1e4, "OpenCode v2 session.create");
|
|
3576
3983
|
statuses.set(session.id, "idle");
|
|
3577
3984
|
await logServerEvent(directory, "worker.created", {
|
|
3578
3985
|
parentID,
|
|
@@ -3584,28 +3991,28 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3584
3991
|
},
|
|
3585
3992
|
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
3586
3993
|
if (agent)
|
|
3587
|
-
await context.session.switchAgent({ sessionID, agent });
|
|
3994
|
+
await withTimeout(context.session.switchAgent({ sessionID, agent }), 1e4, "OpenCode v2 session.switchAgent (prompt)");
|
|
3588
3995
|
if (model) {
|
|
3589
|
-
await context.session.switchModel({
|
|
3996
|
+
await withTimeout(context.session.switchModel({
|
|
3590
3997
|
sessionID,
|
|
3591
3998
|
model: { id: model.modelID, providerID: model.providerID }
|
|
3592
|
-
});
|
|
3999
|
+
}), 1e4, "OpenCode v2 session.switchModel (prompt)");
|
|
3593
4000
|
}
|
|
3594
4001
|
if (messageID !== undefined && !isV2PromptMessageID(messageID)) {
|
|
3595
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.`);
|
|
3596
4003
|
}
|
|
3597
|
-
const result = await context.session.prompt({
|
|
4004
|
+
const result = await withTimeout(context.session.prompt({
|
|
3598
4005
|
sessionID,
|
|
3599
4006
|
id: messageID,
|
|
3600
4007
|
text: prompt
|
|
3601
|
-
});
|
|
4008
|
+
}), 30000, "OpenCode v2 session.prompt (acceptance)");
|
|
3602
4009
|
statuses.set(sessionID, "busy");
|
|
3603
4010
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
3604
4011
|
return { messageID: result?.id };
|
|
3605
4012
|
},
|
|
3606
4013
|
async readSession(sessionID) {
|
|
3607
4014
|
try {
|
|
3608
|
-
const session = await context.session.get({ sessionID });
|
|
4015
|
+
const session = await withTimeout(context.session.get({ sessionID }), 1e4, "OpenCode v2 session.get");
|
|
3609
4016
|
const model = session.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined;
|
|
3610
4017
|
if (!session.agent && !model)
|
|
3611
4018
|
return;
|
|
@@ -3625,7 +4032,7 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3625
4032
|
},
|
|
3626
4033
|
async readMessages(sessionID, limit = 10) {
|
|
3627
4034
|
try {
|
|
3628
|
-
const messages = await context.session.context({ sessionID });
|
|
4035
|
+
const messages = await withTimeout(context.session.context({ sessionID }), 1e4, "OpenCode v2 session.context");
|
|
3629
4036
|
let parentMessageID;
|
|
3630
4037
|
return messages.flatMap((message) => {
|
|
3631
4038
|
if (message.type === "user") {
|
|
@@ -3669,14 +4076,14 @@ function createV2Host(context, statuses, options = {}) {
|
|
|
3669
4076
|
if (model) {
|
|
3670
4077
|
if (typeof context.session.switchModel !== "function")
|
|
3671
4078
|
return;
|
|
3672
|
-
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)");
|
|
3673
4080
|
}
|
|
3674
|
-
await context.session.command({ sessionID, name: "compact", text: "" });
|
|
4081
|
+
await withTimeout(context.session.command({ sessionID, name: "compact", text: "" }), 1e4, "OpenCode v2 session.command (compact)");
|
|
3675
4082
|
} catch {}
|
|
3676
4083
|
},
|
|
3677
4084
|
async notifyOwner(ownerSessionID, message) {
|
|
3678
4085
|
try {
|
|
3679
|
-
await context.session.prompt({ sessionID: ownerSessionID, text: message });
|
|
4086
|
+
await withTimeout(context.session.prompt({ sessionID: ownerSessionID, text: message }), 1e4, "OpenCode v2 parent notify (acceptance)");
|
|
3680
4087
|
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
3681
4088
|
return true;
|
|
3682
4089
|
} catch (error) {
|
|
@@ -3792,349 +4199,107 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
3792
4199
|
``,
|
|
3793
4200
|
`## FILE OWNERSHIP`,
|
|
3794
4201
|
`write_scope: ${JSON.stringify(goal.config.write_scope)} (exact files; [] means exploration-only).`,
|
|
3795
|
-
`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.`,
|
|
3796
|
-
`Claim only the minimum files needed. Request expansion BEFORE editing new paths; rename requires both endpoints. No globs.`,
|
|
3797
|
-
`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.`,
|
|
3798
|
-
`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.`,
|
|
3799
|
-
`Output-location exceptions do not grant extra source files. Only this goal's artifact directory is writable without source claims.`
|
|
3800
|
-
] : goal.config.workspaceWrite === false ? [
|
|
3801
|
-
``,
|
|
3802
|
-
`## READ-ONLY OWNERSHIP`,
|
|
3803
|
-
`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.`
|
|
3804
|
-
] : [];
|
|
3805
|
-
const artifactDir = goal.config.artifactDir;
|
|
3806
|
-
function outputLocationBlock() {
|
|
3807
|
-
if (!artifactDir)
|
|
3808
|
-
return [];
|
|
3809
|
-
return [
|
|
3810
|
-
``,
|
|
3811
|
-
`## OUTPUT LOCATION`,
|
|
3812
|
-
`Write all files, logs, and artifacts under:`,
|
|
3813
|
-
artifactDir,
|
|
3814
|
-
``,
|
|
3815
|
-
`Exception: if the objective explicitly specifies a different output directory, follow the objective instead.`
|
|
3816
|
-
];
|
|
3817
|
-
}
|
|
3818
|
-
if (runtime.runCount <= 1) {
|
|
3819
|
-
parts.push(`You are a worker for an active goal.`, ``, `Call get_goal to read the authoritative objective, acceptance criteria, and current state.`, `Perform one concrete batch of work. After durable verification:`, ``, `- Call report_goal_progress if work remains.`, `- Call complete_goal only if ALL acceptance criteria pass with concrete evidence.`, `- Call block_goal only for a real external blocker requiring user intervention.`, `- Use the built-in question tool when you need clarification only the user can provide.`, ``, `Do not ask questions unnecessarily. Make reasonable assumptions and work directly.`);
|
|
3820
|
-
parts.push(...outputLocationBlock());
|
|
3821
|
-
} else {
|
|
3822
|
-
parts.push(`This is continuation run ${runtime.runCount} for the goal below.`, ``, `## GOAL (user-provided data)`, goal.objective);
|
|
3823
|
-
const progress = context?.progressHistory;
|
|
3824
|
-
if (progress && progress.length > 0) {
|
|
3825
|
-
parts.push(``, `## PROGRESS SO FAR`);
|
|
3826
|
-
for (const p of progress) {
|
|
3827
|
-
parts.push(`- [${p.at.slice(11, 16)}] ${p.summary}`);
|
|
3828
|
-
if (p.next)
|
|
3829
|
-
parts.push(` \u2192 next: ${p.next}`);
|
|
3830
|
-
}
|
|
3831
|
-
}
|
|
3832
|
-
const tail = context?.transcriptTail;
|
|
3833
|
-
if (tail && tail.length > 0) {
|
|
3834
|
-
parts.push(``, `## RECENT WORK (last ${tail.length} messages)`);
|
|
3835
|
-
for (const m of tail) {
|
|
3836
|
-
const snippet = m.content.slice(0, 300).replace(/\n/g, " ");
|
|
3837
|
-
parts.push(`- [${m.role}] ${snippet}`);
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
if (runtime.consecutiveFailures > 0) {
|
|
3841
|
-
parts.push(``, `## WARNINGS`);
|
|
3842
|
-
parts.push(`- ${runtime.consecutiveFailures} consecutive failure(s). Last error: ${runtime.lastError || "unknown"}.`);
|
|
3843
|
-
if (runtime.noProgressCount > 0) {
|
|
3844
|
-
parts.push(`- ${runtime.noProgressCount} turn(s) without progress. Work concretely this turn.`);
|
|
3845
|
-
}
|
|
3846
|
-
}
|
|
3847
|
-
parts.push(...outputLocationBlock());
|
|
3848
|
-
if (context?.verification) {
|
|
3849
|
-
const v = context.verification;
|
|
3850
|
-
if (v.evaluatorRejectionCount && v.evaluatorRejectionCount > 0) {
|
|
3851
|
-
parts.push(``, `## HOST VERDICT: COMPLETION REJECTED`);
|
|
3852
|
-
parts.push(`Rejection #${v.evaluatorRejectionCount}`);
|
|
3853
|
-
if (v.lastRejectionDetails) {
|
|
3854
|
-
parts.push(v.lastRejectionDetails);
|
|
3855
|
-
}
|
|
3856
|
-
parts.push(``, `Required action:`);
|
|
3857
|
-
parts.push(`- Fix the behavior causing the command(s) above to fail.`);
|
|
3858
|
-
parts.push(`- Do NOT merely rewrite the completion evidence.`);
|
|
3859
|
-
parts.push(`- Rerun the command from the stated directory.`);
|
|
3860
|
-
parts.push(`- Call complete_goal only after the command passes.`);
|
|
3861
|
-
} else {
|
|
3862
|
-
parts.push(``, `## VERIFICATION (deterministic pre-screen)`);
|
|
3863
|
-
if (v.checksPassed !== undefined) {
|
|
3864
|
-
if (v.checksPassed)
|
|
3865
|
-
parts.push(`- checks: all passed`);
|
|
3866
|
-
else if (v.failedChecks?.length)
|
|
3867
|
-
parts.push(`- checks FAILED: ${v.failedChecks.join(", ")} \u2014 fix before claiming completion`);
|
|
3868
|
-
else
|
|
3869
|
-
parts.push(`- checks: not yet run`);
|
|
3870
|
-
}
|
|
3871
|
-
if (v.artifactSummary)
|
|
3872
|
-
parts.push(`- artifacts: ${v.artifactSummary}`);
|
|
3873
|
-
}
|
|
3874
|
-
}
|
|
3875
|
-
parts.push(``, `## COMPLETION REVIEW`, `You are the semantic reviewer. The host is the acceptance authority.`, `Before proposing completion:`, `1. Derive concrete requirements from the objective and any referenced files/plans/specs/issues.`, `2. For each requirement, identify authoritative evidence: files, command output, test results.`, `3. Judge each: proves | contradicts | incomplete | missing.`, `4. Only call complete_goal when you have verified every requirement yourself.`, `5. If objective and checks appear contradictory, call block_goal \u2014 do not silently violate either.`);
|
|
3876
|
-
if (context?.forceFinish) {
|
|
3877
|
-
parts.push(``, `## FINAL REPORT REQUIRED \u2014 STOPPING SOON`, `The system requires you to wrap up now. Do NOT start new work.`, `Call complete_goal NOW with:`, `- summary: a specific semantic summary of what was accomplished (files changed, results, key findings)`, `- evidence: concrete proof (commands run, files created, checks passed)`, `If you cannot complete truthfully, call block_goal with the reason \u2014 do not fabricate evidence.`);
|
|
3878
|
-
} else {
|
|
3879
|
-
parts.push(``, `## INSTRUCTIONS`, `1. Inspect current workspace state \u2014 read files, check what exists. Do NOT redo completed work.`, `2. Continue concrete progress toward the objective.`, `3. After completing a batch, call report_goal_progress with what you did and what's next.`, `4. Use the built-in question tool only for genuinely risky ambiguity.`);
|
|
3880
|
-
}
|
|
3881
|
-
}
|
|
3882
|
-
if (context?.inboxMessages && context.inboxMessages.length > 0) {
|
|
3883
|
-
parts.push(``, `## USER INSTRUCTIONS`);
|
|
3884
|
-
for (const msg of context.inboxMessages) {
|
|
3885
|
-
parts.push(`- ${msg}`);
|
|
3886
|
-
}
|
|
3887
|
-
}
|
|
3888
|
-
parts.push(...scopeInstructions);
|
|
3889
|
-
return parts.join(`
|
|
3890
|
-
`);
|
|
3891
|
-
}
|
|
3892
|
-
|
|
3893
|
-
// src/application/workspace-execution.ts
|
|
3894
|
-
init_state_repository();
|
|
3895
|
-
init_workspace_scope();
|
|
3896
|
-
import { randomUUID as randomUUID5 } from "crypto";
|
|
3897
|
-
import path3 from "path";
|
|
3898
|
-
var readTools = new Set([
|
|
3899
|
-
"read",
|
|
3900
|
-
"glob",
|
|
3901
|
-
"grep",
|
|
3902
|
-
"list",
|
|
3903
|
-
"webfetch",
|
|
3904
|
-
"websearch",
|
|
3905
|
-
"todowrite",
|
|
3906
|
-
"skill",
|
|
3907
|
-
"get_goal",
|
|
3908
|
-
"claim_goal_scope",
|
|
3909
|
-
"report_goal_progress",
|
|
3910
|
-
"complete_goal",
|
|
3911
|
-
"block_goal",
|
|
3912
|
-
"list_background_goals",
|
|
3913
|
-
"inspect_background_goal",
|
|
3914
|
-
"read_goal_transcript",
|
|
3915
|
-
"loopd_command_get",
|
|
3916
|
-
"loopd_command_list",
|
|
3917
|
-
"loopd_command_await",
|
|
3918
|
-
"loopd_command_interrupt",
|
|
3919
|
-
"loopd_command_terminate",
|
|
3920
|
-
"loopd_command_remove",
|
|
3921
|
-
"loopd_list_agents",
|
|
3922
|
-
"loopd_list_models",
|
|
3923
|
-
"run_goal_checks",
|
|
3924
|
-
"codegraph_codegraph_context",
|
|
3925
|
-
"codegraph_codegraph_search",
|
|
3926
|
-
"codegraph_codegraph_node",
|
|
3927
|
-
"codegraph_codegraph_explore",
|
|
3928
|
-
"codegraph_codegraph_files",
|
|
3929
|
-
"codegraph_codegraph_trace",
|
|
3930
|
-
"codegraph_codegraph_callers",
|
|
3931
|
-
"codegraph_codegraph_callees",
|
|
3932
|
-
"codegraph_codegraph_impact",
|
|
3933
|
-
"codegraph_codegraph_status"
|
|
3934
|
-
]);
|
|
3935
|
-
var 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"]);
|
|
3936
|
-
function structuredWritePaths(tool, args) {
|
|
3937
|
-
if (tool === "write" || tool === "edit") {
|
|
3938
|
-
if (typeof args.filePath !== "string")
|
|
3939
|
-
throw new Error(`Scope denied: ${tool} requires filePath.`);
|
|
3940
|
-
return [args.filePath];
|
|
3941
|
-
}
|
|
3942
|
-
if (tool !== "apply_patch")
|
|
3943
|
-
return;
|
|
3944
|
-
const source = args.patchText ?? args.patch;
|
|
3945
|
-
if (typeof source !== "string" || !source.startsWith(`*** Begin Patch
|
|
3946
|
-
`) || !source.trimEnd().endsWith("*** End Patch")) {
|
|
3947
|
-
throw new Error("Scope denied: unsupported apply_patch format.");
|
|
3948
|
-
}
|
|
3949
|
-
const paths = [];
|
|
3950
|
-
let section = false;
|
|
3951
|
-
for (const line of source.split(`
|
|
3952
|
-
`)) {
|
|
3953
|
-
const header = /^\*\*\* (?:Add File|Update File|Delete File): (.+)$/.exec(line);
|
|
3954
|
-
if (header) {
|
|
3955
|
-
paths.push(header[1]);
|
|
3956
|
-
section = true;
|
|
3957
|
-
continue;
|
|
3958
|
-
}
|
|
3959
|
-
const move = /^\*\*\* Move to: (.+)$/.exec(line);
|
|
3960
|
-
if (move) {
|
|
3961
|
-
if (!section)
|
|
3962
|
-
throw new Error("Scope denied: rename outside patch section.");
|
|
3963
|
-
paths.push(move[1]);
|
|
3964
|
-
continue;
|
|
3965
|
-
}
|
|
3966
|
-
if (line.startsWith("*** ") && !["*** Begin Patch", "*** End Patch", "*** End of File"].includes(line)) {
|
|
3967
|
-
throw new Error("Scope denied: unknown patch operation.");
|
|
3968
|
-
}
|
|
3969
|
-
}
|
|
3970
|
-
if (!paths.length)
|
|
3971
|
-
throw new Error("Scope denied: patch has no file operations.");
|
|
3972
|
-
return paths;
|
|
3973
|
-
}
|
|
3974
|
-
function heldGoals(state) {
|
|
3975
|
-
return state.goals.filter((goal) => goal.status === "active" || goal.scopeClosing || state.workspaceCalls?.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id);
|
|
3976
|
-
}
|
|
3977
|
-
function mayBeAlive(pid) {
|
|
3978
|
-
if (!pid || pid < 1)
|
|
3979
|
-
return true;
|
|
3980
|
-
try {
|
|
3981
|
-
process.kill(pid, 0);
|
|
3982
|
-
return true;
|
|
3983
|
-
} catch (error) {
|
|
3984
|
-
return error?.code !== "ESRCH";
|
|
3985
|
-
}
|
|
3986
|
-
}
|
|
3987
|
-
function assertScopeAvailable(state, goal) {
|
|
3988
|
-
if (goal.scopeClosing || state.workspaceCalls?.some((call) => call.goalID === goal.id)) {
|
|
3989
|
-
throw new Error("Goal still has closing/in-flight writes; ownership cannot be reacquired before quiescence.");
|
|
3990
|
-
}
|
|
3991
|
-
if (state.workspaceOperation)
|
|
3992
|
-
throw new Error(`Workspace operation ${state.workspaceOperation.id} is active.`);
|
|
3993
|
-
if (goal.config.write_scope !== undefined || goal.config.workspaceWrite === false) {
|
|
3994
|
-
const orphan = [...state.orphanedCommandProcesses || [], ...(state.commands || []).filter((command) => command.status === "missing").map((command) => ({ commandID: command.id, pid: command.pid }))].find((process2) => mayBeAlive(process2.pid));
|
|
3995
|
-
if (orphan)
|
|
3996
|
-
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.`);
|
|
3997
|
-
if (state.workspaceCalls?.some((call) => call.uncontrolled))
|
|
3998
|
-
throw new Error("Protected goal activation denied: an uncontrolled tool/shell is still executing. Wait for its exit event, do not poll claims.");
|
|
3999
|
-
const running = state.commands?.find((command) => command.status === "running");
|
|
4000
|
-
if (running)
|
|
4001
|
-
throw new Error(`Protected goal activation denied: uncontrolled command ${running.id} is still running. Terminate it before starting scoped/read-only work.`);
|
|
4002
|
-
}
|
|
4003
|
-
const conflict = findScopeConflict(heldGoals(state).map((owner) => ({ ...owner, status: "active" })), goal);
|
|
4004
|
-
if (conflict) {
|
|
4005
|
-
const owner = state.goals.find((item) => item.id === conflict.goalID);
|
|
4006
|
-
if (goal.config.write_scope === undefined && owner?.config.write_scope === undefined) {
|
|
4007
|
-
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.`);
|
|
4008
|
-
}
|
|
4009
|
-
throw new Error(scopeConflictMessage(conflict));
|
|
4010
|
-
}
|
|
4011
|
-
if (goal.config.workspaceWrite !== false && goal.config.write_scope?.length !== 0) {
|
|
4012
|
-
const call = state.workspaceCalls?.find((call) => call.goalID !== goal.id && (goal.config.write_scope === undefined || call.paths.some((file) => goal.config.write_scope.includes(file))));
|
|
4013
|
-
if (call)
|
|
4014
|
-
throw new Error(`Scope conflict with in-flight call ${call.callID}, session ${call.sessionID}, path ${call.paths[0]}.`);
|
|
4015
|
-
}
|
|
4016
|
-
}
|
|
4017
|
-
function retireWorker(state, goal) {
|
|
4018
|
-
if (!goal.workerSessionID)
|
|
4019
|
-
return;
|
|
4020
|
-
state.retiredWorkerSessions = [...new Set([...state.retiredWorkerSessions || [], goal.workerSessionID])];
|
|
4021
|
-
}
|
|
4022
|
-
async function beforeWorkspaceTool(directory, input, args) {
|
|
4023
|
-
if (readTools.has(input.tool))
|
|
4024
|
-
return;
|
|
4025
|
-
await mutateState(directory, `workspace.before:${input.callID}`, async (state) => {
|
|
4026
|
-
const goal = state.goals.find((item) => item.workerSessionID === input.sessionID);
|
|
4027
|
-
if (state.retiredWorkerSessions?.includes(input.sessionID))
|
|
4028
|
-
throw new Error("Scope denied: retired worker session; late executions are fenced.");
|
|
4029
|
-
const held = heldGoals(state);
|
|
4030
|
-
const protectedGoals = held.filter((item) => item.config.write_scope !== undefined || item.config.workspaceWrite === false);
|
|
4031
|
-
if (!goal && ownerTools.has(input.tool) && (!protectedGoals.length || state.goals.some((item) => item.ownerSessionID === input.sessionID)))
|
|
4032
|
-
return state;
|
|
4033
|
-
if (!state.workspaceOperation && !protectedGoals.length && (!goal || goal.config.write_scope === undefined && goal.config.workspaceWrite !== false)) {
|
|
4034
|
-
let paths = [];
|
|
4035
|
-
let uncontrolled = false;
|
|
4036
|
-
try {
|
|
4037
|
-
const writes = structuredWritePaths(input.tool, args);
|
|
4038
|
-
if (writes)
|
|
4039
|
-
paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
|
|
4040
|
-
else
|
|
4041
|
-
uncontrolled = true;
|
|
4042
|
-
} catch {
|
|
4043
|
-
uncontrolled = true;
|
|
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
|
+
] : [];
|
|
4212
|
+
const artifactDir = goal.config.artifactDir;
|
|
4213
|
+
function outputLocationBlock() {
|
|
4214
|
+
if (!artifactDir)
|
|
4215
|
+
return [];
|
|
4216
|
+
return [
|
|
4217
|
+
``,
|
|
4218
|
+
`## OUTPUT LOCATION`,
|
|
4219
|
+
`Write all files, logs, and artifacts under:`,
|
|
4220
|
+
artifactDir,
|
|
4221
|
+
``,
|
|
4222
|
+
`Exception: if the objective explicitly specifies a different output directory, follow the objective instead.`
|
|
4223
|
+
];
|
|
4224
|
+
}
|
|
4225
|
+
if (runtime.runCount <= 1) {
|
|
4226
|
+
parts.push(`You are a worker for an active goal.`, ``, `Call get_goal to read the authoritative objective, acceptance criteria, and current state.`, `Perform one concrete batch of work. After durable verification:`, ``, `- Call report_goal_progress if work remains.`, `- Call complete_goal only if ALL acceptance criteria pass with concrete evidence.`, `- Call block_goal only for a real external blocker requiring user intervention.`, `- Use the built-in question tool when you need clarification only the user can provide.`, ``, `Do not ask questions unnecessarily. Make reasonable assumptions and work directly.`);
|
|
4227
|
+
parts.push(...outputLocationBlock());
|
|
4228
|
+
} else {
|
|
4229
|
+
parts.push(`This is continuation run ${runtime.runCount} for the goal below.`, ``, `## GOAL (user-provided data)`, goal.objective);
|
|
4230
|
+
const progress = context?.progressHistory;
|
|
4231
|
+
if (progress && progress.length > 0) {
|
|
4232
|
+
parts.push(``, `## PROGRESS SO FAR`);
|
|
4233
|
+
for (const p of progress) {
|
|
4234
|
+
parts.push(`- [${p.at.slice(11, 16)}] ${p.summary}`);
|
|
4235
|
+
if (p.next)
|
|
4236
|
+
parts.push(` \u2192 next: ${p.next}`);
|
|
4044
4237
|
}
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4238
|
+
}
|
|
4239
|
+
const tail = context?.transcriptTail;
|
|
4240
|
+
if (tail && tail.length > 0) {
|
|
4241
|
+
parts.push(``, `## RECENT WORK (last ${tail.length} messages)`);
|
|
4242
|
+
for (const m of tail) {
|
|
4243
|
+
const snippet = m.content.slice(0, 300).replace(/\n/g, " ");
|
|
4244
|
+
parts.push(`- [${m.role}] ${snippet}`);
|
|
4048
4245
|
}
|
|
4049
|
-
return state;
|
|
4050
4246
|
}
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
throw new Error(`Scope denied: worker goal is ${goal.scopeClosing ? "closing" : goal.status}.`);
|
|
4058
|
-
if (!goal && !state.goals.some((item) => item.ownerSessionID === input.sessionID)) {
|
|
4059
|
-
throw new Error("Scope denied: unrecognized session/child. Subagent writes are unsupported.");
|
|
4247
|
+
if (runtime.consecutiveFailures > 0) {
|
|
4248
|
+
parts.push(``, `## WARNINGS`);
|
|
4249
|
+
parts.push(`- ${runtime.consecutiveFailures} consecutive failure(s). Last error: ${runtime.lastError || "unknown"}.`);
|
|
4250
|
+
if (runtime.noProgressCount > 0) {
|
|
4251
|
+
parts.push(`- ${runtime.noProgressCount} turn(s) without progress. Work concretely this turn.`);
|
|
4252
|
+
}
|
|
4060
4253
|
}
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4254
|
+
parts.push(...outputLocationBlock());
|
|
4255
|
+
if (context?.verification) {
|
|
4256
|
+
const v = context.verification;
|
|
4257
|
+
if (v.evaluatorRejectionCount && v.evaluatorRejectionCount > 0) {
|
|
4258
|
+
parts.push(``, `## HOST VERDICT: COMPLETION REJECTED`);
|
|
4259
|
+
parts.push(`Rejection #${v.evaluatorRejectionCount}`);
|
|
4260
|
+
if (v.lastRejectionDetails) {
|
|
4261
|
+
parts.push(v.lastRejectionDetails);
|
|
4262
|
+
}
|
|
4263
|
+
parts.push(``, `Required action:`);
|
|
4264
|
+
parts.push(`- Fix the behavior causing the command(s) above to fail.`);
|
|
4265
|
+
parts.push(`- Do NOT merely rewrite the completion evidence.`);
|
|
4266
|
+
parts.push(`- Rerun the command from the stated directory.`);
|
|
4267
|
+
parts.push(`- Call complete_goal only after the command passes.`);
|
|
4268
|
+
} else {
|
|
4269
|
+
parts.push(``, `## VERIFICATION (deterministic pre-screen)`);
|
|
4270
|
+
if (v.checksPassed !== undefined) {
|
|
4271
|
+
if (v.checksPassed)
|
|
4272
|
+
parts.push(`- checks: all passed`);
|
|
4273
|
+
else if (v.failedChecks?.length)
|
|
4274
|
+
parts.push(`- checks FAILED: ${v.failedChecks.join(", ")} \u2014 fix before claiming completion`);
|
|
4275
|
+
else
|
|
4276
|
+
parts.push(`- checks: not yet run`);
|
|
4277
|
+
}
|
|
4278
|
+
if (v.artifactSummary)
|
|
4279
|
+
parts.push(`- artifacts: ${v.artifactSummary}`);
|
|
4067
4280
|
}
|
|
4068
|
-
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)));
|
|
4069
|
-
if (owner)
|
|
4070
|
-
throw new Error(scopeConflictMessage({ goalID: owner.id, ownerSessionID: owner.ownerSessionID, name: owner.name, path: file }));
|
|
4071
|
-
const call = state.workspaceCalls?.find((item) => item.paths.includes(file) && !(item.callID === input.callID && item.sessionID === input.sessionID));
|
|
4072
|
-
if (call)
|
|
4073
|
-
throw new Error(`Scope denied: "${file}" is being written by session ${call.sessionID}, call ${call.callID}.`);
|
|
4074
4281
|
}
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4282
|
+
parts.push(``, `## COMPLETION REVIEW`, `You are the semantic reviewer. The host is the acceptance authority.`, `Before proposing completion:`, `1. Derive concrete requirements from the objective and any referenced files/plans/specs/issues.`, `2. For each requirement, identify authoritative evidence: files, command output, test results.`, `3. Judge each: proves | contradicts | incomplete | missing.`, `4. Only call complete_goal when you have verified every requirement yourself.`, `5. If objective and checks appear contradictory, call block_goal \u2014 do not silently violate either.`);
|
|
4283
|
+
if (context?.forceFinish) {
|
|
4284
|
+
parts.push(``, `## FINAL REPORT REQUIRED \u2014 STOPPING SOON`, `The system requires you to wrap up now. Do NOT start new work.`, `Call complete_goal NOW with:`, `- summary: a specific semantic summary of what was accomplished (files changed, results, key findings)`, `- evidence: concrete proof (commands run, files created, checks passed)`, `If you cannot complete truthfully, call block_goal with the reason \u2014 do not fabricate evidence.`);
|
|
4285
|
+
} else {
|
|
4286
|
+
parts.push(``, `## INSTRUCTIONS`, `1. Inspect current workspace state \u2014 read files, check what exists. Do NOT redo completed work.`, `2. Continue concrete progress toward the objective.`, `3. After completing a batch, call report_goal_progress with what you did and what's next.`, `4. Use the built-in question tool only for genuinely risky ambiguity.`);
|
|
4079
4287
|
}
|
|
4080
|
-
return state;
|
|
4081
|
-
});
|
|
4082
|
-
}
|
|
4083
|
-
async function afterWorkspaceTool(directory, sessionID, callID) {
|
|
4084
|
-
await mutateState(directory, `workspace.after:${callID}`, async (state) => {
|
|
4085
|
-
state.workspaceCalls = state.workspaceCalls?.filter((call) => call.sessionID !== sessionID || call.callID !== callID);
|
|
4086
|
-
state.goals = state.goals.filter((goal) => {
|
|
4087
|
-
if (!goal.scopeClearPending || state.workspaceCalls?.some((call) => call.goalID === goal.id) || state.workspaceOperation?.goalID === goal.id)
|
|
4088
|
-
return true;
|
|
4089
|
-
state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
|
|
4090
|
-
return false;
|
|
4091
|
-
});
|
|
4092
|
-
return state;
|
|
4093
|
-
});
|
|
4094
|
-
}
|
|
4095
|
-
function guardV2ToolEditor(directory, editor) {
|
|
4096
|
-
if (typeof editor.list !== "function" || typeof editor.update !== "function")
|
|
4097
|
-
throw new Error("Scoped execution requires the v2 tool editor list/update execution surface.");
|
|
4098
|
-
for (const entry of editor.list()) {
|
|
4099
|
-
editor.update(entry.id, (definition) => {
|
|
4100
|
-
const execute = definition.execute;
|
|
4101
|
-
definition.execute = async (args, context) => {
|
|
4102
|
-
await beforeWorkspaceTool(directory, { tool: entry.name, sessionID: context.sessionID, callID: context.id }, args);
|
|
4103
|
-
try {
|
|
4104
|
-
return await execute(args, context);
|
|
4105
|
-
} finally {
|
|
4106
|
-
await afterWorkspaceTool(directory, context.sessionID, context.id);
|
|
4107
|
-
}
|
|
4108
|
-
};
|
|
4109
|
-
});
|
|
4110
4288
|
}
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
throw new Error("Workspace is busy with an operation or in-flight writes; verification denied until quiescent.");
|
|
4117
|
-
state.workspaceOperation = { id, goalID, sessionID };
|
|
4118
|
-
return state;
|
|
4119
|
-
});
|
|
4120
|
-
try {
|
|
4121
|
-
return await run();
|
|
4122
|
-
} finally {
|
|
4123
|
-
await mutateState(directory, `workspace.operation-end:${id}`, async (state) => {
|
|
4124
|
-
if (state.workspaceOperation?.id === id)
|
|
4125
|
-
state.workspaceOperation = undefined;
|
|
4126
|
-
state.goals = state.goals.filter((goal) => {
|
|
4127
|
-
if (!goal.scopeClearPending || state.workspaceCalls?.some((call) => call.goalID === goal.id))
|
|
4128
|
-
return true;
|
|
4129
|
-
state.runtimes = state.runtimes.filter((runtime) => runtime.goalID !== goal.id);
|
|
4130
|
-
return false;
|
|
4131
|
-
});
|
|
4132
|
-
return state;
|
|
4133
|
-
});
|
|
4289
|
+
if (context?.inboxMessages && context.inboxMessages.length > 0) {
|
|
4290
|
+
parts.push(``, `## USER INSTRUCTIONS`);
|
|
4291
|
+
for (const msg of context.inboxMessages) {
|
|
4292
|
+
parts.push(`- ${msg}`);
|
|
4293
|
+
}
|
|
4134
4294
|
}
|
|
4295
|
+
parts.push(...scopeInstructions);
|
|
4296
|
+
return parts.join(`
|
|
4297
|
+
`);
|
|
4135
4298
|
}
|
|
4136
4299
|
|
|
4137
4300
|
// src/application/goal-service.ts
|
|
4301
|
+
init_workspace_execution();
|
|
4302
|
+
|
|
4138
4303
|
class GoalStartError extends Error {
|
|
4139
4304
|
goalID;
|
|
4140
4305
|
workerSessionID;
|
|
@@ -4639,17 +4804,22 @@ function createGoalService(host) {
|
|
|
4639
4804
|
await host.compactSession(fresh.workerSessionID, parseModelRef(fresh.config.model));
|
|
4640
4805
|
});
|
|
4641
4806
|
}
|
|
4642
|
-
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
4807
|
+
async function recordPromptFailure(directory, goalID, error, blockImmediately = false, expectedRunID) {
|
|
4643
4808
|
const detail = describeError(error);
|
|
4644
4809
|
let runID = "unknown";
|
|
4645
4810
|
let failureCount = 0;
|
|
4646
4811
|
let blocked = false;
|
|
4812
|
+
let superseded = false;
|
|
4647
4813
|
let blockerNeeded = "Retry after the OpenCode worker/session API is available.";
|
|
4648
4814
|
const state = await mutateState(directory, `turn.prompt-failed:${goalID}`, async (s) => {
|
|
4649
4815
|
const goal = s.goals.find((item) => item.id === goalID);
|
|
4650
4816
|
const rt = s.runtimes.find((item) => item.goalID === goalID);
|
|
4651
4817
|
if (!goal || !rt)
|
|
4652
4818
|
return s;
|
|
4819
|
+
if (expectedRunID !== undefined && rt.activeRunID !== expectedRunID) {
|
|
4820
|
+
superseded = true;
|
|
4821
|
+
return s;
|
|
4822
|
+
}
|
|
4653
4823
|
runID = rt.activeRunID || "unknown";
|
|
4654
4824
|
failureCount = rt.consecutiveFailures + 1;
|
|
4655
4825
|
Object.assign(rt, releaseLease(rt));
|
|
@@ -4674,6 +4844,11 @@ function createGoalService(host) {
|
|
|
4674
4844
|
rt.updatedAt = new Date().toISOString();
|
|
4675
4845
|
return s;
|
|
4676
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
|
+
}
|
|
4677
4852
|
await appendEvent(directory, {
|
|
4678
4853
|
version: 1,
|
|
4679
4854
|
eventID: randomUUID6(),
|
|
@@ -4699,6 +4874,41 @@ function createGoalService(host) {
|
|
|
4699
4874
|
}
|
|
4700
4875
|
await observeProviderErrorUnlocked(directory, goalID, error, "prompt-delivery");
|
|
4701
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
|
+
}
|
|
4702
4912
|
async function ensureWorkerSession(directory, goal) {
|
|
4703
4913
|
let session = sessions.get(goal.id);
|
|
4704
4914
|
if (session)
|
|
@@ -4894,9 +5104,8 @@ function createGoalService(host) {
|
|
|
4894
5104
|
revision: state2.revision
|
|
4895
5105
|
});
|
|
4896
5106
|
try {
|
|
4897
|
-
await workers.continueWorker(worker, goal, runtime);
|
|
5107
|
+
await dispatchOrRecordFailure(directory, id, () => workers.continueWorker(worker, goal, runtime), runtime, worker.workerSessionID, true);
|
|
4898
5108
|
} catch (error) {
|
|
4899
|
-
await recordPromptFailure(directory, id, error, true);
|
|
4900
5109
|
throw new GoalStartError(describeError(error), {
|
|
4901
5110
|
goalID: id,
|
|
4902
5111
|
workerSessionID: worker.workerSessionID,
|
|
@@ -4970,8 +5179,11 @@ function createGoalService(host) {
|
|
|
4970
5179
|
return;
|
|
4971
5180
|
if (goal.config.write_scope !== undefined && !host.scopedExecution)
|
|
4972
5181
|
return;
|
|
4973
|
-
|
|
4974
|
-
|
|
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
|
+
}
|
|
4975
5187
|
const runtime = preState.runtimes.find((r) => r.goalID === goalID);
|
|
4976
5188
|
if (!runtime)
|
|
4977
5189
|
return;
|
|
@@ -5046,12 +5258,7 @@ function createGoalService(host) {
|
|
|
5046
5258
|
const bareText = bareWords.join(`
|
|
5047
5259
|
`).trim();
|
|
5048
5260
|
if (bareText) {
|
|
5049
|
-
|
|
5050
|
-
await workers.sendBare(session, freshGoal, freshRuntime, bareText);
|
|
5051
|
-
} catch (error) {
|
|
5052
|
-
await recordPromptFailure(directory, goalID, error);
|
|
5053
|
-
throw error;
|
|
5054
|
-
}
|
|
5261
|
+
await dispatchOrRecordFailure(directory, goalID, () => workers.sendBare(session, freshGoal, freshRuntime, bareText), freshRuntime, session.workerSessionID);
|
|
5055
5262
|
return;
|
|
5056
5263
|
}
|
|
5057
5264
|
}
|
|
@@ -5098,12 +5305,7 @@ function createGoalService(host) {
|
|
|
5098
5305
|
forceFinish: opts?.forceFinish || undefined,
|
|
5099
5306
|
verification
|
|
5100
5307
|
};
|
|
5101
|
-
|
|
5102
|
-
await workers.continueWorker(session, freshGoal, freshRuntime, context);
|
|
5103
|
-
} catch (error) {
|
|
5104
|
-
await recordPromptFailure(directory, goalID, error);
|
|
5105
|
-
throw error;
|
|
5106
|
-
}
|
|
5308
|
+
await dispatchOrRecordFailure(directory, goalID, () => workers.continueWorker(session, freshGoal, freshRuntime, context), freshRuntime, session.workerSessionID);
|
|
5107
5309
|
}
|
|
5108
5310
|
async function setInteractiveUnlocked(directory, goalID, interactive) {
|
|
5109
5311
|
const state = await mutateState(directory, `goal.interactive:${goalID}`, async (s) => {
|
|
@@ -5192,7 +5394,36 @@ function createGoalService(host) {
|
|
|
5192
5394
|
revision: state.revision
|
|
5193
5395
|
});
|
|
5194
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
|
+
}
|
|
5195
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);
|
|
5196
5427
|
let resumed = false;
|
|
5197
5428
|
const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
|
|
5198
5429
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
@@ -5200,12 +5431,30 @@ function createGoalService(host) {
|
|
|
5200
5431
|
return state;
|
|
5201
5432
|
if (!canTransition(goal.status, "active", "user"))
|
|
5202
5433
|
return state;
|
|
5203
|
-
|
|
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
|
+
}
|
|
5204
5445
|
if (goal.config.write_scope !== undefined) {
|
|
5205
5446
|
retireWorker(state, goal);
|
|
5206
5447
|
goal.workerSessionID = undefined;
|
|
5207
5448
|
sessions.delete(goalID);
|
|
5449
|
+
if (rt) {
|
|
5450
|
+
rt.runGeneration += 1;
|
|
5451
|
+
rt.updatedAt = new Date().toISOString();
|
|
5452
|
+
}
|
|
5208
5453
|
}
|
|
5454
|
+
goal.scopeClosing = false;
|
|
5455
|
+
goal.scopeClearPending = false;
|
|
5456
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
|
|
5457
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
5209
5458
|
goal.status = "active";
|
|
5210
5459
|
goal.updatedAt = new Date().toISOString();
|
|
5211
5460
|
resumed = true;
|
|
@@ -5238,17 +5487,37 @@ function createGoalService(host) {
|
|
|
5238
5487
|
await continueTurnUnlocked(directory, goalID);
|
|
5239
5488
|
}
|
|
5240
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);
|
|
5241
5492
|
let retried = false;
|
|
5242
5493
|
const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
|
|
5243
5494
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
5244
5495
|
if (!goal || goal.status !== "blocked")
|
|
5245
5496
|
return state;
|
|
5246
|
-
|
|
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
|
+
}
|
|
5247
5507
|
if (goal.config.write_scope !== undefined) {
|
|
5248
5508
|
retireWorker(state, goal);
|
|
5249
5509
|
goal.workerSessionID = undefined;
|
|
5250
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
|
+
}
|
|
5251
5516
|
}
|
|
5517
|
+
goal.scopeClosing = false;
|
|
5518
|
+
goal.scopeClearPending = false;
|
|
5519
|
+
state.workspaceCalls = (state.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
|
|
5520
|
+
assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
|
|
5252
5521
|
goal.status = "active";
|
|
5253
5522
|
goal.updatedAt = new Date().toISOString();
|
|
5254
5523
|
retried = true;
|
|
@@ -5295,42 +5564,27 @@ function createGoalService(host) {
|
|
|
5295
5564
|
await continueTurnUnlocked(directory, goalID);
|
|
5296
5565
|
}
|
|
5297
5566
|
async function clearUnlocked(directory, goalID) {
|
|
5298
|
-
const
|
|
5299
|
-
const
|
|
5300
|
-
|
|
5301
|
-
return;
|
|
5302
|
-
await mutateState(directory, `goal.clear-closing:${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) => {
|
|
5303
5570
|
const g = s.goals.find((item) => item.id === goalID);
|
|
5304
|
-
if (g)
|
|
5305
|
-
g.scopeClosing = true;
|
|
5571
|
+
if (g)
|
|
5306
5572
|
retireWorker(s, g);
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
const session = sessions.get(goalID) || (goal.workerSessionID ? {
|
|
5311
|
-
goalID: goal.id,
|
|
5312
|
-
workerSessionID: goal.workerSessionID,
|
|
5313
|
-
startedAt: goal.createdAt
|
|
5314
|
-
} : undefined);
|
|
5315
|
-
if (session) {
|
|
5316
|
-
await workers.abortWorker(session.workerSessionID);
|
|
5317
|
-
sessions.delete(goalID);
|
|
5318
|
-
}
|
|
5319
|
-
await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
5320
|
-
if (s.workspaceCalls?.some((call) => call.goalID === goalID) || s.workspaceOperation?.goalID === goalID) {
|
|
5321
|
-
const g = s.goals.find((item) => item.id === goalID);
|
|
5322
|
-
if (g) {
|
|
5323
|
-
g.status = "paused";
|
|
5324
|
-
g.scopeClearPending = true;
|
|
5325
|
-
}
|
|
5326
|
-
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
5327
|
-
return s;
|
|
5328
|
-
}
|
|
5573
|
+
s.workspaceCalls = (s.workspaceCalls ?? []).filter((call) => call.goalID !== goalID);
|
|
5574
|
+
if (s.workspaceOperation?.goalID === goalID)
|
|
5575
|
+
s.workspaceOperation = undefined;
|
|
5329
5576
|
s.goals = s.goals.filter((g) => g.id !== goalID);
|
|
5330
5577
|
s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
|
|
5331
5578
|
s.commandAwaits = (s.commandAwaits ?? []).filter((a) => a.goalID !== goalID);
|
|
5332
5579
|
return s;
|
|
5333
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(() => {});
|
|
5334
5588
|
await appendEvent(directory, {
|
|
5335
5589
|
version: 1,
|
|
5336
5590
|
eventID: randomUUID6(),
|
|
@@ -5339,6 +5593,7 @@ function createGoalService(host) {
|
|
|
5339
5593
|
timestamp: new Date().toISOString(),
|
|
5340
5594
|
revision: state.revision
|
|
5341
5595
|
});
|
|
5596
|
+
await logServerEvent(directory, "goal.cleared-terminal", { goalID }).catch(() => {});
|
|
5342
5597
|
}
|
|
5343
5598
|
function getWorker(goalID) {
|
|
5344
5599
|
return sessions.get(goalID);
|
|
@@ -5347,6 +5602,24 @@ function createGoalService(host) {
|
|
|
5347
5602
|
return new Map(sessions);
|
|
5348
5603
|
}
|
|
5349
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 {}
|
|
5350
5623
|
const state = await readState(directory);
|
|
5351
5624
|
for (const goal of state.goals) {
|
|
5352
5625
|
if (isTerminal(goal.status))
|
|
@@ -5444,6 +5717,9 @@ function createGoalService(host) {
|
|
|
5444
5717
|
if (goal.status !== "active") {
|
|
5445
5718
|
return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
|
|
5446
5719
|
}
|
|
5720
|
+
const quotaHold = quotaHoldMessage(goal);
|
|
5721
|
+
if (quotaHold)
|
|
5722
|
+
return { ok: false, message: quotaHold };
|
|
5447
5723
|
if (goal.config.write_scope !== undefined) {
|
|
5448
5724
|
await pauseUnlocked(directory, goalID);
|
|
5449
5725
|
await resumeUnlocked(directory, goalID);
|
|
@@ -5576,6 +5852,7 @@ function createGoalService(host) {
|
|
|
5576
5852
|
// src/application/schedule-worker.ts
|
|
5577
5853
|
init_state_repository();
|
|
5578
5854
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
5855
|
+
init_workspace_execution();
|
|
5579
5856
|
function createScheduleWorker(options) {
|
|
5580
5857
|
const { directory, goalService } = options;
|
|
5581
5858
|
const intervalMs = options.intervalMs ?? 5000;
|
|
@@ -6359,6 +6636,7 @@ function validateCommandStreamMessage(value) {
|
|
|
6359
6636
|
}
|
|
6360
6637
|
|
|
6361
6638
|
// src/application/command-service.ts
|
|
6639
|
+
init_workspace_execution();
|
|
6362
6640
|
init_command_await2();
|
|
6363
6641
|
init_state_repository();
|
|
6364
6642
|
var ANSI_PATTERN2 = new RegExp("\x1B\\[[0-9;?]*[ -/]*[@-~]|\x1B\\][^\x07]*(?:\x07|\x1B\\\\)|\x1B[()][0-9A-Z]", "g");
|
|
@@ -8116,6 +8394,8 @@ import { promisify } from "util";
|
|
|
8116
8394
|
// src/application/goal-scope.ts
|
|
8117
8395
|
init_state_repository();
|
|
8118
8396
|
init_workspace_scope();
|
|
8397
|
+
init_workspace_execution();
|
|
8398
|
+
|
|
8119
8399
|
class GoalScopeConflictError extends Error {
|
|
8120
8400
|
conflict;
|
|
8121
8401
|
kind;
|
|
@@ -8155,6 +8435,7 @@ async function claimGoalScope(directory, goalID, workerSessionID, generation, pa
|
|
|
8155
8435
|
}
|
|
8156
8436
|
|
|
8157
8437
|
// src/server/goal-tools.ts
|
|
8438
|
+
init_workspace_execution();
|
|
8158
8439
|
var execAsync = promisify(execChild);
|
|
8159
8440
|
function goalTools(dir, goalService, hostSessionID, defaults = {}, host) {
|
|
8160
8441
|
return {
|
|
@@ -9865,9 +10146,10 @@ function commandTools(options) {
|
|
|
9865
10146
|
// src/server/plugin.ts
|
|
9866
10147
|
init_state_repository();
|
|
9867
10148
|
// package.json
|
|
9868
|
-
var version = "1.
|
|
10149
|
+
var version = "1.13.0";
|
|
9869
10150
|
|
|
9870
10151
|
// src/server/plugin.ts
|
|
10152
|
+
init_workspace_execution();
|
|
9871
10153
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
9872
10154
|
var server = async ({ client, directory }, pluginOptions) => {
|
|
9873
10155
|
const defaults = parsePluginDefaults(pluginOptions);
|