@bojackduy/opencode-loopd 1.8.3 → 1.9.2
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 +705 -172
- package/dist/tui.js +411 -147
- 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,10 @@ 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,
|
|
37
|
+
workerAbortedAt: undefined,
|
|
33
38
|
activePromptObservedAt: undefined,
|
|
34
39
|
activeAssistantMessageID: undefined,
|
|
35
40
|
activeAssistantCompletedAt: undefined,
|
|
@@ -479,6 +484,16 @@ function resolveGoalCreationConfig(input) {
|
|
|
479
484
|
const explicitAgent = cleanText(requested.agent);
|
|
480
485
|
const defaultAgent = cleanText(defaults.defaultAgent);
|
|
481
486
|
const agent = explicitAgent || defaultAgent || undefined;
|
|
487
|
+
const explicitModel = cleanText(requested.model);
|
|
488
|
+
const defaultModel = cleanText(defaults.defaultModel);
|
|
489
|
+
const model = explicitModel || defaultModel || undefined;
|
|
490
|
+
if (model && !isValidModelRef(model)) {
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
errorCode: "invalid_model",
|
|
494
|
+
message: `Invalid model "${model}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol", "ollama/qwen3.8:27b"). Discover with \`opencode models\`.`
|
|
495
|
+
};
|
|
496
|
+
}
|
|
482
497
|
const workspaceWrite = requested.workspaceWrite ?? true;
|
|
483
498
|
const explicitChecks = cleanList(requested.checks);
|
|
484
499
|
const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks || ["bun test"]) : [];
|
|
@@ -495,16 +510,30 @@ function resolveGoalCreationConfig(input) {
|
|
|
495
510
|
config: {
|
|
496
511
|
...requested,
|
|
497
512
|
agent,
|
|
513
|
+
model,
|
|
498
514
|
workspaceWrite,
|
|
499
515
|
checks: checks.length > 0 ? checks : undefined,
|
|
500
516
|
checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
|
|
501
517
|
},
|
|
502
518
|
defaultsApplied: {
|
|
503
519
|
agent: !explicitAgent && Boolean(defaultAgent),
|
|
520
|
+
model: !explicitModel && Boolean(defaultModel),
|
|
504
521
|
checks: explicitChecks.length === 0 && defaultChecks.length > 0
|
|
505
522
|
}
|
|
506
523
|
};
|
|
507
524
|
}
|
|
525
|
+
function isValidModelRef(value) {
|
|
526
|
+
const slash = value.indexOf("/");
|
|
527
|
+
if (slash <= 0 || slash >= value.length - 1)
|
|
528
|
+
return false;
|
|
529
|
+
const providerID = value.slice(0, slash).trim();
|
|
530
|
+
const modelID = value.slice(slash + 1).trim();
|
|
531
|
+
if (!providerID || !modelID)
|
|
532
|
+
return false;
|
|
533
|
+
if (/\s/.test(providerID) || /\s/.test(modelID))
|
|
534
|
+
return false;
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
508
537
|
function cleanText(value) {
|
|
509
538
|
if (typeof value !== "string")
|
|
510
539
|
return;
|
|
@@ -567,12 +596,17 @@ function createControlWorker(options) {
|
|
|
567
596
|
if (running)
|
|
568
597
|
return;
|
|
569
598
|
running = true;
|
|
570
|
-
processPending()
|
|
599
|
+
processPending().catch((error) => {
|
|
600
|
+
logServerEvent(directory, "control.worker.error", { detail: describeError(error) }).catch(() => {});
|
|
601
|
+
});
|
|
571
602
|
pollTimer = setInterval(() => {
|
|
572
603
|
if (running && lastProcessDone) {
|
|
573
604
|
lastProcessDone = false;
|
|
574
605
|
processPending().then(() => {
|
|
575
606
|
lastProcessDone = true;
|
|
607
|
+
}, (error) => {
|
|
608
|
+
lastProcessDone = true;
|
|
609
|
+
logServerEvent(directory, "control.worker.error", { detail: describeError(error) }).catch(() => {});
|
|
576
610
|
});
|
|
577
611
|
}
|
|
578
612
|
}, pollMs);
|
|
@@ -721,6 +755,21 @@ function createControlWorker(options) {
|
|
|
721
755
|
};
|
|
722
756
|
break;
|
|
723
757
|
}
|
|
758
|
+
case "nudge": {
|
|
759
|
+
if (!request.goalID) {
|
|
760
|
+
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
const result = await goalSvc.nudge(directory, request.goalID);
|
|
764
|
+
const state = await readState(directory);
|
|
765
|
+
response = {
|
|
766
|
+
...base,
|
|
767
|
+
ok: result.ok,
|
|
768
|
+
message: result.message,
|
|
769
|
+
stateRevision: state.revision
|
|
770
|
+
};
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
724
773
|
case "clear": {
|
|
725
774
|
await goalSvc.clear(directory, request.goalID);
|
|
726
775
|
const state = await readState(directory);
|
|
@@ -742,12 +791,27 @@ function createControlWorker(options) {
|
|
|
742
791
|
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
743
792
|
break;
|
|
744
793
|
}
|
|
745
|
-
await
|
|
794
|
+
const sent = await goalSvc.sendUserMessage(directory, request.goalID, text);
|
|
795
|
+
const state = await readState(directory);
|
|
796
|
+
response = {
|
|
797
|
+
...base,
|
|
798
|
+
ok: sent.ok,
|
|
799
|
+
message: sent.message,
|
|
800
|
+
stateRevision: state.revision
|
|
801
|
+
};
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
case "abort_worker": {
|
|
805
|
+
if (!request.goalID) {
|
|
806
|
+
response = { ...base, ok: false, message: "goalID is required", errorCode: "bad_request" };
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
const result = await goalSvc.abortWorker(directory, request.goalID);
|
|
746
810
|
const state = await readState(directory);
|
|
747
|
-
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
748
811
|
response = {
|
|
749
812
|
...base,
|
|
750
|
-
|
|
813
|
+
ok: result.ok,
|
|
814
|
+
message: result.message,
|
|
751
815
|
stateRevision: state.revision
|
|
752
816
|
};
|
|
753
817
|
break;
|
|
@@ -904,7 +968,7 @@ function isTerminal(status) {
|
|
|
904
968
|
}
|
|
905
969
|
function createGoal(input) {
|
|
906
970
|
const now = new Date().toISOString();
|
|
907
|
-
return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
971
|
+
return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
908
972
|
}
|
|
909
973
|
// src/application/loop-engine.ts
|
|
910
974
|
var CONFIRM_IDLE_DURATION_MS = 2000;
|
|
@@ -921,6 +985,9 @@ function createLoopEngine(options) {
|
|
|
921
985
|
const maintenanceMs = options.pollIntervalMs ?? 30000;
|
|
922
986
|
const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
|
|
923
987
|
const unknownStatusThreshold = Math.max(1, options.unknownStatusThreshold ?? 3);
|
|
988
|
+
const stuckRunningMs = options.stuckRunningMs ?? 10 * 60000;
|
|
989
|
+
const idleUnconfirmedMs = options.idleUnconfirmedMs ?? 5 * 60000;
|
|
990
|
+
const idleRecoverMs = options.idleRecoverMs ?? 3 * 60000;
|
|
924
991
|
let running = false;
|
|
925
992
|
let maintenanceTimer;
|
|
926
993
|
let knownWorkerSessions = new Set;
|
|
@@ -1044,6 +1111,8 @@ function createLoopEngine(options) {
|
|
|
1044
1111
|
} else {
|
|
1045
1112
|
const part = event.properties?.part;
|
|
1046
1113
|
if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
|
|
1114
|
+
if (rt.activeAssistantCompletedAt)
|
|
1115
|
+
return s;
|
|
1047
1116
|
Object.assign(rt, recordActivity(rt));
|
|
1048
1117
|
matched = true;
|
|
1049
1118
|
}
|
|
@@ -1083,7 +1152,9 @@ function createLoopEngine(options) {
|
|
|
1083
1152
|
const elapsed = now - Date.parse(rt.idleCandidateAt);
|
|
1084
1153
|
if (elapsed < confirmIdleMs)
|
|
1085
1154
|
return s;
|
|
1086
|
-
|
|
1155
|
+
const anchoredHere = Boolean(rt.activeAssistantCompletedAt);
|
|
1156
|
+
const quietSince = anchoredHere && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : rt.idleCandidateAt;
|
|
1157
|
+
if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt && rt.lastActivityAt > quietSince) {
|
|
1087
1158
|
rt.idleCandidateAt = undefined;
|
|
1088
1159
|
rt.idleCandidateGeneration = undefined;
|
|
1089
1160
|
return s;
|
|
@@ -1105,8 +1176,35 @@ function createLoopEngine(options) {
|
|
|
1105
1176
|
});
|
|
1106
1177
|
if (confirmation) {
|
|
1107
1178
|
const candidate = confirmation;
|
|
1179
|
+
const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
|
|
1180
|
+
const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
|
|
1108
1181
|
const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
|
|
1109
|
-
|
|
1182
|
+
const failureReason = !transcript.latestUserPrompt && !eventAnchored ? "prompt-outside-window" : !eventAnchored && !candidate.assistantCompleted && !transcript.assistantCompleted ? "assistant-incomplete" : undefined;
|
|
1183
|
+
if (failureReason) {
|
|
1184
|
+
let newlyStamped = false;
|
|
1185
|
+
await mutateState(directory, `idle.confirm-stamp:${goalID}`, async (s) => {
|
|
1186
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1187
|
+
if (!rt || rt.runGeneration !== candidate.generation)
|
|
1188
|
+
return s;
|
|
1189
|
+
if (rt.idleConfirmFailedGeneration !== candidate.generation) {
|
|
1190
|
+
rt.idleConfirmFailedAt = new Date().toISOString();
|
|
1191
|
+
rt.idleConfirmFailedGeneration = candidate.generation;
|
|
1192
|
+
newlyStamped = true;
|
|
1193
|
+
}
|
|
1194
|
+
return s;
|
|
1195
|
+
});
|
|
1196
|
+
if (newlyStamped) {
|
|
1197
|
+
await appendEvent(directory, {
|
|
1198
|
+
version: 1,
|
|
1199
|
+
eventID: randomUUID2(),
|
|
1200
|
+
goalID,
|
|
1201
|
+
type: "idle.confirm-failed",
|
|
1202
|
+
reason: failureReason,
|
|
1203
|
+
runGeneration: candidate.generation,
|
|
1204
|
+
timestamp: new Date().toISOString(),
|
|
1205
|
+
revision: afterIdle.revision
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1110
1208
|
return true;
|
|
1111
1209
|
}
|
|
1112
1210
|
afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
|
|
@@ -1124,14 +1222,19 @@ function createLoopEngine(options) {
|
|
|
1124
1222
|
return s;
|
|
1125
1223
|
if (rt.idleCandidateAt !== candidate.candidateAt)
|
|
1126
1224
|
return s;
|
|
1127
|
-
if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
|
|
1128
|
-
|
|
1225
|
+
if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt) {
|
|
1226
|
+
const quiet = rt.runGeneration === candidate.generation && rt.activeAssistantCompletedAt ? rt.activeAssistantCompletedAt : candidate.candidateAt;
|
|
1227
|
+
if (rt.lastActivityAt > quiet)
|
|
1228
|
+
return s;
|
|
1229
|
+
}
|
|
1129
1230
|
if ((rt.activeToolCallIDs?.length ?? 0) > 0)
|
|
1130
1231
|
return s;
|
|
1131
1232
|
completedRunID = rt.activeRunID;
|
|
1132
1233
|
Object.assign(rt, releaseLease(rt));
|
|
1133
1234
|
rt.activeRunID = undefined;
|
|
1134
1235
|
rt.lastWorkerStatus = "idle";
|
|
1236
|
+
rt.idleConfirmFailedAt = undefined;
|
|
1237
|
+
rt.idleConfirmFailedGeneration = undefined;
|
|
1135
1238
|
return s;
|
|
1136
1239
|
});
|
|
1137
1240
|
}
|
|
@@ -1174,6 +1277,12 @@ function createLoopEngine(options) {
|
|
|
1174
1277
|
return true;
|
|
1175
1278
|
recentForceFinishBlocked.set(blockedKey, nowBlocked);
|
|
1176
1279
|
let shouldNotifyBlocked = false;
|
|
1280
|
+
await goalService.accountUsage(directory, goalID).catch(() => ({
|
|
1281
|
+
tokenDelta: 0,
|
|
1282
|
+
costDelta: 0,
|
|
1283
|
+
timeDeltaSeconds: 0,
|
|
1284
|
+
counted: []
|
|
1285
|
+
}));
|
|
1177
1286
|
const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
|
|
1178
1287
|
const g = s.goals.find((item) => item.id === goalID);
|
|
1179
1288
|
if (!g)
|
|
@@ -1244,7 +1353,7 @@ function createLoopEngine(options) {
|
|
|
1244
1353
|
return noMatch;
|
|
1245
1354
|
let messages;
|
|
1246
1355
|
try {
|
|
1247
|
-
messages = await host.readMessages(workerSessionID,
|
|
1356
|
+
messages = await host.readMessages(workerSessionID, 200);
|
|
1248
1357
|
} catch {
|
|
1249
1358
|
return noMatch;
|
|
1250
1359
|
}
|
|
@@ -1402,6 +1511,16 @@ function createLoopEngine(options) {
|
|
|
1402
1511
|
reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
|
|
1403
1512
|
};
|
|
1404
1513
|
}
|
|
1514
|
+
if (typeof goal.costBudget === "number" && (goal.costUsed ?? 0) >= goal.costBudget) {
|
|
1515
|
+
goal.status = "budget_limited";
|
|
1516
|
+
goal.updatedAt = new Date().toISOString();
|
|
1517
|
+
return {
|
|
1518
|
+
stop: "budget",
|
|
1519
|
+
blocked: true,
|
|
1520
|
+
event: "goal.status_changed",
|
|
1521
|
+
reason: `Cost budget exhausted ($${(goal.costUsed ?? 0).toFixed(4)}/$${goal.costBudget})`
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1405
1524
|
return noResult;
|
|
1406
1525
|
}
|
|
1407
1526
|
function shouldCompact(goal, runtime) {
|
|
@@ -1440,6 +1559,67 @@ function createLoopEngine(options) {
|
|
|
1440
1559
|
return s;
|
|
1441
1560
|
});
|
|
1442
1561
|
}
|
|
1562
|
+
async function accountAndEnforceBudget(goal) {
|
|
1563
|
+
try {
|
|
1564
|
+
await goalService.accountUsage(directory, goal.id);
|
|
1565
|
+
} catch {}
|
|
1566
|
+
const fresh = await readState(directory);
|
|
1567
|
+
const g = fresh.goals.find((item) => item.id === goal.id);
|
|
1568
|
+
if (!g || g.status !== "active")
|
|
1569
|
+
return false;
|
|
1570
|
+
const overTokens = typeof g.tokenBudget === "number" && g.tokensUsed >= g.tokenBudget;
|
|
1571
|
+
const overCost = typeof g.costBudget === "number" && (g.costUsed ?? 0) >= g.costBudget;
|
|
1572
|
+
if (!overTokens && !overCost)
|
|
1573
|
+
return false;
|
|
1574
|
+
const reason = overCost ? `Cost budget exhausted ($${(g.costUsed ?? 0).toFixed(4)}/$${g.costBudget})` : `Token budget exhausted (${g.tokensUsed}/${g.tokenBudget})`;
|
|
1575
|
+
if (g.workerSessionID) {
|
|
1576
|
+
try {
|
|
1577
|
+
await host.abortSession(g.workerSessionID);
|
|
1578
|
+
} catch {}
|
|
1579
|
+
}
|
|
1580
|
+
let shouldNotify = false;
|
|
1581
|
+
const stoppedState = await mutateState(directory, `maintenance.budget:${goal.id}`, async (s) => {
|
|
1582
|
+
const target = s.goals.find((item) => item.id === goal.id);
|
|
1583
|
+
if (!target || target.status !== "active")
|
|
1584
|
+
return s;
|
|
1585
|
+
target.status = "budget_limited";
|
|
1586
|
+
target.updatedAt = new Date().toISOString();
|
|
1587
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1588
|
+
if (rt) {
|
|
1589
|
+
Object.assign(rt, releaseLease(rt));
|
|
1590
|
+
rt.activeRunID = undefined;
|
|
1591
|
+
rt.updatedAt = new Date().toISOString();
|
|
1592
|
+
if (shouldNotifyParent(rt, "stopped")) {
|
|
1593
|
+
markParentNotified(rt, "stopped");
|
|
1594
|
+
shouldNotify = true;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
return s;
|
|
1598
|
+
});
|
|
1599
|
+
const stoppedGoal = stoppedState.goals.find((item) => item.id === goal.id);
|
|
1600
|
+
if (stoppedGoal?.status !== "budget_limited")
|
|
1601
|
+
return false;
|
|
1602
|
+
await appendEvent(directory, {
|
|
1603
|
+
version: 1,
|
|
1604
|
+
eventID: randomUUID2(),
|
|
1605
|
+
goalID: goal.id,
|
|
1606
|
+
type: "goal.status_changed",
|
|
1607
|
+
from: "active",
|
|
1608
|
+
to: "budget_limited",
|
|
1609
|
+
timestamp: new Date().toISOString(),
|
|
1610
|
+
revision: stoppedState.revision
|
|
1611
|
+
});
|
|
1612
|
+
await logServerEvent(directory, "maintenance.budget-exhausted", {
|
|
1613
|
+
goalID: goal.id,
|
|
1614
|
+
reason,
|
|
1615
|
+
tokensUsed: stoppedGoal.tokensUsed,
|
|
1616
|
+
costUsed: stoppedGoal.costUsed
|
|
1617
|
+
});
|
|
1618
|
+
if (shouldNotify) {
|
|
1619
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${reason}. Worker aborted, status: budget_limited. Resume with resume_goal to continue spending.`);
|
|
1620
|
+
}
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1443
1623
|
async function maintenance() {
|
|
1444
1624
|
syncWorkerSessionsFromService();
|
|
1445
1625
|
if (knownWorkerSessions.size === 0)
|
|
@@ -1476,6 +1656,11 @@ function createLoopEngine(options) {
|
|
|
1476
1656
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1477
1657
|
if (!runtime)
|
|
1478
1658
|
continue;
|
|
1659
|
+
if (goal.workerSessionID) {
|
|
1660
|
+
const stopped = await accountAndEnforceBudget(goal);
|
|
1661
|
+
if (stopped)
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1479
1664
|
if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
|
|
1480
1665
|
if (Date.now() >= Date.parse(runtime.retryAfter)) {
|
|
1481
1666
|
await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
|
|
@@ -1558,6 +1743,87 @@ function createLoopEngine(options) {
|
|
|
1558
1743
|
});
|
|
1559
1744
|
await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
|
|
1560
1745
|
}
|
|
1746
|
+
if (status === "idle" && runtime.phase === "running") {
|
|
1747
|
+
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);
|
|
1748
|
+
const quietMs = Date.now() - lastSignal;
|
|
1749
|
+
if (lastSignal > 0 && quietMs > idleRecoverMs) {
|
|
1750
|
+
const generation = runtime.runGeneration;
|
|
1751
|
+
const stalledRunID = runtime.activeRunID;
|
|
1752
|
+
let clearedToolCalls = 0;
|
|
1753
|
+
let recovered = false;
|
|
1754
|
+
const recoveredState = await mutateState(directory, `maintenance.idle-recover:${goal.id}`, async (s) => {
|
|
1755
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
1756
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1757
|
+
if (!g || !rt || g.status !== "active")
|
|
1758
|
+
return s;
|
|
1759
|
+
if (rt.phase !== "running")
|
|
1760
|
+
return s;
|
|
1761
|
+
if (rt.runGeneration !== generation)
|
|
1762
|
+
return s;
|
|
1763
|
+
clearedToolCalls = rt.activeToolCallIDs?.length ?? 0;
|
|
1764
|
+
Object.assign(rt, releaseLease(rt));
|
|
1765
|
+
rt.activeRunID = undefined;
|
|
1766
|
+
rt.lastWorkerStatus = "idle";
|
|
1767
|
+
rt.idleConfirmFailedAt = undefined;
|
|
1768
|
+
rt.idleConfirmFailedGeneration = undefined;
|
|
1769
|
+
rt.idleStuckNotifiedGeneration = undefined;
|
|
1770
|
+
recovered = true;
|
|
1771
|
+
return s;
|
|
1772
|
+
});
|
|
1773
|
+
if (recovered) {
|
|
1774
|
+
await appendEvent(directory, {
|
|
1775
|
+
version: 1,
|
|
1776
|
+
eventID: randomUUID2(),
|
|
1777
|
+
goalID: goal.id,
|
|
1778
|
+
type: "run.recovered",
|
|
1779
|
+
runID: stalledRunID ?? "unknown",
|
|
1780
|
+
quietSeconds: Math.floor(quietMs / 1000),
|
|
1781
|
+
clearedToolCalls,
|
|
1782
|
+
timestamp: new Date().toISOString(),
|
|
1783
|
+
revision: recoveredState.revision
|
|
1784
|
+
});
|
|
1785
|
+
await logServerEvent(directory, "maintenance.idle-recovered", {
|
|
1786
|
+
goalID: goal.id,
|
|
1787
|
+
generation,
|
|
1788
|
+
quietSeconds: Math.floor(quietMs / 1000),
|
|
1789
|
+
clearedToolCalls
|
|
1790
|
+
});
|
|
1791
|
+
await continueGoal(goal.id);
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
if (status === "idle" && runtime.phase === "running" && runtime.idleConfirmFailedGeneration === runtime.runGeneration && runtime.idleConfirmFailedAt && runtime.idleStuckNotifiedGeneration !== runtime.runGeneration && Date.now() - Date.parse(runtime.idleConfirmFailedAt) > idleUnconfirmedMs) {
|
|
1797
|
+
const failedAt = runtime.idleConfirmFailedAt;
|
|
1798
|
+
const generation = runtime.runGeneration;
|
|
1799
|
+
const stuckState = await mutateState(directory, `maintenance.idle-stuck:${goal.id}`, async (s) => {
|
|
1800
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1801
|
+
if (!rt || rt.runGeneration !== generation || rt.phase !== "running")
|
|
1802
|
+
return s;
|
|
1803
|
+
rt.idleStuckNotifiedGeneration = generation;
|
|
1804
|
+
rt.updatedAt = new Date().toISOString();
|
|
1805
|
+
return s;
|
|
1806
|
+
});
|
|
1807
|
+
const stuckSeconds = Math.floor((Date.now() - Date.parse(failedAt)) / 1000);
|
|
1808
|
+
await appendEvent(directory, {
|
|
1809
|
+
version: 1,
|
|
1810
|
+
eventID: randomUUID2(),
|
|
1811
|
+
goalID: goal.id,
|
|
1812
|
+
type: "run.stuck",
|
|
1813
|
+
runID: runtime.activeRunID ?? "unknown",
|
|
1814
|
+
stuckSeconds,
|
|
1815
|
+
timestamp: new Date().toISOString(),
|
|
1816
|
+
revision: stuckState.revision
|
|
1817
|
+
});
|
|
1818
|
+
await logServerEvent(directory, "maintenance.idle-stuck", {
|
|
1819
|
+
goalID: goal.id,
|
|
1820
|
+
generation,
|
|
1821
|
+
stuckSeconds
|
|
1822
|
+
});
|
|
1823
|
+
const quietMinutes = Math.max(1, Math.floor(stuckSeconds / 60));
|
|
1824
|
+
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.`);
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1561
1827
|
if (status === "idle") {
|
|
1562
1828
|
if (runtime.phase === "idle") {
|
|
1563
1829
|
await continueGoal(goal.id);
|
|
@@ -1566,6 +1832,37 @@ function createLoopEngine(options) {
|
|
|
1566
1832
|
}
|
|
1567
1833
|
continue;
|
|
1568
1834
|
}
|
|
1835
|
+
if ((status === "busy" || status === "retry") && runtime.phase === "running" && runtime.activeRunID && runtime.stuckNotifiedRunID !== runtime.activeRunID) {
|
|
1836
|
+
const leaseExpired = !runtime.leaseExpiresAt || Date.now() >= Date.parse(runtime.leaseExpiresAt);
|
|
1837
|
+
const lastActive = runtime.lastActivityAt ? Date.parse(runtime.lastActivityAt) : 0;
|
|
1838
|
+
if (leaseExpired && Date.now() - lastActive > stuckRunningMs) {
|
|
1839
|
+
const stuckSeconds = Math.floor((Date.now() - lastActive) / 1000);
|
|
1840
|
+
const stuckState = await mutateState(directory, `maintenance.run-stuck:${goal.id}`, async (s) => {
|
|
1841
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1842
|
+
if (!rt || rt.activeRunID !== runtime.activeRunID)
|
|
1843
|
+
return s;
|
|
1844
|
+
rt.stuckNotifiedRunID = rt.activeRunID;
|
|
1845
|
+
rt.updatedAt = new Date().toISOString();
|
|
1846
|
+
return s;
|
|
1847
|
+
});
|
|
1848
|
+
await appendEvent(directory, {
|
|
1849
|
+
version: 1,
|
|
1850
|
+
eventID: randomUUID2(),
|
|
1851
|
+
goalID: goal.id,
|
|
1852
|
+
type: "run.stuck",
|
|
1853
|
+
runID: runtime.activeRunID,
|
|
1854
|
+
stuckSeconds,
|
|
1855
|
+
timestamp: new Date().toISOString(),
|
|
1856
|
+
revision: stuckState.revision
|
|
1857
|
+
});
|
|
1858
|
+
await logServerEvent(directory, "maintenance.run-stuck", {
|
|
1859
|
+
goalID: goal.id,
|
|
1860
|
+
runID: runtime.activeRunID,
|
|
1861
|
+
stuckSeconds
|
|
1862
|
+
});
|
|
1863
|
+
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.`);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1569
1866
|
}
|
|
1570
1867
|
}
|
|
1571
1868
|
}
|
|
@@ -1577,6 +1874,194 @@ import { randomUUID as randomUUID3 } from "crypto";
|
|
|
1577
1874
|
import * as path2 from "path";
|
|
1578
1875
|
import { promises as fs2 } from "fs";
|
|
1579
1876
|
|
|
1877
|
+
// src/server/host-adapter.ts
|
|
1878
|
+
function parseModelRef(value) {
|
|
1879
|
+
if (value === undefined)
|
|
1880
|
+
return;
|
|
1881
|
+
const trimmed = value.trim();
|
|
1882
|
+
if (!trimmed)
|
|
1883
|
+
return;
|
|
1884
|
+
const slash = trimmed.indexOf("/");
|
|
1885
|
+
if (slash <= 0 || slash >= trimmed.length - 1) {
|
|
1886
|
+
throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
|
|
1887
|
+
}
|
|
1888
|
+
const providerID = trimmed.slice(0, slash).trim();
|
|
1889
|
+
const modelID = trimmed.slice(slash + 1).trim();
|
|
1890
|
+
if (!providerID || !modelID || /\s/.test(providerID) || /\s/.test(modelID)) {
|
|
1891
|
+
throw new Error(`Invalid model "${value}". Use "providerID/modelID" (e.g. "openai/gpt-5.6-sol").`);
|
|
1892
|
+
}
|
|
1893
|
+
return { providerID, modelID };
|
|
1894
|
+
}
|
|
1895
|
+
var recentParentNotifies = new Map;
|
|
1896
|
+
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
1897
|
+
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
1898
|
+
const now = Date.now();
|
|
1899
|
+
const last = recentParentNotifies.get(key);
|
|
1900
|
+
if (last !== undefined && now - last < 60000)
|
|
1901
|
+
return true;
|
|
1902
|
+
recentParentNotifies.set(key, now);
|
|
1903
|
+
if (recentParentNotifies.size > 200) {
|
|
1904
|
+
for (const [k, t] of recentParentNotifies.entries())
|
|
1905
|
+
if (now - t > 60000)
|
|
1906
|
+
recentParentNotifies.delete(k);
|
|
1907
|
+
}
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
function createRealHost(client, directory) {
|
|
1911
|
+
return {
|
|
1912
|
+
async createWorker({ parentID, title, agent, model }) {
|
|
1913
|
+
try {
|
|
1914
|
+
const body = { parentID, title };
|
|
1915
|
+
if (agent)
|
|
1916
|
+
body.agent = agent;
|
|
1917
|
+
if (model)
|
|
1918
|
+
body.model = { id: model.modelID, providerID: model.providerID };
|
|
1919
|
+
const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
|
|
1920
|
+
const data = result?.data;
|
|
1921
|
+
if (result?.error || !data?.id) {
|
|
1922
|
+
const detail = describeError(result?.error || "response contained no session ID");
|
|
1923
|
+
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
1924
|
+
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
1925
|
+
}
|
|
1926
|
+
await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
|
|
1927
|
+
return data.id;
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
|
|
1930
|
+
throw error;
|
|
1931
|
+
const detail = describeError(error);
|
|
1932
|
+
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
1933
|
+
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
1934
|
+
}
|
|
1935
|
+
},
|
|
1936
|
+
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
1937
|
+
const body = {
|
|
1938
|
+
parts: [{ type: "text", text: prompt }]
|
|
1939
|
+
};
|
|
1940
|
+
if (messageID) {
|
|
1941
|
+
const collapsed = messageID.replace(/^(msg-)+/, "msg-");
|
|
1942
|
+
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
1943
|
+
}
|
|
1944
|
+
if (model)
|
|
1945
|
+
body.model = model;
|
|
1946
|
+
if (agent)
|
|
1947
|
+
body.agent = agent;
|
|
1948
|
+
const result = await withTimeout(client.session.promptAsync({
|
|
1949
|
+
path: { id: sessionID },
|
|
1950
|
+
body
|
|
1951
|
+
}), 1e4, "OpenCode session.promptAsync");
|
|
1952
|
+
if (result?.error) {
|
|
1953
|
+
const detail = describeError(result.error);
|
|
1954
|
+
await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
|
|
1955
|
+
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
|
|
1956
|
+
}
|
|
1957
|
+
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
1958
|
+
return { messageID: result?.data?.messageID };
|
|
1959
|
+
},
|
|
1960
|
+
async sessionStatus(sessionID) {
|
|
1961
|
+
try {
|
|
1962
|
+
const result = await client.session.status({});
|
|
1963
|
+
if (result?.error)
|
|
1964
|
+
return "unknown";
|
|
1965
|
+
const data = result?.data;
|
|
1966
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
1967
|
+
return "unknown";
|
|
1968
|
+
const status = data[sessionID];
|
|
1969
|
+
if (status === undefined || status === null)
|
|
1970
|
+
return "idle";
|
|
1971
|
+
if (typeof status !== "object" || Array.isArray(status))
|
|
1972
|
+
return "unknown";
|
|
1973
|
+
const type = status.type;
|
|
1974
|
+
if (type === "busy" || type === "retry")
|
|
1975
|
+
return type;
|
|
1976
|
+
if (type === "idle")
|
|
1977
|
+
return "idle";
|
|
1978
|
+
return "unknown";
|
|
1979
|
+
} catch {
|
|
1980
|
+
return "unknown";
|
|
1981
|
+
}
|
|
1982
|
+
},
|
|
1983
|
+
async abortSession(sessionID) {
|
|
1984
|
+
try {
|
|
1985
|
+
await client.session.abort({ path: { id: sessionID } });
|
|
1986
|
+
} catch {}
|
|
1987
|
+
},
|
|
1988
|
+
async readMessages(sessionID, limit = 10) {
|
|
1989
|
+
try {
|
|
1990
|
+
const result = await client.session.messages({
|
|
1991
|
+
path: { id: sessionID },
|
|
1992
|
+
query: { limit }
|
|
1993
|
+
});
|
|
1994
|
+
const data = result?.data;
|
|
1995
|
+
if (!Array.isArray(data))
|
|
1996
|
+
return [];
|
|
1997
|
+
return data.map((m) => {
|
|
1998
|
+
const createdMs = m.info?.time?.created;
|
|
1999
|
+
const completedMs = m.info?.time?.completed;
|
|
2000
|
+
const tokens = m.info?.tokens;
|
|
2001
|
+
return {
|
|
2002
|
+
role: m.info?.role || "assistant",
|
|
2003
|
+
content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
|
|
2004
|
+
`) || "",
|
|
2005
|
+
timestamp: completedMs || createdMs ? new Date(completedMs || createdMs).toISOString() : undefined,
|
|
2006
|
+
messageID: m.info?.id || m.id,
|
|
2007
|
+
parentMessageID: m.info?.parentID,
|
|
2008
|
+
completedAt: completedMs ? new Date(completedMs).toISOString() : undefined,
|
|
2009
|
+
tokens: tokens && typeof tokens.input === "number" ? {
|
|
2010
|
+
input: tokens.input || 0,
|
|
2011
|
+
output: tokens.output || 0,
|
|
2012
|
+
reasoning: tokens.reasoning || 0,
|
|
2013
|
+
cacheRead: tokens.cache?.read || 0,
|
|
2014
|
+
cacheWrite: tokens.cache?.write || 0
|
|
2015
|
+
} : undefined,
|
|
2016
|
+
cost: typeof m.info?.cost === "number" ? m.info.cost : undefined,
|
|
2017
|
+
durationMs: typeof createdMs === "number" && typeof completedMs === "number" && completedMs >= createdMs ? completedMs - createdMs : undefined
|
|
2018
|
+
};
|
|
2019
|
+
});
|
|
2020
|
+
} catch {
|
|
2021
|
+
return [];
|
|
2022
|
+
}
|
|
2023
|
+
},
|
|
2024
|
+
async compactSession(sessionID) {
|
|
2025
|
+
try {
|
|
2026
|
+
await client.session.compact({ sessionID });
|
|
2027
|
+
} catch {}
|
|
2028
|
+
},
|
|
2029
|
+
async notifyOwner(ownerSessionID, message) {
|
|
2030
|
+
if (shouldDedupParentNotify(ownerSessionID, message)) {
|
|
2031
|
+
await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
try {
|
|
2035
|
+
const result = await withTimeout(client.session.promptAsync({
|
|
2036
|
+
path: { id: ownerSessionID },
|
|
2037
|
+
body: { parts: [{ type: "text", text: message }] }
|
|
2038
|
+
}), 1e4, "OpenCode parent notify");
|
|
2039
|
+
if (result?.error) {
|
|
2040
|
+
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
|
|
2041
|
+
} else {
|
|
2042
|
+
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2043
|
+
}
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
async function withTimeout(promise, timeoutMs, operation) {
|
|
2051
|
+
let timer;
|
|
2052
|
+
try {
|
|
2053
|
+
return await Promise.race([
|
|
2054
|
+
promise,
|
|
2055
|
+
new Promise((_, reject) => {
|
|
2056
|
+
timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
2057
|
+
})
|
|
2058
|
+
]);
|
|
2059
|
+
} finally {
|
|
2060
|
+
if (timer)
|
|
2061
|
+
clearTimeout(timer);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
1580
2065
|
// src/server/worker-session.ts
|
|
1581
2066
|
function createWorkerManager(host) {
|
|
1582
2067
|
return {
|
|
@@ -1584,7 +2069,8 @@ function createWorkerManager(host) {
|
|
|
1584
2069
|
const workerSessionID = await host.createWorker({
|
|
1585
2070
|
parentID: goal.ownerSessionID,
|
|
1586
2071
|
title: `loopd: ${goal.name}`,
|
|
1587
|
-
agent: goal.config.agent
|
|
2072
|
+
agent: goal.config.agent,
|
|
2073
|
+
model: parseModelRef(goal.config.model)
|
|
1588
2074
|
});
|
|
1589
2075
|
return {
|
|
1590
2076
|
goalID: goal.id,
|
|
@@ -1598,7 +2084,18 @@ function createWorkerManager(host) {
|
|
|
1598
2084
|
sessionID: worker.workerSessionID,
|
|
1599
2085
|
prompt,
|
|
1600
2086
|
messageID: runtime.activePromptMessageID,
|
|
1601
|
-
agent: goal.config.agent
|
|
2087
|
+
agent: goal.config.agent,
|
|
2088
|
+
model: parseModelRef(goal.config.model)
|
|
2089
|
+
});
|
|
2090
|
+
return result;
|
|
2091
|
+
},
|
|
2092
|
+
async sendBare(worker, goal, runtime, text) {
|
|
2093
|
+
const result = await host.promptWorker({
|
|
2094
|
+
sessionID: worker.workerSessionID,
|
|
2095
|
+
prompt: text,
|
|
2096
|
+
messageID: runtime.activePromptMessageID,
|
|
2097
|
+
agent: goal.config.agent,
|
|
2098
|
+
model: parseModelRef(goal.config.model)
|
|
1602
2099
|
});
|
|
1603
2100
|
return result;
|
|
1604
2101
|
},
|
|
@@ -1832,6 +2329,8 @@ function createGoalService(host) {
|
|
|
1832
2329
|
...input.config
|
|
1833
2330
|
}
|
|
1834
2331
|
});
|
|
2332
|
+
if (typeof input.costBudget === "number")
|
|
2333
|
+
goal.costBudget = input.costBudget;
|
|
1835
2334
|
const artifactDir = goalArtifactDir(directory, id);
|
|
1836
2335
|
goal.config.artifactDir = artifactDir;
|
|
1837
2336
|
if (!goal.config.progressFile)
|
|
@@ -1940,6 +2439,63 @@ function createGoalService(host) {
|
|
|
1940
2439
|
}
|
|
1941
2440
|
return { goal, worker };
|
|
1942
2441
|
}
|
|
2442
|
+
async function accountTailUsage(directory, goalID, runtime, tail) {
|
|
2443
|
+
const seenIDs = new Set(runtime.accountedMessageIDs ?? []);
|
|
2444
|
+
let tokenDelta = 0;
|
|
2445
|
+
let costDelta = 0;
|
|
2446
|
+
let timeDeltaSeconds = 0;
|
|
2447
|
+
const counted = [];
|
|
2448
|
+
for (const m of tail) {
|
|
2449
|
+
if (m.role !== "assistant" || !m.messageID || !m.completedAt)
|
|
2450
|
+
continue;
|
|
2451
|
+
if (seenIDs.has(m.messageID))
|
|
2452
|
+
continue;
|
|
2453
|
+
seenIDs.add(m.messageID);
|
|
2454
|
+
counted.push(m.messageID);
|
|
2455
|
+
if (m.tokens) {
|
|
2456
|
+
tokenDelta += (m.tokens.input || 0) + (m.tokens.output || 0) + (m.tokens.reasoning || 0) + (m.tokens.cacheRead || 0) + (m.tokens.cacheWrite || 0);
|
|
2457
|
+
}
|
|
2458
|
+
if (typeof m.cost === "number")
|
|
2459
|
+
costDelta += m.cost;
|
|
2460
|
+
if (typeof m.durationMs === "number")
|
|
2461
|
+
timeDeltaSeconds += m.durationMs / 1000;
|
|
2462
|
+
}
|
|
2463
|
+
if (counted.length === 0)
|
|
2464
|
+
return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted };
|
|
2465
|
+
const mergedWatermark = [...runtime.accountedMessageIDs ?? [], ...counted].slice(-200);
|
|
2466
|
+
await mutateState(directory, `turn.account-usage:${goalID}`, async (s) => {
|
|
2467
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
2468
|
+
if (g) {
|
|
2469
|
+
g.tokensUsed += tokenDelta;
|
|
2470
|
+
g.costUsed = (g.costUsed ?? 0) + costDelta;
|
|
2471
|
+
g.timeUsedSeconds += timeDeltaSeconds;
|
|
2472
|
+
g.updatedAt = new Date().toISOString();
|
|
2473
|
+
}
|
|
2474
|
+
const rt = s.runtimes.find((item) => item.goalID === goalID);
|
|
2475
|
+
if (rt) {
|
|
2476
|
+
rt.turnTokensUsed = (rt.turnTokensUsed ?? 0) + tokenDelta;
|
|
2477
|
+
rt.accountedMessageIDs = mergedWatermark;
|
|
2478
|
+
rt.updatedAt = new Date().toISOString();
|
|
2479
|
+
}
|
|
2480
|
+
return s;
|
|
2481
|
+
});
|
|
2482
|
+
return { tokenDelta, costDelta, timeDeltaSeconds, counted };
|
|
2483
|
+
}
|
|
2484
|
+
async function accountUsageUnlocked(directory, goalID) {
|
|
2485
|
+
const preState = await readState(directory);
|
|
2486
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
2487
|
+
const runtime = preState.runtimes.find((r) => r.goalID === goalID);
|
|
2488
|
+
if (!goal?.workerSessionID || !runtime) {
|
|
2489
|
+
return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
|
|
2490
|
+
}
|
|
2491
|
+
let tail = [];
|
|
2492
|
+
try {
|
|
2493
|
+
tail = await host.readMessages(goal.workerSessionID, 50);
|
|
2494
|
+
} catch {
|
|
2495
|
+
return { tokenDelta: 0, costDelta: 0, timeDeltaSeconds: 0, counted: [] };
|
|
2496
|
+
}
|
|
2497
|
+
return accountTailUsage(directory, goalID, runtime, tail);
|
|
2498
|
+
}
|
|
1943
2499
|
async function continueTurnUnlocked(directory, goalID, opts) {
|
|
1944
2500
|
const preState = await readState(directory);
|
|
1945
2501
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
@@ -2001,6 +2557,20 @@ function createGoalService(host) {
|
|
|
2001
2557
|
timestamp: new Date().toISOString(),
|
|
2002
2558
|
revision: state.revision
|
|
2003
2559
|
});
|
|
2560
|
+
if (opts?.bare) {
|
|
2561
|
+
const bareWords = await drainGoalInbox(directory, goalID);
|
|
2562
|
+
const bareText = bareWords.join(`
|
|
2563
|
+
`).trim();
|
|
2564
|
+
if (bareText) {
|
|
2565
|
+
try {
|
|
2566
|
+
await workers.sendBare(session, freshGoal, freshRuntime, bareText);
|
|
2567
|
+
} catch (error) {
|
|
2568
|
+
await recordPromptFailure(directory, goalID, error);
|
|
2569
|
+
throw error;
|
|
2570
|
+
}
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2004
2574
|
const inboxMessages = await drainGoalInbox(directory, goalID);
|
|
2005
2575
|
const allEvents = await readEvents(directory, 200);
|
|
2006
2576
|
const progressHistory = allEvents.filter((e) => e.goalID === goalID && e.type === "goal.progress").map((e) => ({
|
|
@@ -2014,6 +2584,7 @@ function createGoalService(host) {
|
|
|
2014
2584
|
} catch {
|
|
2015
2585
|
transcriptTail = [];
|
|
2016
2586
|
}
|
|
2587
|
+
await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
|
|
2017
2588
|
let verification;
|
|
2018
2589
|
try {
|
|
2019
2590
|
const artifactDir = freshGoal.config.artifactDir;
|
|
@@ -2307,6 +2878,40 @@ function createGoalService(host) {
|
|
|
2307
2878
|
await continueTurnUnlocked(directory, goalID, { force: true });
|
|
2308
2879
|
return { ok: true, message: `Re-prompted worker for "${freshGoal.name}".` };
|
|
2309
2880
|
}
|
|
2881
|
+
async function sendUnlocked(directory, goalID, text) {
|
|
2882
|
+
const trimmed = text.trim();
|
|
2883
|
+
if (!trimmed)
|
|
2884
|
+
return { ok: false, message: "Nothing to send." };
|
|
2885
|
+
const preState = await readState(directory);
|
|
2886
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
2887
|
+
if (!goal)
|
|
2888
|
+
return { ok: false, message: "Goal not found." };
|
|
2889
|
+
await appendGoalInbox(directory, goalID, "user", trimmed);
|
|
2890
|
+
if (goal.status !== "active") {
|
|
2891
|
+
return { ok: true, message: `Queued for "${goal.name}" (goal is ${goal.status}; delivers on the next active turn).` };
|
|
2892
|
+
}
|
|
2893
|
+
const cleared = await mutateState(directory, `goal.send:${goalID}`, async (s) => {
|
|
2894
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2895
|
+
if (!rt)
|
|
2896
|
+
return s;
|
|
2897
|
+
rt.phase = "idle";
|
|
2898
|
+
rt.activeRunID = undefined;
|
|
2899
|
+
rt.idleCandidateAt = undefined;
|
|
2900
|
+
rt.activePromptMessageID = undefined;
|
|
2901
|
+
rt.activeToolCallIDs = [];
|
|
2902
|
+
rt.updatedAt = new Date().toISOString();
|
|
2903
|
+
return s;
|
|
2904
|
+
});
|
|
2905
|
+
const freshGoal = cleared.goals.find((g) => g.id === goalID);
|
|
2906
|
+
if (!freshGoal || !freshGoal.workerSessionID) {
|
|
2907
|
+
return { ok: true, message: `Queued for "${goal.name}" (no worker session yet; delivers on the next turn).` };
|
|
2908
|
+
}
|
|
2909
|
+
await continueTurnUnlocked(directory, goalID, { force: true, bare: true });
|
|
2910
|
+
return { ok: true, message: `Sent to "${freshGoal.name}" as its own turn.` };
|
|
2911
|
+
}
|
|
2912
|
+
function sendUserMessage(directory, goalID, text) {
|
|
2913
|
+
return withGoalOperation(goalID, () => sendUnlocked(directory, goalID, text));
|
|
2914
|
+
}
|
|
2310
2915
|
function continueTurn(directory, goalID, opts) {
|
|
2311
2916
|
return withGoalOperation(goalID, () => continueTurnUnlocked(directory, goalID, opts));
|
|
2312
2917
|
}
|
|
@@ -2325,7 +2930,48 @@ function createGoalService(host) {
|
|
|
2325
2930
|
function nudge(directory, goalID) {
|
|
2326
2931
|
return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
|
|
2327
2932
|
}
|
|
2328
|
-
|
|
2933
|
+
async function abortWorkerUnlocked(directory, goalID) {
|
|
2934
|
+
const preState = await readState(directory);
|
|
2935
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
2936
|
+
if (!goal)
|
|
2937
|
+
return { ok: false, message: "Goal not found." };
|
|
2938
|
+
const workerID = sessions.get(goalID)?.workerSessionID || goal.workerSessionID;
|
|
2939
|
+
if (!workerID)
|
|
2940
|
+
return { ok: false, message: `Goal "${goal.name}" has no worker session to abort.` };
|
|
2941
|
+
try {
|
|
2942
|
+
await workers.abortWorker(workerID);
|
|
2943
|
+
} catch {}
|
|
2944
|
+
sessions.delete(goalID);
|
|
2945
|
+
await mutateState(directory, `goal.abort-worker:${goalID}`, async (s) => {
|
|
2946
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2947
|
+
if (rt) {
|
|
2948
|
+
Object.assign(rt, releaseLease(rt));
|
|
2949
|
+
rt.activeRunID = undefined;
|
|
2950
|
+
rt.activePromptMessageID = undefined;
|
|
2951
|
+
rt.activeToolCallIDs = [];
|
|
2952
|
+
rt.idleCandidateAt = undefined;
|
|
2953
|
+
rt.idleCandidateGeneration = undefined;
|
|
2954
|
+
rt.workerAbortedAt = new Date().toISOString();
|
|
2955
|
+
rt.updatedAt = new Date().toISOString();
|
|
2956
|
+
}
|
|
2957
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
2958
|
+
if (g)
|
|
2959
|
+
g.updatedAt = new Date().toISOString();
|
|
2960
|
+
return s;
|
|
2961
|
+
});
|
|
2962
|
+
await logServerEvent(directory, "worker.aborted-manual", { goalID, workerSessionID: workerID });
|
|
2963
|
+
return {
|
|
2964
|
+
ok: true,
|
|
2965
|
+
message: `Worker run for "${goal.name}" aborted, session kept for inspection (status unchanged: ${goal.status}).` + (goal.status === "active" ? " Engine continues the same session next turn." : "")
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
function abortWorker(directory, goalID) {
|
|
2969
|
+
return withGoalOperation(goalID, () => abortWorkerUnlocked(directory, goalID));
|
|
2970
|
+
}
|
|
2971
|
+
function accountUsage(directory, goalID) {
|
|
2972
|
+
return withGoalOperation(goalID, () => accountUsageUnlocked(directory, goalID));
|
|
2973
|
+
}
|
|
2974
|
+
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile, accountUsage, abortWorker, sendUserMessage };
|
|
2329
2975
|
}
|
|
2330
2976
|
|
|
2331
2977
|
// src/application/schedule-worker.ts
|
|
@@ -2442,161 +3088,6 @@ function createScheduleWorker(options) {
|
|
|
2442
3088
|
return { start, stop, isRunning, tick };
|
|
2443
3089
|
}
|
|
2444
3090
|
|
|
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
3091
|
// src/server/goal-tools.ts
|
|
2601
3092
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
2602
3093
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
@@ -2617,11 +3108,13 @@ var execAsync = promisify(execChild);
|
|
|
2617
3108
|
function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
2618
3109
|
return {
|
|
2619
3110
|
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
|
|
3111
|
+
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
3112
|
args: {
|
|
2622
3113
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
2623
3114
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
2624
|
-
agent: tool.schema.string().optional().describe(
|
|
3115
|
+
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.`),
|
|
3116
|
+
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.`),
|
|
3117
|
+
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
3118
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
2626
3119
|
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
2627
3120
|
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 +3143,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2650
3143
|
};
|
|
2651
3144
|
if (args.agent)
|
|
2652
3145
|
config.agent = args.agent;
|
|
3146
|
+
if (args.model)
|
|
3147
|
+
config.model = args.model;
|
|
2653
3148
|
if (args.checks)
|
|
2654
3149
|
config.checks = args.checks;
|
|
2655
3150
|
if (args.checkCwd)
|
|
@@ -2690,6 +3185,16 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2690
3185
|
output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
|
|
2691
3186
|
};
|
|
2692
3187
|
}
|
|
3188
|
+
let costBudget;
|
|
3189
|
+
if (args.costBudget !== undefined) {
|
|
3190
|
+
if (typeof args.costBudget !== "number" || !Number.isFinite(args.costBudget) || args.costBudget <= 0) {
|
|
3191
|
+
return {
|
|
3192
|
+
title: "Goal not created",
|
|
3193
|
+
output: JSON.stringify({ ok: false, message: "costBudget must be a positive number of dollars", errorCode: "invalid_cost_budget" })
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
costBudget = args.costBudget;
|
|
3197
|
+
}
|
|
2693
3198
|
const resolution = resolveGoalCreationConfig({
|
|
2694
3199
|
directory: dir,
|
|
2695
3200
|
objective: args.objective,
|
|
@@ -2711,7 +3216,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2711
3216
|
name: args.name,
|
|
2712
3217
|
objective: args.objective,
|
|
2713
3218
|
ownerSessionID: sessionID,
|
|
2714
|
-
config: resolution.config
|
|
3219
|
+
config: resolution.config,
|
|
3220
|
+
costBudget
|
|
2715
3221
|
});
|
|
2716
3222
|
return {
|
|
2717
3223
|
title: "Goal created",
|
|
@@ -2721,6 +3227,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2721
3227
|
workerSessionID: worker.workerSessionID,
|
|
2722
3228
|
artifactDir: goal.config.artifactDir,
|
|
2723
3229
|
agent: resolution.config.agent,
|
|
3230
|
+
model: resolution.config.model,
|
|
3231
|
+
costBudget: goal.costBudget,
|
|
2724
3232
|
checks: resolution.config.checks || [],
|
|
2725
3233
|
workspaceWrite: resolution.config.workspaceWrite,
|
|
2726
3234
|
defaultsApplied: resolution.defaultsApplied,
|
|
@@ -2933,11 +3441,17 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2933
3441
|
evidence: args.evidence,
|
|
2934
3442
|
at: new Date().toISOString()
|
|
2935
3443
|
};
|
|
3444
|
+
const finalUsage = await goalService.accountUsage(dir, goal.id);
|
|
3445
|
+
goal.tokensUsed += finalUsage.tokenDelta;
|
|
3446
|
+
goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
|
|
3447
|
+
goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
|
|
2936
3448
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
2937
3449
|
if (runtime) {
|
|
2938
3450
|
Object.assign(runtime, releaseLease(runtime));
|
|
2939
3451
|
runtime.activeRunID = undefined;
|
|
2940
3452
|
runtime.lastError = undefined;
|
|
3453
|
+
runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
|
|
3454
|
+
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
2941
3455
|
runtime.updatedAt = new Date().toISOString();
|
|
2942
3456
|
const schedule = goal.config.schedule;
|
|
2943
3457
|
if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
|
|
@@ -3022,11 +3536,17 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3022
3536
|
needed: args.needed,
|
|
3023
3537
|
at: new Date().toISOString()
|
|
3024
3538
|
};
|
|
3539
|
+
const finalUsage = await goalService.accountUsage(dir, goal.id);
|
|
3540
|
+
goal.tokensUsed += finalUsage.tokenDelta;
|
|
3541
|
+
goal.costUsed = (goal.costUsed ?? 0) + finalUsage.costDelta;
|
|
3542
|
+
goal.timeUsedSeconds += finalUsage.timeDeltaSeconds;
|
|
3025
3543
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
3026
3544
|
if (runtime) {
|
|
3027
3545
|
Object.assign(runtime, releaseLease(runtime));
|
|
3028
3546
|
runtime.activeRunID = undefined;
|
|
3029
3547
|
runtime.lastError = undefined;
|
|
3548
|
+
runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
|
|
3549
|
+
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
3030
3550
|
runtime.updatedAt = new Date().toISOString();
|
|
3031
3551
|
}
|
|
3032
3552
|
await writeState(dir, state);
|
|
@@ -3076,6 +3596,7 @@ function formatGoalStructured(goal, runtime) {
|
|
|
3076
3596
|
checkCwd: goal.config.checkCwd,
|
|
3077
3597
|
workspaceWrite: goal.config.workspaceWrite,
|
|
3078
3598
|
agent: goal.config.agent,
|
|
3599
|
+
model: goal.config.model,
|
|
3079
3600
|
maxTurns: goal.config.maxTurns,
|
|
3080
3601
|
maxNoProgress: goal.config.maxNoProgress,
|
|
3081
3602
|
maxFailures: goal.config.maxFailures,
|
|
@@ -3087,6 +3608,9 @@ function formatGoalStructured(goal, runtime) {
|
|
|
3087
3608
|
completionEvidence: goal.completionEvidence,
|
|
3088
3609
|
blocker: goal.blocker,
|
|
3089
3610
|
tokensUsed: goal.tokensUsed,
|
|
3611
|
+
tokenBudget: goal.tokenBudget,
|
|
3612
|
+
costUsed: goal.costUsed ?? 0,
|
|
3613
|
+
costBudget: goal.costBudget,
|
|
3090
3614
|
timeUsedSeconds: goal.timeUsedSeconds
|
|
3091
3615
|
};
|
|
3092
3616
|
if (runtime) {
|
|
@@ -3186,6 +3710,8 @@ function ownerTools(options) {
|
|
|
3186
3710
|
turn: runtime?.runCount ?? 0,
|
|
3187
3711
|
budgetTurnCount: runtime?.budgetTurnCount ?? 0,
|
|
3188
3712
|
maxTurns: g.config.maxTurns,
|
|
3713
|
+
agent: g.config.agent,
|
|
3714
|
+
model: g.config.model,
|
|
3189
3715
|
lastProgress: g.lastProgress?.summary?.slice(0, 120),
|
|
3190
3716
|
lastProgressAt: g.lastProgress?.at,
|
|
3191
3717
|
blocker: g.blocker?.reason?.slice(0, 120),
|
|
@@ -3206,7 +3732,7 @@ function ownerTools(options) {
|
|
|
3206
3732
|
}
|
|
3207
3733
|
}),
|
|
3208
3734
|
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.",
|
|
3735
|
+
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
3736
|
args: {
|
|
3211
3737
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal."),
|
|
3212
3738
|
includeTranscript: tool2.schema.boolean().optional().describe("Include live transcript tail (adds ~100ms). Default true. Set false for fast metadata-only."),
|
|
@@ -3261,12 +3787,16 @@ function ownerTools(options) {
|
|
|
3261
3787
|
checkCwd: goal.config.checkCwd,
|
|
3262
3788
|
workspaceWrite: goal.config.workspaceWrite,
|
|
3263
3789
|
agent: goal.config.agent,
|
|
3790
|
+
model: goal.config.model,
|
|
3264
3791
|
schedule: goal.config.schedule
|
|
3265
3792
|
},
|
|
3266
3793
|
lastProgress: goal.lastProgress,
|
|
3267
3794
|
completionEvidence: goal.completionEvidence,
|
|
3268
3795
|
blocker: goal.blocker,
|
|
3269
3796
|
tokensUsed: goal.tokensUsed,
|
|
3797
|
+
tokenBudget: goal.tokenBudget,
|
|
3798
|
+
costUsed: goal.costUsed ?? 0,
|
|
3799
|
+
costBudget: goal.costBudget,
|
|
3270
3800
|
timeUsedSeconds: goal.timeUsedSeconds,
|
|
3271
3801
|
progressHistory,
|
|
3272
3802
|
pendingInbox,
|
|
@@ -3300,6 +3830,7 @@ function ownerTools(options) {
|
|
|
3300
3830
|
unknownStatusCount: runtime.unknownStatusCount,
|
|
3301
3831
|
lastUnknownStatusAt: runtime.lastUnknownStatusAt,
|
|
3302
3832
|
workerUnreachableNotifiedAt: runtime.workerUnreachableNotifiedAt,
|
|
3833
|
+
workerAbortedAt: runtime.workerAbortedAt,
|
|
3303
3834
|
retryAfter: runtime.retryAfter,
|
|
3304
3835
|
forceFinishRequested: runtime.forceFinishRequested,
|
|
3305
3836
|
scheduleRunCount: runtime.scheduleRunCount,
|
|
@@ -3693,9 +4224,11 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3693
4224
|
};
|
|
3694
4225
|
function parsePluginDefaults(options) {
|
|
3695
4226
|
const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
|
|
4227
|
+
const model = typeof options?.defaultModel === "string" ? options.defaultModel.trim() : "";
|
|
3696
4228
|
const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
|
|
3697
4229
|
return {
|
|
3698
4230
|
defaultAgent: agent || undefined,
|
|
4231
|
+
defaultModel: model || undefined,
|
|
3699
4232
|
defaultChecks: checks.length > 0 ? checks : undefined
|
|
3700
4233
|
};
|
|
3701
4234
|
}
|