@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.
Files changed (3) hide show
  1. package/dist/server.js +711 -429
  2. package/dist/tui.js +24 -1
  3. 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
- } finally {
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 randomUUID3 } from "crypto";
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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: randomUUID3(),
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 randomUUID4 } from "crypto";
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_${randomUUID4()}`;
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
- state.workspaceCalls ??= [];
4046
- if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
4047
- state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, paths, uncontrolled, at: new Date().toISOString() });
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
- const writes = structuredWritePaths(input.tool, args);
4052
- if (!writes)
4053
- 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.`);
4054
- if (state.workspaceOperation)
4055
- throw new Error(`Workspace operation ${state.workspaceOperation.id} is active; source writes are denied until it finishes.`);
4056
- if (goal && (goal.status !== "active" || goal.scopeClosing))
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
- const paths = await Promise.all(writes.map((file) => canonicalScopePath(directory, file)));
4062
- const artifact = goal?.config.artifactDir ? path3.relative(directory, goal.config.artifactDir).split(path3.sep).join("/") : undefined;
4063
- for (const file of paths) {
4064
- const inArtifact = artifact && file.startsWith(`${artifact}/`);
4065
- if (goal && !inArtifact && (goal.config.workspaceWrite === false || goal.config.write_scope !== undefined && !goal.config.write_scope.includes(file))) {
4066
- throw new Error(`Scope denied: "${file}" is outside this goal's write_scope. Claim it before editing.`);
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
- if (!state.workspaceCalls)
4076
- state.workspaceCalls = [];
4077
- if (!state.workspaceCalls.some((call) => call.callID === input.callID && call.sessionID === input.sessionID)) {
4078
- state.workspaceCalls.push({ callID: input.callID, sessionID: input.sessionID, goalID: goal?.id, generation: state.runtimes.find((runtime) => runtime.goalID === goal?.id)?.runGeneration, paths, at: new Date().toISOString() });
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
- async function withWorkspaceOperation(directory, goalID, sessionID, run) {
4113
- const id = randomUUID5();
4114
- await mutateState(directory, `workspace.operation:${id}`, async (state) => {
4115
- if (state.workspaceOperation || state.workspaceCalls?.length)
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
- if (goal.scopeClosing || preState.workspaceCalls?.some((call) => call.goalID === goalID) || preState.workspaceOperation)
4974
- 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
+ }
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
- try {
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
- try {
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
- assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
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
- assertWorkspaceWriteAvailable(state, goal, goal.ownerSessionID);
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 state = await readState(directory);
5299
- const goal = state.goals.find((g) => g.id === goalID);
5300
- if (!goal)
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
- return s;
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.12.4";
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);