@bojackduy/opencode-loopd 1.8.3 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/goal.md +3 -2
- package/dist/server.js +568 -168
- package/dist/tui.js +287 -110
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -11,6 +11,7 @@ function createRuntimeState(goalID) {
|
|
|
11
11
|
noProgressCount: 0,
|
|
12
12
|
progressDuringTurn: false,
|
|
13
13
|
unknownStatusCount: 0,
|
|
14
|
+
accountedMessageIDs: [],
|
|
14
15
|
runGeneration: 0,
|
|
15
16
|
createdAt: now,
|
|
16
17
|
updatedAt: now
|
|
@@ -30,6 +31,9 @@ function acquireLease(rt, timeoutMs) {
|
|
|
30
31
|
lastActivityAt: new Date(now).toISOString(),
|
|
31
32
|
idleCandidateAt: undefined,
|
|
32
33
|
idleCandidateGeneration: undefined,
|
|
34
|
+
idleConfirmFailedAt: undefined,
|
|
35
|
+
idleConfirmFailedGeneration: undefined,
|
|
36
|
+
idleStuckNotifiedGeneration: undefined,
|
|
33
37
|
activePromptObservedAt: undefined,
|
|
34
38
|
activeAssistantMessageID: undefined,
|
|
35
39
|
activeAssistantCompletedAt: undefined,
|
|
@@ -479,6 +483,16 @@ function resolveGoalCreationConfig(input) {
|
|
|
479
483
|
const explicitAgent = cleanText(requested.agent);
|
|
480
484
|
const defaultAgent = cleanText(defaults.defaultAgent);
|
|
481
485
|
const agent = explicitAgent || defaultAgent || undefined;
|
|
486
|
+
const explicitModel = cleanText(requested.model);
|
|
487
|
+
const defaultModel = cleanText(defaults.defaultModel);
|
|
488
|
+
const model = explicitModel || defaultModel || undefined;
|
|
489
|
+
if (model && !isValidModelRef(model)) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
errorCode: "invalid_model",
|
|
493
|
+
message: `Invalid model "${model}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Discover with \`opencode models\`.`
|
|
494
|
+
};
|
|
495
|
+
}
|
|
482
496
|
const workspaceWrite = requested.workspaceWrite ?? true;
|
|
483
497
|
const explicitChecks = cleanList(requested.checks);
|
|
484
498
|
const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks || ["bun test"]) : [];
|
|
@@ -495,16 +509,30 @@ function resolveGoalCreationConfig(input) {
|
|
|
495
509
|
config: {
|
|
496
510
|
...requested,
|
|
497
511
|
agent,
|
|
512
|
+
model,
|
|
498
513
|
workspaceWrite,
|
|
499
514
|
checks: checks.length > 0 ? checks : undefined,
|
|
500
515
|
checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
|
|
501
516
|
},
|
|
502
517
|
defaultsApplied: {
|
|
503
518
|
agent: !explicitAgent && Boolean(defaultAgent),
|
|
519
|
+
model: !explicitModel && Boolean(defaultModel),
|
|
504
520
|
checks: explicitChecks.length === 0 && defaultChecks.length > 0
|
|
505
521
|
}
|
|
506
522
|
};
|
|
507
523
|
}
|
|
524
|
+
function isValidModelRef(value) {
|
|
525
|
+
const slash = value.indexOf("/");
|
|
526
|
+
if (slash <= 0 || slash >= value.length - 1)
|
|
527
|
+
return false;
|
|
528
|
+
const providerID = value.slice(0, slash).trim();
|
|
529
|
+
const modelID = value.slice(slash + 1).trim();
|
|
530
|
+
if (!providerID || !modelID)
|
|
531
|
+
return false;
|
|
532
|
+
if (/\s/.test(providerID) || /\s/.test(modelID))
|
|
533
|
+
return false;
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
508
536
|
function cleanText(value) {
|
|
509
537
|
if (typeof value !== "string")
|
|
510
538
|
return;
|
|
@@ -904,7 +932,7 @@ function isTerminal(status) {
|
|
|
904
932
|
}
|
|
905
933
|
function createGoal(input) {
|
|
906
934
|
const now = new Date().toISOString();
|
|
907
|
-
return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
935
|
+
return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
908
936
|
}
|
|
909
937
|
// src/application/loop-engine.ts
|
|
910
938
|
var CONFIRM_IDLE_DURATION_MS = 2000;
|
|
@@ -921,6 +949,9 @@ function createLoopEngine(options) {
|
|
|
921
949
|
const maintenanceMs = options.pollIntervalMs ?? 30000;
|
|
922
950
|
const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
|
|
923
951
|
const unknownStatusThreshold = Math.max(1, options.unknownStatusThreshold ?? 3);
|
|
952
|
+
const stuckRunningMs = options.stuckRunningMs ?? 10 * 60000;
|
|
953
|
+
const idleUnconfirmedMs = options.idleUnconfirmedMs ?? 5 * 60000;
|
|
954
|
+
const idleRecoverMs = options.idleRecoverMs ?? 3 * 60000;
|
|
924
955
|
let running = false;
|
|
925
956
|
let maintenanceTimer;
|
|
926
957
|
let knownWorkerSessions = new Set;
|
|
@@ -1044,6 +1075,8 @@ function createLoopEngine(options) {
|
|
|
1044
1075
|
} else {
|
|
1045
1076
|
const part = event.properties?.part;
|
|
1046
1077
|
if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
|
|
1078
|
+
if (rt.activeAssistantCompletedAt)
|
|
1079
|
+
return s;
|
|
1047
1080
|
Object.assign(rt, recordActivity(rt));
|
|
1048
1081
|
matched = true;
|
|
1049
1082
|
}
|
|
@@ -1083,7 +1116,9 @@ function createLoopEngine(options) {
|
|
|
1083
1116
|
const elapsed = now - Date.parse(rt.idleCandidateAt);
|
|
1084
1117
|
if (elapsed < confirmIdleMs)
|
|
1085
1118
|
return s;
|
|
1086
|
-
|
|
1119
|
+
const anchoredHere = Boolean(rt.activeAssistantCompletedAt);
|
|
1120
|
+
const quietSince = anchoredHere && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : rt.idleCandidateAt;
|
|
1121
|
+
if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt && rt.lastActivityAt > quietSince) {
|
|
1087
1122
|
rt.idleCandidateAt = undefined;
|
|
1088
1123
|
rt.idleCandidateGeneration = undefined;
|
|
1089
1124
|
return s;
|
|
@@ -1105,8 +1140,35 @@ function createLoopEngine(options) {
|
|
|
1105
1140
|
});
|
|
1106
1141
|
if (confirmation) {
|
|
1107
1142
|
const candidate = confirmation;
|
|
1143
|
+
const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
|
|
1144
|
+
const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
|
|
1108
1145
|
const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
|
|
1109
|
-
|
|
1146
|
+
const failureReason = !transcript.latestUserPrompt && !eventAnchored ? "prompt-outside-window" : !eventAnchored && !candidate.assistantCompleted && !transcript.assistantCompleted ? "assistant-incomplete" : undefined;
|
|
1147
|
+
if (failureReason) {
|
|
1148
|
+
let newlyStamped = false;
|
|
1149
|
+
await mutateState(directory, `idle.confirm-stamp:${goalID}`, async (s) => {
|
|
1150
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1151
|
+
if (!rt || rt.runGeneration !== candidate.generation)
|
|
1152
|
+
return s;
|
|
1153
|
+
if (rt.idleConfirmFailedGeneration !== candidate.generation) {
|
|
1154
|
+
rt.idleConfirmFailedAt = new Date().toISOString();
|
|
1155
|
+
rt.idleConfirmFailedGeneration = candidate.generation;
|
|
1156
|
+
newlyStamped = true;
|
|
1157
|
+
}
|
|
1158
|
+
return s;
|
|
1159
|
+
});
|
|
1160
|
+
if (newlyStamped) {
|
|
1161
|
+
await appendEvent(directory, {
|
|
1162
|
+
version: 1,
|
|
1163
|
+
eventID: randomUUID2(),
|
|
1164
|
+
goalID,
|
|
1165
|
+
type: "idle.confirm-failed",
|
|
1166
|
+
reason: failureReason,
|
|
1167
|
+
runGeneration: candidate.generation,
|
|
1168
|
+
timestamp: new Date().toISOString(),
|
|
1169
|
+
revision: afterIdle.revision
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1110
1172
|
return true;
|
|
1111
1173
|
}
|
|
1112
1174
|
afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
|
|
@@ -1124,14 +1186,19 @@ function createLoopEngine(options) {
|
|
|
1124
1186
|
return s;
|
|
1125
1187
|
if (rt.idleCandidateAt !== candidate.candidateAt)
|
|
1126
1188
|
return s;
|
|
1127
|
-
if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
|
|
1128
|
-
|
|
1189
|
+
if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt) {
|
|
1190
|
+
const quiet = rt.runGeneration === candidate.generation && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : candidate.candidateAt;
|
|
1191
|
+
if (rt.lastActivityAt > quiet)
|
|
1192
|
+
return s;
|
|
1193
|
+
}
|
|
1129
1194
|
if ((rt.activeToolCallIDs?.length ?? 0) > 0)
|
|
1130
1195
|
return s;
|
|
1131
1196
|
completedRunID = rt.activeRunID;
|
|
1132
1197
|
Object.assign(rt, releaseLease(rt));
|
|
1133
1198
|
rt.activeRunID = undefined;
|
|
1134
1199
|
rt.lastWorkerStatus = "idle";
|
|
1200
|
+
rt.idleConfirmFailedAt = undefined;
|
|
1201
|
+
rt.idleConfirmFailedGeneration = undefined;
|
|
1135
1202
|
return s;
|
|
1136
1203
|
});
|
|
1137
1204
|
}
|
|
@@ -1174,6 +1241,12 @@ function createLoopEngine(options) {
|
|
|
1174
1241
|
return true;
|
|
1175
1242
|
recentForceFinishBlocked.set(blockedKey, nowBlocked);
|
|
1176
1243
|
let shouldNotifyBlocked = false;
|
|
1244
|
+
await goalService.accountUsage(directory, goalID).catch(() => ({
|
|
1245
|
+
tokenDelta: 0,
|
|
1246
|
+
costDelta: 0,
|
|
1247
|
+
timeDeltaSeconds: 0,
|
|
1248
|
+
counted: []
|
|
1249
|
+
}));
|
|
1177
1250
|
const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
|
|
1178
1251
|
const g = s.goals.find((item) => item.id === goalID);
|
|
1179
1252
|
if (!g)
|
|
@@ -1244,7 +1317,7 @@ function createLoopEngine(options) {
|
|
|
1244
1317
|
return noMatch;
|
|
1245
1318
|
let messages;
|
|
1246
1319
|
try {
|
|
1247
|
-
messages = await host.readMessages(workerSessionID,
|
|
1320
|
+
messages = await host.readMessages(workerSessionID, 200);
|
|
1248
1321
|
} catch {
|
|
1249
1322
|
return noMatch;
|
|
1250
1323
|
}
|
|
@@ -1402,6 +1475,16 @@ function createLoopEngine(options) {
|
|
|
1402
1475
|
reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
|
|
1403
1476
|
};
|
|
1404
1477
|
}
|
|
1478
|
+
if (typeof goal.costBudget === "number" && (goal.costUsed ?? 0) >= goal.costBudget) {
|
|
1479
|
+
goal.status = "budget_limited";
|
|
1480
|
+
goal.updatedAt = new Date().toISOString();
|
|
1481
|
+
return {
|
|
1482
|
+
stop: "budget",
|
|
1483
|
+
blocked: true,
|
|
1484
|
+
event: "goal.status_changed",
|
|
1485
|
+
reason: `Cost budget exhausted ($${(goal.costUsed ?? 0).toFixed(4)}/$${goal.costBudget})`
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1405
1488
|
return noResult;
|
|
1406
1489
|
}
|
|
1407
1490
|
function shouldCompact(goal, runtime) {
|
|
@@ -1440,6 +1523,67 @@ function createLoopEngine(options) {
|
|
|
1440
1523
|
return s;
|
|
1441
1524
|
});
|
|
1442
1525
|
}
|
|
1526
|
+
async function accountAndEnforceBudget(goal) {
|
|
1527
|
+
try {
|
|
1528
|
+
await goalService.accountUsage(directory, goal.id);
|
|
1529
|
+
} catch {}
|
|
1530
|
+
const fresh = await readState(directory);
|
|
1531
|
+
const g = fresh.goals.find((item) => item.id === goal.id);
|
|
1532
|
+
if (!g || g.status !== "active")
|
|
1533
|
+
return false;
|
|
1534
|
+
const overTokens = typeof g.tokenBudget === "number" && g.tokensUsed >= g.tokenBudget;
|
|
1535
|
+
const overCost = typeof g.costBudget === "number" && (g.costUsed ?? 0) >= g.costBudget;
|
|
1536
|
+
if (!overTokens && !overCost)
|
|
1537
|
+
return false;
|
|
1538
|
+
const reason = overCost ? `Cost budget exhausted ($${(g.costUsed ?? 0).toFixed(4)}/$${g.costBudget})` : `Token budget exhausted (${g.tokensUsed}/${g.tokenBudget})`;
|
|
1539
|
+
if (g.workerSessionID) {
|
|
1540
|
+
try {
|
|
1541
|
+
await host.abortSession(g.workerSessionID);
|
|
1542
|
+
} catch {}
|
|
1543
|
+
}
|
|
1544
|
+
let shouldNotify = false;
|
|
1545
|
+
const stoppedState = await mutateState(directory, `maintenance.budget:${goal.id}`, async (s) => {
|
|
1546
|
+
const target = s.goals.find((item) => item.id === goal.id);
|
|
1547
|
+
if (!target || target.status !== "active")
|
|
1548
|
+
return s;
|
|
1549
|
+
target.status = "budget_limited";
|
|
1550
|
+
target.updatedAt = new Date().toISOString();
|
|
1551
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1552
|
+
if (rt) {
|
|
1553
|
+
Object.assign(rt, releaseLease(rt));
|
|
1554
|
+
rt.activeRunID = undefined;
|
|
1555
|
+
rt.updatedAt = new Date().toISOString();
|
|
1556
|
+
if (shouldNotifyParent(rt, "stopped")) {
|
|
1557
|
+
markParentNotified(rt, "stopped");
|
|
1558
|
+
shouldNotify = true;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return s;
|
|
1562
|
+
});
|
|
1563
|
+
const stoppedGoal = stoppedState.goals.find((item) => item.id === goal.id);
|
|
1564
|
+
if (stoppedGoal?.status !== "budget_limited")
|
|
1565
|
+
return false;
|
|
1566
|
+
await appendEvent(directory, {
|
|
1567
|
+
version: 1,
|
|
1568
|
+
eventID: randomUUID2(),
|
|
1569
|
+
goalID: goal.id,
|
|
1570
|
+
type: "goal.status_changed",
|
|
1571
|
+
from: "active",
|
|
1572
|
+
to: "budget_limited",
|
|
1573
|
+
timestamp: new Date().toISOString(),
|
|
1574
|
+
revision: stoppedState.revision
|
|
1575
|
+
});
|
|
1576
|
+
await logServerEvent(directory, "maintenance.budget-exhausted", {
|
|
1577
|
+
goalID: goal.id,
|
|
1578
|
+
reason,
|
|
1579
|
+
tokensUsed: stoppedGoal.tokensUsed,
|
|
1580
|
+
costUsed: stoppedGoal.costUsed
|
|
1581
|
+
});
|
|
1582
|
+
if (shouldNotify) {
|
|
1583
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${reason}. Worker aborted, status: budget_limited. Resume with resume_goal to continue spending.`);
|
|
1584
|
+
}
|
|
1585
|
+
return true;
|
|
1586
|
+
}
|
|
1443
1587
|
async function maintenance() {
|
|
1444
1588
|
syncWorkerSessionsFromService();
|
|
1445
1589
|
if (knownWorkerSessions.size === 0)
|
|
@@ -1476,6 +1620,11 @@ function createLoopEngine(options) {
|
|
|
1476
1620
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1477
1621
|
if (!runtime)
|
|
1478
1622
|
continue;
|
|
1623
|
+
if (goal.workerSessionID) {
|
|
1624
|
+
const stopped = await accountAndEnforceBudget(goal);
|
|
1625
|
+
if (stopped)
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1479
1628
|
if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
|
|
1480
1629
|
if (Date.now() >= Date.parse(runtime.retryAfter)) {
|
|
1481
1630
|
await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
|
|
@@ -1558,6 +1707,87 @@ function createLoopEngine(options) {
|
|
|
1558
1707
|
});
|
|
1559
1708
|
await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
|
|
1560
1709
|
}
|
|
1710
|
+
if (status === "idle" && runtime.phase === "running") {
|
|
1711
|
+
const lastSignal = Math.max(runtime.lastActivityAt ? Date.parse(runtime.lastActivityAt) : 0, runtime.lastRunAt ? Date.parse(runtime.lastRunAt) : 0, runtime.turnStartedAt ? Date.parse(runtime.turnStartedAt) : 0);
|
|
1712
|
+
const quietMs = Date.now() - lastSignal;
|
|
1713
|
+
if (lastSignal > 0 && quietMs > idleRecoverMs) {
|
|
1714
|
+
const generation = runtime.runGeneration;
|
|
1715
|
+
const stalledRunID = runtime.activeRunID;
|
|
1716
|
+
let clearedToolCalls = 0;
|
|
1717
|
+
let recovered = false;
|
|
1718
|
+
const recoveredState = await mutateState(directory, `maintenance.idle-recover:${goal.id}`, async (s) => {
|
|
1719
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
1720
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1721
|
+
if (!g || !rt || g.status !== "active")
|
|
1722
|
+
return s;
|
|
1723
|
+
if (rt.phase !== "running")
|
|
1724
|
+
return s;
|
|
1725
|
+
if (rt.runGeneration !== generation)
|
|
1726
|
+
return s;
|
|
1727
|
+
clearedToolCalls = rt.activeToolCallIDs?.length ?? 0;
|
|
1728
|
+
Object.assign(rt, releaseLease(rt));
|
|
1729
|
+
rt.activeRunID = undefined;
|
|
1730
|
+
rt.lastWorkerStatus = "idle";
|
|
1731
|
+
rt.idleConfirmFailedAt = undefined;
|
|
1732
|
+
rt.idleConfirmFailedGeneration = undefined;
|
|
1733
|
+
rt.idleStuckNotifiedGeneration = undefined;
|
|
1734
|
+
recovered = true;
|
|
1735
|
+
return s;
|
|
1736
|
+
});
|
|
1737
|
+
if (recovered) {
|
|
1738
|
+
await appendEvent(directory, {
|
|
1739
|
+
version: 1,
|
|
1740
|
+
eventID: randomUUID2(),
|
|
1741
|
+
goalID: goal.id,
|
|
1742
|
+
type: "run.recovered",
|
|
1743
|
+
runID: stalledRunID ?? "unknown",
|
|
1744
|
+
quietSeconds: Math.floor(quietMs / 1000),
|
|
1745
|
+
clearedToolCalls,
|
|
1746
|
+
timestamp: new Date().toISOString(),
|
|
1747
|
+
revision: recoveredState.revision
|
|
1748
|
+
});
|
|
1749
|
+
await logServerEvent(directory, "maintenance.idle-recovered", {
|
|
1750
|
+
goalID: goal.id,
|
|
1751
|
+
generation,
|
|
1752
|
+
quietSeconds: Math.floor(quietMs / 1000),
|
|
1753
|
+
clearedToolCalls
|
|
1754
|
+
});
|
|
1755
|
+
await continueGoal(goal.id);
|
|
1756
|
+
continue;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
if (status === "idle" && runtime.phase === "running" && runtime.idleConfirmFailedGeneration === runtime.runGeneration && runtime.idleConfirmFailedAt && runtime.idleStuckNotifiedGeneration !== runtime.runGeneration && Date.now() - Date.parse(runtime.idleConfirmFailedAt) > idleUnconfirmedMs) {
|
|
1761
|
+
const failedAt = runtime.idleConfirmFailedAt;
|
|
1762
|
+
const generation = runtime.runGeneration;
|
|
1763
|
+
const stuckState = await mutateState(directory, `maintenance.idle-stuck:${goal.id}`, async (s) => {
|
|
1764
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1765
|
+
if (!rt || rt.runGeneration !== generation || rt.phase !== "running")
|
|
1766
|
+
return s;
|
|
1767
|
+
rt.idleStuckNotifiedGeneration = generation;
|
|
1768
|
+
rt.updatedAt = new Date().toISOString();
|
|
1769
|
+
return s;
|
|
1770
|
+
});
|
|
1771
|
+
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
1772
|
+
await appendEvent(directory, {
|
|
1773
|
+
version: 1,
|
|
1774
|
+
eventID: randomUUID2(),
|
|
1775
|
+
goalID: goal.id,
|
|
1776
|
+
type: "run.stuck",
|
|
1777
|
+
runID: runtime.activeRunID ?? "unknown",
|
|
1778
|
+
stuckSeconds,
|
|
1779
|
+
timestamp: new Date().toISOString(),
|
|
1780
|
+
revision: stuckState.revision
|
|
1781
|
+
});
|
|
1782
|
+
await logServerEvent(directory, "maintenance.idle-stuck", {
|
|
1783
|
+
goalID: goal.id,
|
|
1784
|
+
generation,
|
|
1785
|
+
stuckSeconds
|
|
1786
|
+
});
|
|
1787
|
+
const quietMinutes = Math.max(1, Math.floor(stuckSeconds / 60));
|
|
1788
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" worker is idle but its turn will not confirm (unconfirmed for ${quietMinutes}m, no activity). The goal remains active; use inspect_background_goal to look, nudge_goal to re-prompt, or pause_goal to stop it.`);
|
|
1789
|
+
continue;
|
|
1790
|
+
}
|
|
1561
1791
|
if (status === "idle") {
|
|
1562
1792
|
if (runtime.phase === "idle") {
|
|
1563
1793
|
await continueGoal(goal.id);
|
|
@@ -1566,6 +1796,37 @@ function createLoopEngine(options) {
|
|
|
1566
1796
|
}
|
|
1567
1797
|
continue;
|
|
1568
1798
|
}
|
|
1799
|
+
if ((status === "busy" || status === "retry") && runtime.phase === "running" && runtime.activeRunID && runtime.stuckNotifiedRunID !== runtime.activeRunID) {
|
|
1800
|
+
const leaseExpired = !runtime.leaseExpiresAt || Date.now() >= Date.parse(runtime.leaseExpiresAt);
|
|
1801
|
+
const lastActive = runtime.lastActivityAt ? Date.parse(runtime.lastActivityAt) : 0;
|
|
1802
|
+
if (leaseExpired && Date.now() - lastActive > stuckRunningMs) {
|
|
1803
|
+
const stuckSeconds = Math.floor((Date.now() - lastActive) / 1000);
|
|
1804
|
+
const stuckState = await mutateState(directory, `maintenance.run-stuck:${goal.id}`, async (s) => {
|
|
1805
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1806
|
+
if (!rt || rt.activeRunID !== runtime.activeRunID)
|
|
1807
|
+
return s;
|
|
1808
|
+
rt.stuckNotifiedRunID = rt.activeRunID;
|
|
1809
|
+
rt.updatedAt = new Date().toISOString();
|
|
1810
|
+
return s;
|
|
1811
|
+
});
|
|
1812
|
+
await appendEvent(directory, {
|
|
1813
|
+
version: 1,
|
|
1814
|
+
eventID: randomUUID2(),
|
|
1815
|
+
goalID: goal.id,
|
|
1816
|
+
type: "run.stuck",
|
|
1817
|
+
runID: runtime.activeRunID,
|
|
1818
|
+
stuckSeconds,
|
|
1819
|
+
timestamp: new Date().toISOString(),
|
|
1820
|
+
revision: stuckState.revision
|
|
1821
|
+
});
|
|
1822
|
+
await logServerEvent(directory, "maintenance.run-stuck", {
|
|
1823
|
+
goalID: goal.id,
|
|
1824
|
+
runID: runtime.activeRunID,
|
|
1825
|
+
stuckSeconds
|
|
1826
|
+
});
|
|
1827
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" worker may be stuck: no activity for ${Math.floor(stuckSeconds / 60)}m while reporting ${status}, lease expired. The goal remains active; use inspect_background_goal to look, nudge_goal to re-prompt, or pause_goal to stop it.`);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1569
1830
|
}
|
|
1570
1831
|
}
|
|
1571
1832
|
}
|
|
@@ -1577,6 +1838,194 @@ import { randomUUID as randomUUID3 } from "crypto";
|
|
|
1577
1838
|
import * as path2 from "path";
|
|
1578
1839
|
import { promises as fs2 } from "fs";
|
|
1579
1840
|
|
|
1841
|
+
// src/server/host-adapter.ts
|
|
1842
|
+
function parseModelRef(value) {
|
|
1843
|
+
if (value === undefined)
|
|
1844
|
+
return;
|
|
1845
|
+
const trimmed = value.trim();
|
|
1846
|
+
if (!trimmed)
|
|
1847
|
+
return;
|
|
1848
|
+
const slash = trimmed.indexOf("/");
|
|
1849
|
+
if (slash <= 0 || slash >= trimmed.length - 1) {
|
|
1850
|
+
throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
|
|
1851
|
+
}
|
|
1852
|
+
const providerID = trimmed.slice(0, slash).trim();
|
|
1853
|
+
const modelID = trimmed.slice(slash + 1).trim();
|
|
1854
|
+
if (!providerID || !modelID || /\s/.test(providerID) || /\s/.test(modelID)) {
|
|
1855
|
+
throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
|
|
1856
|
+
}
|
|
1857
|
+
return { providerID, modelID };
|
|
1858
|
+
}
|
|
1859
|
+
var recentParentNotifies = new Map;
|
|
1860
|
+
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
1861
|
+
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
1862
|
+
const now = Date.now();
|
|
1863
|
+
const last = recentParentNotifies.get(key);
|
|
1864
|
+
if (last !== undefined && now - last < 60000)
|
|
1865
|
+
return true;
|
|
1866
|
+
recentParentNotifies.set(key, now);
|
|
1867
|
+
if (recentParentNotifies.size > 200) {
|
|
1868
|
+
for (const [k, t] of recentParentNotifies.entries())
|
|
1869
|
+
if (now - t > 60000)
|
|
1870
|
+
recentParentNotifies.delete(k);
|
|
1871
|
+
}
|
|
1872
|
+
return false;
|
|
1873
|
+
}
|
|
1874
|
+
function createRealHost(client, directory) {
|
|
1875
|
+
return {
|
|
1876
|
+
async createWorker({ parentID, title, agent, model }) {
|
|
1877
|
+
try {
|
|
1878
|
+
const body = { parentID, title };
|
|
1879
|
+
if (agent)
|
|
1880
|
+
body.agent = agent;
|
|
1881
|
+
if (model)
|
|
1882
|
+
body.model = { id: model.modelID, providerID: model.providerID };
|
|
1883
|
+
const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
|
|
1884
|
+
const data = result?.data;
|
|
1885
|
+
if (result?.error || !data?.id) {
|
|
1886
|
+
const detail = describeError(result?.error || "response contained no session ID");
|
|
1887
|
+
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
1888
|
+
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
1889
|
+
}
|
|
1890
|
+
await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
|
|
1891
|
+
return data.id;
|
|
1892
|
+
} catch (error) {
|
|
1893
|
+
if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
|
|
1894
|
+
throw error;
|
|
1895
|
+
const detail = describeError(error);
|
|
1896
|
+
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
1897
|
+
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
1898
|
+
}
|
|
1899
|
+
},
|
|
1900
|
+
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
1901
|
+
const body = {
|
|
1902
|
+
parts: [{ type: "text", text: prompt }]
|
|
1903
|
+
};
|
|
1904
|
+
if (messageID) {
|
|
1905
|
+
const collapsed = messageID.replace(/^(msg-)+/, "msg-");
|
|
1906
|
+
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
1907
|
+
}
|
|
1908
|
+
if (model)
|
|
1909
|
+
body.model = model;
|
|
1910
|
+
if (agent)
|
|
1911
|
+
body.agent = agent;
|
|
1912
|
+
const result = await withTimeout(client.session.promptAsync({
|
|
1913
|
+
path: { id: sessionID },
|
|
1914
|
+
body
|
|
1915
|
+
}), 1e4, "OpenCode session.promptAsync");
|
|
1916
|
+
if (result?.error) {
|
|
1917
|
+
const detail = describeError(result.error);
|
|
1918
|
+
await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
|
|
1919
|
+
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
|
|
1920
|
+
}
|
|
1921
|
+
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
1922
|
+
return { messageID: result?.data?.messageID };
|
|
1923
|
+
},
|
|
1924
|
+
async sessionStatus(sessionID) {
|
|
1925
|
+
try {
|
|
1926
|
+
const result = await client.session.status({});
|
|
1927
|
+
if (result?.error)
|
|
1928
|
+
return "unknown";
|
|
1929
|
+
const data = result?.data;
|
|
1930
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
1931
|
+
return "unknown";
|
|
1932
|
+
const status = data[sessionID];
|
|
1933
|
+
if (status === undefined || status === null)
|
|
1934
|
+
return "idle";
|
|
1935
|
+
if (typeof status !== "object" || Array.isArray(status))
|
|
1936
|
+
return "unknown";
|
|
1937
|
+
const type = status.type;
|
|
1938
|
+
if (type === "busy" || type === "retry")
|
|
1939
|
+
return type;
|
|
1940
|
+
if (type === "idle")
|
|
1941
|
+
return "idle";
|
|
1942
|
+
return "unknown";
|
|
1943
|
+
} catch {
|
|
1944
|
+
return "unknown";
|
|
1945
|
+
}
|
|
1946
|
+
},
|
|
1947
|
+
async abortSession(sessionID) {
|
|
1948
|
+
try {
|
|
1949
|
+
await client.session.abort({ path: { id: sessionID } });
|
|
1950
|
+
} catch {}
|
|
1951
|
+
},
|
|
1952
|
+
async readMessages(sessionID, limit = 10) {
|
|
1953
|
+
try {
|
|
1954
|
+
const result = await client.session.messages({
|
|
1955
|
+
path: { id: sessionID },
|
|
1956
|
+
query: { limit }
|
|
1957
|
+
});
|
|
1958
|
+
const data = result?.data;
|
|
1959
|
+
if (!Array.isArray(data))
|
|
1960
|
+
return [];
|
|
1961
|
+
return data.map((m) => {
|
|
1962
|
+
const createdMs = m.info?.time?.created;
|
|
1963
|
+
const completedMs = m.info?.time?.completed;
|
|
1964
|
+
const tokens = m.info?.tokens;
|
|
1965
|
+
return {
|
|
1966
|
+
role: m.info?.role || "assistant",
|
|
1967
|
+
content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
|
|
1968
|
+
`) || "",
|
|
1969
|
+
timestamp: completedMs || createdMs ? new Date(completedMs || createdMs).toISOString() : undefined,
|
|
1970
|
+
messageID: m.info?.id || m.id,
|
|
1971
|
+
parentMessageID: m.info?.parentID,
|
|
1972
|
+
completedAt: completedMs ? new Date(completedMs).toISOString() : undefined,
|
|
1973
|
+
tokens: tokens && typeof tokens.input === "number" ? {
|
|
1974
|
+
input: tokens.input || 0,
|
|
1975
|
+
output: tokens.output || 0,
|
|
1976
|
+
reasoning: tokens.reasoning || 0,
|
|
1977
|
+
cacheRead: tokens.cache?.read || 0,
|
|
1978
|
+
cacheWrite: tokens.cache?.write || 0
|
|
1979
|
+
} : undefined,
|
|
1980
|
+
cost: typeof m.info?.cost === "number" ? m.info.cost : undefined,
|
|
1981
|
+
durationMs: typeof createdMs === "number" && typeof completedMs === "number" && completedMs >= createdMs ? completedMs - createdMs : undefined
|
|
1982
|
+
};
|
|
1983
|
+
});
|
|
1984
|
+
} catch {
|
|
1985
|
+
return [];
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
async compactSession(sessionID) {
|
|
1989
|
+
try {
|
|
1990
|
+
await client.session.compact({ sessionID });
|
|
1991
|
+
} catch {}
|
|
1992
|
+
},
|
|
1993
|
+
async notifyOwner(ownerSessionID, message) {
|
|
1994
|
+
if (shouldDedupParentNotify(ownerSessionID, message)) {
|
|
1995
|
+
await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
try {
|
|
1999
|
+
const result = await withTimeout(client.session.promptAsync({
|
|
2000
|
+
path: { id: ownerSessionID },
|
|
2001
|
+
body: { parts: [{ type: "text", text: message }] }
|
|
2002
|
+
}), 1e4, "OpenCode parent notify");
|
|
2003
|
+
if (result?.error) {
|
|
2004
|
+
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
|
|
2005
|
+
} else {
|
|
2006
|
+
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2007
|
+
}
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
async function withTimeout(promise, timeoutMs, operation) {
|
|
2015
|
+
let timer;
|
|
2016
|
+
try {
|
|
2017
|
+
return await Promise.race([
|
|
2018
|
+
promise,
|
|
2019
|
+
new Promise((_, reject) => {
|
|
2020
|
+
timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
2021
|
+
})
|
|
2022
|
+
]);
|
|
2023
|
+
} finally {
|
|
2024
|
+
if (timer)
|
|
2025
|
+
clearTimeout(timer);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1580
2029
|
// src/server/worker-session.ts
|
|
1581
2030
|
function createWorkerManager(host) {
|
|
1582
2031
|
return {
|
|
@@ -1584,7 +2033,8 @@ function createWorkerManager(host) {
|
|
|
1584
2033
|
const workerSessionID = await host.createWorker({
|
|
1585
2034
|
parentID: goal.ownerSessionID,
|
|
1586
2035
|
title: `loopd: ${goal.name}`,
|
|
1587
|
-
agent: goal.config.agent
|
|
2036
|
+
agent: goal.config.agent,
|
|
2037
|
+
model: parseModelRef(goal.config.model)
|
|
1588
2038
|
});
|
|
1589
2039
|
return {
|
|
1590
2040
|
goalID: goal.id,
|
|
@@ -1598,7 +2048,8 @@ function createWorkerManager(host) {
|
|
|
1598
2048
|
sessionID: worker.workerSessionID,
|
|
1599
2049
|
prompt,
|
|
1600
2050
|
messageID: runtime.activePromptMessageID,
|
|
1601
|
-
agent: goal.config.agent
|
|
2051
|
+
agent: goal.config.agent,
|
|
2052
|
+
model: parseModelRef(goal.config.model)
|
|
1602
2053
|
});
|
|
1603
2054
|
return result;
|
|
1604
2055
|
},
|
|
@@ -1832,6 +2283,8 @@ function createGoalService(host) {
|
|
|
1832
2283
|
...input.config
|
|
1833
2284
|
}
|
|
1834
2285
|
});
|
|
2286
|
+
if (typeof input.costBudget === "number")
|
|
2287
|
+
goal.costBudget = input.costBudget;
|
|
1835
2288
|
const artifactDir = goalArtifactDir(directory, id);
|
|
1836
2289
|
goal.config.artifactDir = artifactDir;
|
|
1837
2290
|
if (!goal.config.progressFile)
|
|
@@ -1940,6 +2393,63 @@ function createGoalService(host) {
|
|
|
1940
2393
|
}
|
|
1941
2394
|
return { goal, worker };
|
|
1942
2395
|
}
|
|
2396
|
+
async function accountTailUsage(directory, goalID, runtime, tail) {
|
|
2397
|
+
const seenIDs = new Set(runtime.accountedMessageIDs ?? []);
|
|
2398
|
+
let tokenDelta = 0;
|
|
2399
|
+
let costDelta = 0;
|
|
2400
|
+
let timeDeltaSeconds = 0;
|
|
2401
|
+
const counted = [];
|
|
2402
|
+
for (const m of tail) {
|
|
2403
|
+
if (m.role !== "assistant" || !m.messageID || !m.completedAt)
|
|
2404
|
+
continue;
|
|
2405
|
+
if (seenIDs.has(m.messageID))
|
|
2406
|
+
continue;
|
|
2407
|
+
seenIDs.add(m.messageID);
|
|
2408
|
+
counted.push(m.messageID);
|
|
2409
|
+
if (m.tokens) {
|
|
2410
|
+
tokenDelta += (m.tokens.input || 0) + (m.tokens.output || 0) + (m.tokens.reasoning || 0) + (m.tokens.cacheRead || 0) + (m.tokens.cacheWrite || 0);
|
|
2411
|
+
}
|
|
2412
|
+
if (typeof m.cost === "number")
|
|
2413
|
+
costDelta += m.cost;
|
|
2414
|
+
if (typeof m.durationMs === "number")
|
|
2415
|
+
timeDeltaSeconds += m.durationMs / 1000;
|
|
2416
|
+
}
|
|
2417
|
+
if (counted.length === 0)
|
|
2418
|
+
return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted };
|
|
2419
|
+
const mergedWatermark = [...runtime.accountedMessageIDs ?? [], ...counted].slice(-200);
|
|
2420
|
+
await mutateState(directory, `turn.account-usage:${goalID}`, async (s) => {
|
|
2421
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
2422
|
+
if (g) {
|
|
2423
|
+
g.tokensUsed += tokenDelta;
|
|
2424
|
+
g.costUsed = (g.costUsed ?? 0) + costDelta;
|
|
2425
|
+
g.timeUsedSeconds += timeDeltaSeconds;
|
|
2426
|
+
g.updatedAt = new Date().toISOString();
|
|
2427
|
+
}
|
|
2428
|
+
const rt = s.runtimes.find((item) => item.goalID === goalID);
|
|
2429
|
+
if (rt) {
|
|
2430
|
+
rt.turnTokensUsed = (rt.turnTokensUsed ?? 0) + tokenDelta;
|
|
2431
|
+
rt.accountedMessageIDs = mergedWatermark;
|
|
2432
|
+
rt.updatedAt = new Date().toISOString();
|
|
2433
|
+
}
|
|
2434
|
+
return s;
|
|
2435
|
+
});
|
|
2436
|
+
return { tokenDelta, costDelta, timeDeltaSeconds, counted };
|
|
2437
|
+
}
|
|
2438
|
+
async function accountUsageUnlocked(directory, goalID) {
|
|
2439
|
+
const preState = await readState(directory);
|
|
2440
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
2441
|
+
const runtime = preState.runtimes.find((r) => r.goalID === goalID);
|
|
2442
|
+
if (!goal?.workerSessionID || !runtime) {
|
|
2443
|
+
return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
|
|
2444
|
+
}
|
|
2445
|
+
let tail = [];
|
|
2446
|
+
try {
|
|
2447
|
+
tail = await host.readMessages(goal.workerSessionID, 50);
|
|
2448
|
+
} catch {
|
|
2449
|
+
return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
|
|
2450
|
+
}
|
|
2451
|
+
return accountTailUsage(directory, goalID, runtime, tail);
|
|
2452
|
+
}
|
|
1943
2453
|
async function continueTurnUnlocked(directory, goalID, opts) {
|
|
1944
2454
|
const preState = await readState(directory);
|
|
1945
2455
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
@@ -2014,6 +2524,7 @@ function createGoalService(host) {
|
|
|
2014
2524
|
} catch {
|
|
2015
2525
|
transcriptTail = [];
|
|
2016
2526
|
}
|
|
2527
|
+
await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
|
|
2017
2528
|
let verification;
|
|
2018
2529
|
try {
|
|
2019
2530
|
const artifactDir = freshGoal.config.artifactDir;
|
|
@@ -2325,7 +2836,10 @@ function createGoalService(host) {
|
|
|
2325
2836
|
function nudge(directory, goalID) {
|
|
2326
2837
|
return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
|
|
2327
2838
|
}
|
|
2328
|
-
|
|
2839
|
+
function accountUsage(directory, goalID) {
|
|
2840
|
+
return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
|
|
2841
|
+
}
|
|
2842
|
+
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage };
|
|
2329
2843
|
}
|
|
2330
2844
|
|
|
2331
2845
|
// src/application/schedule-worker.ts
|
|
@@ -2442,161 +2956,6 @@ function createScheduleWorker(options) {
|
|
|
2442
2956
|
return { start, stop, isRunning, tick };
|
|
2443
2957
|
}
|
|
2444
2958
|
|
|
2445
|
-
// src/server/host-adapter.ts
|
|
2446
|
-
var recentParentNotifies = new Map;
|
|
2447
|
-
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
2448
|
-
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
2449
|
-
const now = Date.now();
|
|
2450
|
-
const last = recentParentNotifies.get(key);
|
|
2451
|
-
if (last !== undefined && now - last < 60000)
|
|
2452
|
-
return true;
|
|
2453
|
-
recentParentNotifies.set(key, now);
|
|
2454
|
-
if (recentParentNotifies.size > 200) {
|
|
2455
|
-
for (const [k, t] of recentParentNotifies.entries())
|
|
2456
|
-
if (now - t > 60000)
|
|
2457
|
-
recentParentNotifies.delete(k);
|
|
2458
|
-
}
|
|
2459
|
-
return false;
|
|
2460
|
-
}
|
|
2461
|
-
function createRealHost(client, directory) {
|
|
2462
|
-
return {
|
|
2463
|
-
async createWorker({ parentID, title, agent }) {
|
|
2464
|
-
try {
|
|
2465
|
-
const body = { parentID, title };
|
|
2466
|
-
if (agent)
|
|
2467
|
-
body.agent = agent;
|
|
2468
|
-
const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
|
|
2469
|
-
const data = result?.data;
|
|
2470
|
-
if (result?.error || !data?.id) {
|
|
2471
|
-
const detail = describeError(result?.error || "response contained no session ID");
|
|
2472
|
-
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
2473
|
-
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
2474
|
-
}
|
|
2475
|
-
await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
|
|
2476
|
-
return data.id;
|
|
2477
|
-
} catch (error) {
|
|
2478
|
-
if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
|
|
2479
|
-
throw error;
|
|
2480
|
-
const detail = describeError(error);
|
|
2481
|
-
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
2482
|
-
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
2483
|
-
}
|
|
2484
|
-
},
|
|
2485
|
-
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
2486
|
-
const body = {
|
|
2487
|
-
parts: [{ type: "text", text: prompt }]
|
|
2488
|
-
};
|
|
2489
|
-
if (messageID) {
|
|
2490
|
-
const collapsed = messageID.replace(/^(msg-)+/, "msg-");
|
|
2491
|
-
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
2492
|
-
}
|
|
2493
|
-
if (model)
|
|
2494
|
-
body.model = model;
|
|
2495
|
-
if (agent)
|
|
2496
|
-
body.agent = agent;
|
|
2497
|
-
const result = await withTimeout(client.session.promptAsync({
|
|
2498
|
-
path: { id: sessionID },
|
|
2499
|
-
body
|
|
2500
|
-
}), 1e4, "OpenCode session.promptAsync");
|
|
2501
|
-
if (result?.error) {
|
|
2502
|
-
const detail = describeError(result.error);
|
|
2503
|
-
await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
|
|
2504
|
-
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
|
|
2505
|
-
}
|
|
2506
|
-
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2507
|
-
return { messageID: result?.data?.messageID };
|
|
2508
|
-
},
|
|
2509
|
-
async sessionStatus(sessionID) {
|
|
2510
|
-
try {
|
|
2511
|
-
const result = await client.session.status({});
|
|
2512
|
-
if (result?.error)
|
|
2513
|
-
return "unknown";
|
|
2514
|
-
const data = result?.data;
|
|
2515
|
-
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
2516
|
-
return "unknown";
|
|
2517
|
-
const status = data[sessionID];
|
|
2518
|
-
if (status === undefined || status === null)
|
|
2519
|
-
return "idle";
|
|
2520
|
-
if (typeof status !== "object" || Array.isArray(status))
|
|
2521
|
-
return "unknown";
|
|
2522
|
-
const type = status.type;
|
|
2523
|
-
if (type === "busy" || type === "retry")
|
|
2524
|
-
return type;
|
|
2525
|
-
if (type === "idle")
|
|
2526
|
-
return "idle";
|
|
2527
|
-
return "unknown";
|
|
2528
|
-
} catch {
|
|
2529
|
-
return "unknown";
|
|
2530
|
-
}
|
|
2531
|
-
},
|
|
2532
|
-
async abortSession(sessionID) {
|
|
2533
|
-
try {
|
|
2534
|
-
await client.session.abort({ path: { id: sessionID } });
|
|
2535
|
-
} catch {}
|
|
2536
|
-
},
|
|
2537
|
-
async readMessages(sessionID, limit = 10) {
|
|
2538
|
-
try {
|
|
2539
|
-
const result = await client.session.messages({
|
|
2540
|
-
path: { id: sessionID },
|
|
2541
|
-
query: { limit }
|
|
2542
|
-
});
|
|
2543
|
-
const data = result?.data;
|
|
2544
|
-
if (!Array.isArray(data))
|
|
2545
|
-
return [];
|
|
2546
|
-
return data.map((m) => ({
|
|
2547
|
-
role: m.info?.role || "assistant",
|
|
2548
|
-
content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
|
|
2549
|
-
`) || "",
|
|
2550
|
-
timestamp: m.info?.time?.completed || m.info?.time?.created ? new Date(m.info.time.completed || m.info.time.created).toISOString() : undefined,
|
|
2551
|
-
messageID: m.info?.id || m.id,
|
|
2552
|
-
parentMessageID: m.info?.parentID,
|
|
2553
|
-
completedAt: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined
|
|
2554
|
-
}));
|
|
2555
|
-
} catch {
|
|
2556
|
-
return [];
|
|
2557
|
-
}
|
|
2558
|
-
},
|
|
2559
|
-
async compactSession(sessionID) {
|
|
2560
|
-
try {
|
|
2561
|
-
await client.session.compact({ sessionID });
|
|
2562
|
-
} catch {}
|
|
2563
|
-
},
|
|
2564
|
-
async notifyOwner(ownerSessionID, message) {
|
|
2565
|
-
if (shouldDedupParentNotify(ownerSessionID, message)) {
|
|
2566
|
-
await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2567
|
-
return;
|
|
2568
|
-
}
|
|
2569
|
-
try {
|
|
2570
|
-
const result = await withTimeout(client.session.promptAsync({
|
|
2571
|
-
path: { id: ownerSessionID },
|
|
2572
|
-
body: { parts: [{ type: "text", text: message }] }
|
|
2573
|
-
}), 1e4, "OpenCode parent notify");
|
|
2574
|
-
if (result?.error) {
|
|
2575
|
-
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
|
|
2576
|
-
} else {
|
|
2577
|
-
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2578
|
-
}
|
|
2579
|
-
} catch (error) {
|
|
2580
|
-
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
|
|
2581
|
-
}
|
|
2582
|
-
}
|
|
2583
|
-
};
|
|
2584
|
-
}
|
|
2585
|
-
async function withTimeout(promise, timeoutMs, operation) {
|
|
2586
|
-
let timer;
|
|
2587
|
-
try {
|
|
2588
|
-
return await Promise.race([
|
|
2589
|
-
promise,
|
|
2590
|
-
new Promise((_, reject) => {
|
|
2591
|
-
timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
2592
|
-
})
|
|
2593
|
-
]);
|
|
2594
|
-
} finally {
|
|
2595
|
-
if (timer)
|
|
2596
|
-
clearTimeout(timer);
|
|
2597
|
-
}
|
|
2598
|
-
}
|
|
2599
|
-
|
|
2600
2959
|
// src/server/goal-tools.ts
|
|
2601
2960
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
2602
2961
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
@@ -2617,11 +2976,13 @@ var execAsync = promisify(execChild);
|
|
|
2617
2976
|
function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
2618
2977
|
return {
|
|
2619
2978
|
loopd_create_goal: tool({
|
|
2620
|
-
description: "Create a new background loop goal (contract: objective + checks + agent + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "agent
|
|
2979
|
+
description: "Create a new background loop goal (contract: objective + checks + agent/model + workspaceWrite). " + "The engine spawns a dedicated worker session that does the work autonomously \u2014 it never runs in this chat. " + "Call this after clarifying the contract with the user. " + "Worker identity is free-form: agent is any OpenCode agent name (built-in, ~/.config/opencode/agents/*.md, or opencode.jsonc agent.* \u2014 discover with `opencode agent list`), " + 'model is any "providerID/modelID" (discover with `opencode models [provider]`). Both are sent on every worker prompt. ' + "Host is the acceptance authority: checks must pass for complete_goal (free retry if rejected <3, blocked after 3). " + "Workspace-writing goals are serialized (only one active writer) and require checks. " + "agent/model are optional \u2014 fall back to the parent session's agent/model, or plugin defaultAgent/defaultModel in opencode.jsonc.",
|
|
2621
2980
|
args: {
|
|
2622
2981
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
2623
2982
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
2624
|
-
agent: tool.schema.string().optional().describe(
|
|
2983
|
+
agent: tool.schema.string().optional().describe(`Agent to run the worker as (e.g. "researcher", "smart-agent"). Optional \u2014 uses parent session's agent if omitted, or plugin defaultAgent.`),
|
|
2984
|
+
model: tool.schema.string().optional().describe(`Model to run the worker as, as "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Optional \u2014 uses parent session's model if omitted, or plugin defaultModel.`),
|
|
2985
|
+
costBudget: tool.schema.number().optional().describe("Max provider cost in dollars before the engine stops the goal as budget_limited (e.g. 0.5). Optional \u2014 unlimited if omitted."),
|
|
2625
2986
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
2626
2987
|
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
2627
2988
|
workspaceWrite: tool.schema.boolean().optional().describe("Whether this goal edits the shared project workspace. Defaults to true; explicitly set false for artifact-only/read-only work."),
|
|
@@ -2650,6 +3011,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2650
3011
|
};
|
|
2651
3012
|
if (args.agent)
|
|
2652
3013
|
config.agent = args.agent;
|
|
3014
|
+
if (args.model)
|
|
3015
|
+
config.model = args.model;
|
|
2653
3016
|
if (args.checks)
|
|
2654
3017
|
config.checks = args.checks;
|
|
2655
3018
|
if (args.checkCwd)
|
|
@@ -2690,6 +3053,16 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2690
3053
|
output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
|
|
2691
3054
|
};
|
|
2692
3055
|
}
|
|
3056
|
+
let costBudget;
|
|
3057
|
+
if (args.costBudget !== undefined) {
|
|
3058
|
+
if (typeof args.costBudget !== "number" || !Number.isFinite(args.costBudget) || args.costBudget <= 0) {
|
|
3059
|
+
return {
|
|
3060
|
+
title: "Goal not created",
|
|
3061
|
+
output: JSON.stringify({ ok: false, message: "costBudget must be a positive number of dollars", errorCode: "invalid_cost_budget" })
|
|
3062
|
+
};
|
|
3063
|
+
}
|
|
3064
|
+
costBudget = args.costBudget;
|
|
3065
|
+
}
|
|
2693
3066
|
const resolution = resolveGoalCreationConfig({
|
|
2694
3067
|
directory: dir,
|
|
2695
3068
|
objective: args.objective,
|
|
@@ -2711,7 +3084,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2711
3084
|
name: args.name,
|
|
2712
3085
|
objective: args.objective,
|
|
2713
3086
|
ownerSessionID: sessionID,
|
|
2714
|
-
config: resolution.config
|
|
3087
|
+
config: resolution.config,
|
|
3088
|
+
costBudget
|
|
2715
3089
|
});
|
|
2716
3090
|
return {
|
|
2717
3091
|
title: "Goal created",
|
|
@@ -2721,6 +3095,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2721
3095
|
workerSessionID: worker.workerSessionID,
|
|
2722
3096
|
artifactDir: goal.config.artifactDir,
|
|
2723
3097
|
agent: resolution.config.agent,
|
|
3098
|
+
model: resolution.config.model,
|
|
3099
|
+
costBudget: goal.costBudget,
|
|
2724
3100
|
checks: resolution.config.checks || [],
|
|
2725
3101
|
workspaceWrite: resolution.config.workspaceWrite,
|
|
2726
3102
|
defaultsApplied: resolution.defaultsApplied,
|
|
@@ -2933,11 +3309,17 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2933
3309
|
evidence: args.evidence,
|
|
2934
3310
|
at: new Date().toISOString()
|
|
2935
3311
|
};
|
|
3312
|
+
const finalUsage = await goalService.accountUsage(dir, goal.id);
|
|
3313
|
+
goal.tokensUsed += finalUsage.tokenDelta;
|
|
3314
|
+
goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
|
|
3315
|
+
goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
|
|
2936
3316
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
2937
3317
|
if (runtime) {
|
|
2938
3318
|
Object.assign(runtime, releaseLease(runtime));
|
|
2939
3319
|
runtime.activeRunID = undefined;
|
|
2940
3320
|
runtime.lastError = undefined;
|
|
3321
|
+
runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
|
|
3322
|
+
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
2941
3323
|
runtime.updatedAt = new Date().toISOString();
|
|
2942
3324
|
const schedule = goal.config.schedule;
|
|
2943
3325
|
if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
|
|
@@ -3022,11 +3404,17 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3022
3404
|
needed: args.needed,
|
|
3023
3405
|
at: new Date().toISOString()
|
|
3024
3406
|
};
|
|
3407
|
+
const finalUsage = await goalService.accountUsage(dir, goal.id);
|
|
3408
|
+
goal.tokensUsed += finalUsage.tokenDelta;
|
|
3409
|
+
goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
|
|
3410
|
+
goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
|
|
3025
3411
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
3026
3412
|
if (runtime) {
|
|
3027
3413
|
Object.assign(runtime, releaseLease(runtime));
|
|
3028
3414
|
runtime.activeRunID = undefined;
|
|
3029
3415
|
runtime.lastError = undefined;
|
|
3416
|
+
runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
|
|
3417
|
+
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
3030
3418
|
runtime.updatedAt = new Date().toISOString();
|
|
3031
3419
|
}
|
|
3032
3420
|
await writeState(dir, state);
|
|
@@ -3076,6 +3464,7 @@ function formatGoalStructured(goal, runtime) {
|
|
|
3076
3464
|
checkCwd: goal.config.checkCwd,
|
|
3077
3465
|
workspaceWrite: goal.config.workspaceWrite,
|
|
3078
3466
|
agent: goal.config.agent,
|
|
3467
|
+
model: goal.config.model,
|
|
3079
3468
|
maxTurns: goal.config.maxTurns,
|
|
3080
3469
|
maxNoProgress: goal.config.maxNoProgress,
|
|
3081
3470
|
maxFailures: goal.config.maxFailures,
|
|
@@ -3087,6 +3476,9 @@ function formatGoalStructured(goal, runtime) {
|
|
|
3087
3476
|
completionEvidence: goal.completionEvidence,
|
|
3088
3477
|
blocker: goal.blocker,
|
|
3089
3478
|
tokensUsed: goal.tokensUsed,
|
|
3479
|
+
tokenBudget: goal.tokenBudget,
|
|
3480
|
+
costUsed: goal.costUsed ?? 0,
|
|
3481
|
+
costBudget: goal.costBudget,
|
|
3090
3482
|
timeUsedSeconds: goal.timeUsedSeconds
|
|
3091
3483
|
};
|
|
3092
3484
|
if (runtime) {
|
|
@@ -3186,6 +3578,8 @@ function ownerTools(options) {
|
|
|
3186
3578
|
turn: runtime?.runCount ?? 0,
|
|
3187
3579
|
budgetTurnCount: runtime?.budgetTurnCount ?? 0,
|
|
3188
3580
|
maxTurns: g.config.maxTurns,
|
|
3581
|
+
agent: g.config.agent,
|
|
3582
|
+
model: g.config.model,
|
|
3189
3583
|
lastProgress: g.lastProgress?.summary?.slice(0, 120),
|
|
3190
3584
|
lastProgressAt: g.lastProgress?.at,
|
|
3191
3585
|
blocker: g.blocker?.reason?.slice(0, 120),
|
|
@@ -3206,7 +3600,7 @@ function ownerTools(options) {
|
|
|
3206
3600
|
}
|
|
3207
3601
|
}),
|
|
3208
3602
|
inspect_background_goal: tool2({
|
|
3209
|
-
description: "Inspect a goal\u2019s full contract, runtime, and live execution state: objective, config{agent,checks,checkCwd,workspaceWrite,limits}, progress, blocker, runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,unknownStatusCount,lastActivityAt,activePromptMessageID}, plus live transcriptTail, activeToolCallIDs, progressHistory, artifactSummary, pendingInbox. Single-call follow-up for parent to see what child is actually doing.",
|
|
3603
|
+
description: "Inspect a goal\u2019s full contract, runtime, and live execution state: objective, config{agent,model,checks,checkCwd,workspaceWrite,limits}, progress, blocker, runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,unknownStatusCount,lastActivityAt,activePromptMessageID}, plus live transcriptTail, activeToolCallIDs, progressHistory, artifactSummary, pendingInbox. Single-call follow-up for parent to see what child is actually doing.",
|
|
3210
3604
|
args: {
|
|
3211
3605
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal."),
|
|
3212
3606
|
includeTranscript: tool2.schema.boolean().optional().describe("Include live transcript tail (adds ~100ms). Default true. Set false for fast metadata-only."),
|
|
@@ -3261,12 +3655,16 @@ function ownerTools(options) {
|
|
|
3261
3655
|
checkCwd: goal.config.checkCwd,
|
|
3262
3656
|
workspaceWrite: goal.config.workspaceWrite,
|
|
3263
3657
|
agent: goal.config.agent,
|
|
3658
|
+
model: goal.config.model,
|
|
3264
3659
|
schedule: goal.config.schedule
|
|
3265
3660
|
},
|
|
3266
3661
|
lastProgress: goal.lastProgress,
|
|
3267
3662
|
completionEvidence: goal.completionEvidence,
|
|
3268
3663
|
blocker: goal.blocker,
|
|
3269
3664
|
tokensUsed: goal.tokensUsed,
|
|
3665
|
+
tokenBudget: goal.tokenBudget,
|
|
3666
|
+
costUsed: goal.costUsed ?? 0,
|
|
3667
|
+
costBudget: goal.costBudget,
|
|
3270
3668
|
timeUsedSeconds: goal.timeUsedSeconds,
|
|
3271
3669
|
progressHistory,
|
|
3272
3670
|
pendingInbox,
|
|
@@ -3693,9 +4091,11 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3693
4091
|
};
|
|
3694
4092
|
function parsePluginDefaults(options) {
|
|
3695
4093
|
const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
|
|
4094
|
+
const model = typeof options?.defaultModel === "string" ? options.defaultModel.trim() : "";
|
|
3696
4095
|
const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
|
|
3697
4096
|
return {
|
|
3698
4097
|
defaultAgent: agent || undefined,
|
|
4098
|
+
defaultModel: model || undefined,
|
|
3699
4099
|
defaultChecks: checks.length > 0 ? checks : undefined
|
|
3700
4100
|
};
|
|
3701
4101
|
}
|