@bojackduy/opencode-loopd 1.8.2 → 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 +648 -274
- package/dist/tui.js +294 -117
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,34 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __returnValue = (v) => v;
|
|
4
|
-
function __exportSetter(name, newValue) {
|
|
5
|
-
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
-
}
|
|
7
|
-
var __export = (target, all) => {
|
|
8
|
-
for (var name in all)
|
|
9
|
-
__defProp(target, name, {
|
|
10
|
-
get: all[name],
|
|
11
|
-
enumerable: true,
|
|
12
|
-
configurable: true,
|
|
13
|
-
set: __exportSetter.bind(all, name)
|
|
14
|
-
});
|
|
15
|
-
};
|
|
16
|
-
|
|
17
2
|
// src/domain/runtime.ts
|
|
18
|
-
var exports_runtime = {};
|
|
19
|
-
__export(exports_runtime, {
|
|
20
|
-
acquireLease: () => acquireLease,
|
|
21
|
-
addToolCall: () => addToolCall,
|
|
22
|
-
createRuntimeState: () => createRuntimeState,
|
|
23
|
-
hasActiveToolCalls: () => hasActiveToolCalls,
|
|
24
|
-
leaseIsValid: () => leaseIsValid,
|
|
25
|
-
markParentNotified: () => markParentNotified,
|
|
26
|
-
markProgress: () => markProgress,
|
|
27
|
-
recordActivity: () => recordActivity,
|
|
28
|
-
releaseLease: () => releaseLease,
|
|
29
|
-
removeToolCall: () => removeToolCall,
|
|
30
|
-
shouldNotifyParent: () => shouldNotifyParent
|
|
31
|
-
});
|
|
32
3
|
function createRuntimeState(goalID) {
|
|
33
4
|
const now = new Date().toISOString();
|
|
34
5
|
return {
|
|
@@ -40,6 +11,7 @@ function createRuntimeState(goalID) {
|
|
|
40
11
|
noProgressCount: 0,
|
|
41
12
|
progressDuringTurn: false,
|
|
42
13
|
unknownStatusCount: 0,
|
|
14
|
+
accountedMessageIDs: [],
|
|
43
15
|
runGeneration: 0,
|
|
44
16
|
createdAt: now,
|
|
45
17
|
updatedAt: now
|
|
@@ -59,6 +31,9 @@ function acquireLease(rt, timeoutMs) {
|
|
|
59
31
|
lastActivityAt: new Date(now).toISOString(),
|
|
60
32
|
idleCandidateAt: undefined,
|
|
61
33
|
idleCandidateGeneration: undefined,
|
|
34
|
+
idleConfirmFailedAt: undefined,
|
|
35
|
+
idleConfirmFailedGeneration: undefined,
|
|
36
|
+
idleStuckNotifiedGeneration: undefined,
|
|
62
37
|
activePromptObservedAt: undefined,
|
|
63
38
|
activeAssistantMessageID: undefined,
|
|
64
39
|
activeAssistantCompletedAt: undefined,
|
|
@@ -135,9 +110,6 @@ function removeToolCall(rt, callID) {
|
|
|
135
110
|
updatedAt: new Date().toISOString()
|
|
136
111
|
};
|
|
137
112
|
}
|
|
138
|
-
function hasActiveToolCalls(rt) {
|
|
139
|
-
return (rt.activeToolCallIDs?.length ?? 0) > 0;
|
|
140
|
-
}
|
|
141
113
|
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
142
114
|
|
|
143
115
|
// src/application/control-worker.ts
|
|
@@ -176,8 +148,8 @@ async function acquireLock(directory, key, operation) {
|
|
|
176
148
|
try {
|
|
177
149
|
try {
|
|
178
150
|
const raw = await fs.readFile(lockPath, "utf8");
|
|
179
|
-
const
|
|
180
|
-
const age = Date.now() - Date.parse(
|
|
151
|
+
const meta = JSON.parse(raw);
|
|
152
|
+
const age = Date.now() - Date.parse(meta.acquiredAt);
|
|
181
153
|
if (age > LOCK_STALE_MS) {
|
|
182
154
|
await fs.rm(lockPath, { force: true });
|
|
183
155
|
}
|
|
@@ -511,6 +483,16 @@ function resolveGoalCreationConfig(input) {
|
|
|
511
483
|
const explicitAgent = cleanText(requested.agent);
|
|
512
484
|
const defaultAgent = cleanText(defaults.defaultAgent);
|
|
513
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
|
+
}
|
|
514
496
|
const workspaceWrite = requested.workspaceWrite ?? true;
|
|
515
497
|
const explicitChecks = cleanList(requested.checks);
|
|
516
498
|
const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks || ["bun test"]) : [];
|
|
@@ -527,16 +509,30 @@ function resolveGoalCreationConfig(input) {
|
|
|
527
509
|
config: {
|
|
528
510
|
...requested,
|
|
529
511
|
agent,
|
|
512
|
+
model,
|
|
530
513
|
workspaceWrite,
|
|
531
514
|
checks: checks.length > 0 ? checks : undefined,
|
|
532
515
|
checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
|
|
533
516
|
},
|
|
534
517
|
defaultsApplied: {
|
|
535
518
|
agent: !explicitAgent && Boolean(defaultAgent),
|
|
519
|
+
model: !explicitModel && Boolean(defaultModel),
|
|
536
520
|
checks: explicitChecks.length === 0 && defaultChecks.length > 0
|
|
537
521
|
}
|
|
538
522
|
};
|
|
539
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
|
+
}
|
|
540
536
|
function cleanText(value) {
|
|
541
537
|
if (typeof value !== "string")
|
|
542
538
|
return;
|
|
@@ -712,54 +708,54 @@ function createControlWorker(options) {
|
|
|
712
708
|
ownerSessionID: args.ownerSessionID,
|
|
713
709
|
config: resolution.config
|
|
714
710
|
});
|
|
715
|
-
const
|
|
711
|
+
const state = await readState(directory);
|
|
716
712
|
response = {
|
|
717
713
|
...base,
|
|
718
714
|
message: `goal "${args.name}" created (${goal.id.slice(0, 8)}...)`,
|
|
719
|
-
stateRevision:
|
|
715
|
+
stateRevision: state.revision
|
|
720
716
|
};
|
|
721
717
|
break;
|
|
722
718
|
}
|
|
723
719
|
case "pause": {
|
|
724
720
|
await goalSvc.pause(directory, request.goalID);
|
|
725
|
-
const
|
|
726
|
-
const goal =
|
|
721
|
+
const state = await readState(directory);
|
|
722
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
727
723
|
response = {
|
|
728
724
|
...base,
|
|
729
725
|
message: `goal "${goal?.name || request.goalID}" paused`,
|
|
730
|
-
stateRevision:
|
|
726
|
+
stateRevision: state.revision
|
|
731
727
|
};
|
|
732
728
|
break;
|
|
733
729
|
}
|
|
734
730
|
case "resume": {
|
|
735
731
|
await goalSvc.resume(directory, request.goalID);
|
|
736
|
-
const
|
|
737
|
-
const goal =
|
|
732
|
+
const state = await readState(directory);
|
|
733
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
738
734
|
response = {
|
|
739
735
|
...base,
|
|
740
736
|
message: `goal "${goal?.name || request.goalID}" resumed`,
|
|
741
|
-
stateRevision:
|
|
737
|
+
stateRevision: state.revision
|
|
742
738
|
};
|
|
743
739
|
break;
|
|
744
740
|
}
|
|
745
741
|
case "retry": {
|
|
746
742
|
await goalSvc.retry(directory, request.goalID);
|
|
747
|
-
const
|
|
748
|
-
const goal =
|
|
743
|
+
const state = await readState(directory);
|
|
744
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
749
745
|
response = {
|
|
750
746
|
...base,
|
|
751
747
|
message: `goal "${goal?.name || request.goalID}" retried`,
|
|
752
|
-
stateRevision:
|
|
748
|
+
stateRevision: state.revision
|
|
753
749
|
};
|
|
754
750
|
break;
|
|
755
751
|
}
|
|
756
752
|
case "clear": {
|
|
757
753
|
await goalSvc.clear(directory, request.goalID);
|
|
758
|
-
const
|
|
754
|
+
const state = await readState(directory);
|
|
759
755
|
response = {
|
|
760
756
|
...base,
|
|
761
757
|
message: `goal cleared`,
|
|
762
|
-
stateRevision:
|
|
758
|
+
stateRevision: state.revision
|
|
763
759
|
};
|
|
764
760
|
break;
|
|
765
761
|
}
|
|
@@ -775,25 +771,25 @@ function createControlWorker(options) {
|
|
|
775
771
|
break;
|
|
776
772
|
}
|
|
777
773
|
await appendGoalInbox(directory, request.goalID, "user", text);
|
|
778
|
-
const
|
|
779
|
-
const goal =
|
|
774
|
+
const state = await readState(directory);
|
|
775
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
780
776
|
response = {
|
|
781
777
|
...base,
|
|
782
778
|
message: `sent to "${goal?.name || request.goalID}"`,
|
|
783
|
-
stateRevision:
|
|
779
|
+
stateRevision: state.revision
|
|
784
780
|
};
|
|
785
781
|
break;
|
|
786
782
|
}
|
|
787
783
|
case "force_complete": {
|
|
788
784
|
const args = request.args;
|
|
789
|
-
const
|
|
790
|
-
const goal =
|
|
785
|
+
const state = await readState(directory);
|
|
786
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
791
787
|
if (!goal) {
|
|
792
788
|
response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
|
|
793
789
|
break;
|
|
794
790
|
}
|
|
795
791
|
if (goal.status === "complete") {
|
|
796
|
-
response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision:
|
|
792
|
+
response = { ...base, message: `goal "${goal.name}" already complete`, stateRevision: state.revision };
|
|
797
793
|
break;
|
|
798
794
|
}
|
|
799
795
|
goal.status = "complete";
|
|
@@ -803,14 +799,14 @@ function createControlWorker(options) {
|
|
|
803
799
|
evidence: String(args.evidence || "Manual override \u2014 no verification checks run."),
|
|
804
800
|
at: new Date().toISOString()
|
|
805
801
|
};
|
|
806
|
-
const runtime =
|
|
802
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
807
803
|
if (runtime) {
|
|
808
804
|
Object.assign(runtime, releaseLease(runtime));
|
|
809
805
|
runtime.activeRunID = undefined;
|
|
810
806
|
runtime.lastError = undefined;
|
|
811
807
|
runtime.updatedAt = new Date().toISOString();
|
|
812
808
|
}
|
|
813
|
-
await writeState(directory,
|
|
809
|
+
await writeState(directory, state);
|
|
814
810
|
await appendEvent(directory, {
|
|
815
811
|
version: 1,
|
|
816
812
|
eventID: randomUUID(),
|
|
@@ -819,22 +815,22 @@ function createControlWorker(options) {
|
|
|
819
815
|
summary: goal.completionEvidence.summary,
|
|
820
816
|
evidence: goal.completionEvidence.evidence,
|
|
821
817
|
timestamp: new Date().toISOString(),
|
|
822
|
-
revision:
|
|
818
|
+
revision: state.revision
|
|
823
819
|
});
|
|
824
|
-
response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision:
|
|
820
|
+
response = { ...base, message: `goal "${goal.name}" force-completed`, stateRevision: state.revision };
|
|
825
821
|
break;
|
|
826
822
|
}
|
|
827
823
|
case "force_block":
|
|
828
824
|
case "block": {
|
|
829
825
|
const args = request.args;
|
|
830
|
-
const
|
|
831
|
-
const goal =
|
|
826
|
+
const state = await readState(directory);
|
|
827
|
+
const goal = state.goals.find((g) => g.id === request.goalID);
|
|
832
828
|
if (!goal) {
|
|
833
829
|
response = { ...base, ok: false, message: "goal not found", errorCode: "not_found" };
|
|
834
830
|
break;
|
|
835
831
|
}
|
|
836
832
|
if (goal.status === "blocked") {
|
|
837
|
-
response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision:
|
|
833
|
+
response = { ...base, message: `goal "${goal.name}" already blocked`, stateRevision: state.revision };
|
|
838
834
|
break;
|
|
839
835
|
}
|
|
840
836
|
goal.status = "blocked";
|
|
@@ -844,14 +840,14 @@ function createControlWorker(options) {
|
|
|
844
840
|
needed: String(args.needed || "User intervention required."),
|
|
845
841
|
at: new Date().toISOString()
|
|
846
842
|
};
|
|
847
|
-
const runtime =
|
|
843
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
848
844
|
if (runtime) {
|
|
849
845
|
Object.assign(runtime, releaseLease(runtime));
|
|
850
846
|
runtime.activeRunID = undefined;
|
|
851
847
|
runtime.lastError = undefined;
|
|
852
848
|
runtime.updatedAt = new Date().toISOString();
|
|
853
849
|
}
|
|
854
|
-
await writeState(directory,
|
|
850
|
+
await writeState(directory, state);
|
|
855
851
|
await appendEvent(directory, {
|
|
856
852
|
version: 1,
|
|
857
853
|
eventID: randomUUID(),
|
|
@@ -860,9 +856,9 @@ function createControlWorker(options) {
|
|
|
860
856
|
reason: goal.blocker.reason,
|
|
861
857
|
needed: goal.blocker.needed,
|
|
862
858
|
timestamp: new Date().toISOString(),
|
|
863
|
-
revision:
|
|
859
|
+
revision: state.revision
|
|
864
860
|
});
|
|
865
|
-
response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision:
|
|
861
|
+
response = { ...base, message: `goal "${goal.name}" blocked`, stateRevision: state.revision };
|
|
866
862
|
break;
|
|
867
863
|
}
|
|
868
864
|
default: {
|
|
@@ -878,8 +874,8 @@ function createControlWorker(options) {
|
|
|
878
874
|
await recordInLedger(directory, request);
|
|
879
875
|
return response;
|
|
880
876
|
}
|
|
881
|
-
async function recordInLedger(
|
|
882
|
-
const state = await readState(
|
|
877
|
+
async function recordInLedger(directory, request) {
|
|
878
|
+
const state = await readState(directory);
|
|
883
879
|
if (!state.commandLedger)
|
|
884
880
|
state.commandLedger = [];
|
|
885
881
|
state.commandLedger.push({
|
|
@@ -892,7 +888,7 @@ function createControlWorker(options) {
|
|
|
892
888
|
if (state.commandLedger.length > MAX_LEDGER_SIZE) {
|
|
893
889
|
state.commandLedger = state.commandLedger.slice(-MAX_LEDGER_SIZE);
|
|
894
890
|
}
|
|
895
|
-
await writeState(
|
|
891
|
+
await writeState(directory, state);
|
|
896
892
|
}
|
|
897
893
|
return { start, stop: async () => {
|
|
898
894
|
await stop();
|
|
@@ -936,7 +932,7 @@ function isTerminal(status) {
|
|
|
936
932
|
}
|
|
937
933
|
function createGoal(input) {
|
|
938
934
|
const now = new Date().toISOString();
|
|
939
|
-
return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
935
|
+
return { ...input, tokensUsed: 0, costUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
940
936
|
}
|
|
941
937
|
// src/application/loop-engine.ts
|
|
942
938
|
var CONFIRM_IDLE_DURATION_MS = 2000;
|
|
@@ -953,6 +949,9 @@ function createLoopEngine(options) {
|
|
|
953
949
|
const maintenanceMs = options.pollIntervalMs ?? 30000;
|
|
954
950
|
const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
|
|
955
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;
|
|
956
955
|
let running = false;
|
|
957
956
|
let maintenanceTimer;
|
|
958
957
|
let knownWorkerSessions = new Set;
|
|
@@ -1076,6 +1075,8 @@ function createLoopEngine(options) {
|
|
|
1076
1075
|
} else {
|
|
1077
1076
|
const part = event.properties?.part;
|
|
1078
1077
|
if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
|
|
1078
|
+
if (rt.activeAssistantCompletedAt)
|
|
1079
|
+
return s;
|
|
1079
1080
|
Object.assign(rt, recordActivity(rt));
|
|
1080
1081
|
matched = true;
|
|
1081
1082
|
}
|
|
@@ -1115,7 +1116,9 @@ function createLoopEngine(options) {
|
|
|
1115
1116
|
const elapsed = now - Date.parse(rt.idleCandidateAt);
|
|
1116
1117
|
if (elapsed < confirmIdleMs)
|
|
1117
1118
|
return s;
|
|
1118
|
-
|
|
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) {
|
|
1119
1122
|
rt.idleCandidateAt = undefined;
|
|
1120
1123
|
rt.idleCandidateGeneration = undefined;
|
|
1121
1124
|
return s;
|
|
@@ -1137,8 +1140,35 @@ function createLoopEngine(options) {
|
|
|
1137
1140
|
});
|
|
1138
1141
|
if (confirmation) {
|
|
1139
1142
|
const candidate = confirmation;
|
|
1143
|
+
const stagedRt = afterIdle.runtimes.find((r) => r.goalID === goalID);
|
|
1144
|
+
const eventAnchored = stagedRt?.runGeneration === candidate.generation && Boolean(stagedRt?.activeAssistantCompletedAt);
|
|
1140
1145
|
const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
|
|
1141
|
-
|
|
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
|
+
}
|
|
1142
1172
|
return true;
|
|
1143
1173
|
}
|
|
1144
1174
|
afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
|
|
@@ -1156,14 +1186,19 @@ function createLoopEngine(options) {
|
|
|
1156
1186
|
return s;
|
|
1157
1187
|
if (rt.idleCandidateAt !== candidate.candidateAt)
|
|
1158
1188
|
return s;
|
|
1159
|
-
if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
|
|
1160
|
-
|
|
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
|
+
}
|
|
1161
1194
|
if ((rt.activeToolCallIDs?.length ?? 0) > 0)
|
|
1162
1195
|
return s;
|
|
1163
1196
|
completedRunID = rt.activeRunID;
|
|
1164
1197
|
Object.assign(rt, releaseLease(rt));
|
|
1165
1198
|
rt.activeRunID = undefined;
|
|
1166
1199
|
rt.lastWorkerStatus = "idle";
|
|
1200
|
+
rt.idleConfirmFailedAt = undefined;
|
|
1201
|
+
rt.idleConfirmFailedGeneration = undefined;
|
|
1167
1202
|
return s;
|
|
1168
1203
|
});
|
|
1169
1204
|
}
|
|
@@ -1206,6 +1241,12 @@ function createLoopEngine(options) {
|
|
|
1206
1241
|
return true;
|
|
1207
1242
|
recentForceFinishBlocked.set(blockedKey, nowBlocked);
|
|
1208
1243
|
let shouldNotifyBlocked = false;
|
|
1244
|
+
await goalService.accountUsage(directory, goalID).catch(() => ({
|
|
1245
|
+
tokenDelta: 0,
|
|
1246
|
+
costDelta: 0,
|
|
1247
|
+
timeDeltaSeconds: 0,
|
|
1248
|
+
counted: []
|
|
1249
|
+
}));
|
|
1209
1250
|
const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
|
|
1210
1251
|
const g = s.goals.find((item) => item.id === goalID);
|
|
1211
1252
|
if (!g)
|
|
@@ -1276,7 +1317,7 @@ function createLoopEngine(options) {
|
|
|
1276
1317
|
return noMatch;
|
|
1277
1318
|
let messages;
|
|
1278
1319
|
try {
|
|
1279
|
-
messages = await host.readMessages(workerSessionID,
|
|
1320
|
+
messages = await host.readMessages(workerSessionID, 200);
|
|
1280
1321
|
} catch {
|
|
1281
1322
|
return noMatch;
|
|
1282
1323
|
}
|
|
@@ -1434,6 +1475,16 @@ function createLoopEngine(options) {
|
|
|
1434
1475
|
reason: `Token budget exhausted (${goal.tokensUsed}/${goal.tokenBudget})`
|
|
1435
1476
|
};
|
|
1436
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
|
+
}
|
|
1437
1488
|
return noResult;
|
|
1438
1489
|
}
|
|
1439
1490
|
function shouldCompact(goal, runtime) {
|
|
@@ -1472,6 +1523,67 @@ function createLoopEngine(options) {
|
|
|
1472
1523
|
return s;
|
|
1473
1524
|
});
|
|
1474
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
|
+
}
|
|
1475
1587
|
async function maintenance() {
|
|
1476
1588
|
syncWorkerSessionsFromService();
|
|
1477
1589
|
if (knownWorkerSessions.size === 0)
|
|
@@ -1508,6 +1620,11 @@ function createLoopEngine(options) {
|
|
|
1508
1620
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1509
1621
|
if (!runtime)
|
|
1510
1622
|
continue;
|
|
1623
|
+
if (goal.workerSessionID) {
|
|
1624
|
+
const stopped = await accountAndEnforceBudget(goal);
|
|
1625
|
+
if (stopped)
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1511
1628
|
if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
|
|
1512
1629
|
if (Date.now() >= Date.parse(runtime.retryAfter)) {
|
|
1513
1630
|
await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
|
|
@@ -1590,6 +1707,87 @@ function createLoopEngine(options) {
|
|
|
1590
1707
|
});
|
|
1591
1708
|
await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
|
|
1592
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
|
+
}
|
|
1593
1791
|
if (status === "idle") {
|
|
1594
1792
|
if (runtime.phase === "idle") {
|
|
1595
1793
|
await continueGoal(goal.id);
|
|
@@ -1598,6 +1796,37 @@ function createLoopEngine(options) {
|
|
|
1598
1796
|
}
|
|
1599
1797
|
continue;
|
|
1600
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
|
+
}
|
|
1601
1830
|
}
|
|
1602
1831
|
}
|
|
1603
1832
|
}
|
|
@@ -1609,6 +1838,194 @@ import { randomUUID as randomUUID3 } from "crypto";
|
|
|
1609
1838
|
import * as path2 from "path";
|
|
1610
1839
|
import { promises as fs2 } from "fs";
|
|
1611
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
|
+
|
|
1612
2029
|
// src/server/worker-session.ts
|
|
1613
2030
|
function createWorkerManager(host) {
|
|
1614
2031
|
return {
|
|
@@ -1616,7 +2033,8 @@ function createWorkerManager(host) {
|
|
|
1616
2033
|
const workerSessionID = await host.createWorker({
|
|
1617
2034
|
parentID: goal.ownerSessionID,
|
|
1618
2035
|
title: `loopd: ${goal.name}`,
|
|
1619
|
-
agent: goal.config.agent
|
|
2036
|
+
agent: goal.config.agent,
|
|
2037
|
+
model: parseModelRef(goal.config.model)
|
|
1620
2038
|
});
|
|
1621
2039
|
return {
|
|
1622
2040
|
goalID: goal.id,
|
|
@@ -1630,7 +2048,8 @@ function createWorkerManager(host) {
|
|
|
1630
2048
|
sessionID: worker.workerSessionID,
|
|
1631
2049
|
prompt,
|
|
1632
2050
|
messageID: runtime.activePromptMessageID,
|
|
1633
|
-
agent: goal.config.agent
|
|
2051
|
+
agent: goal.config.agent,
|
|
2052
|
+
model: parseModelRef(goal.config.model)
|
|
1634
2053
|
});
|
|
1635
2054
|
return result;
|
|
1636
2055
|
},
|
|
@@ -1864,6 +2283,8 @@ function createGoalService(host) {
|
|
|
1864
2283
|
...input.config
|
|
1865
2284
|
}
|
|
1866
2285
|
});
|
|
2286
|
+
if (typeof input.costBudget === "number")
|
|
2287
|
+
goal.costBudget = input.costBudget;
|
|
1867
2288
|
const artifactDir = goalArtifactDir(directory, id);
|
|
1868
2289
|
goal.config.artifactDir = artifactDir;
|
|
1869
2290
|
if (!goal.config.progressFile)
|
|
@@ -1879,9 +2300,9 @@ function createGoalService(host) {
|
|
|
1879
2300
|
rt.lastScheduleAt = undefined;
|
|
1880
2301
|
}
|
|
1881
2302
|
state.runtimes.push(rt);
|
|
1882
|
-
const
|
|
1883
|
-
if (
|
|
1884
|
-
|
|
2303
|
+
const runtime = state.runtimes.find((r) => r.goalID === id);
|
|
2304
|
+
if (runtime)
|
|
2305
|
+
runtime.phase = "queued";
|
|
1885
2306
|
return state;
|
|
1886
2307
|
});
|
|
1887
2308
|
let runtime = state1.runtimes.find((r) => r.goalID === id);
|
|
@@ -1972,6 +2393,63 @@ function createGoalService(host) {
|
|
|
1972
2393
|
}
|
|
1973
2394
|
return { goal, worker };
|
|
1974
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
|
+
}
|
|
1975
2453
|
async function continueTurnUnlocked(directory, goalID, opts) {
|
|
1976
2454
|
const preState = await readState(directory);
|
|
1977
2455
|
const goal = preState.goals.find((g) => g.id === goalID);
|
|
@@ -2046,6 +2524,7 @@ function createGoalService(host) {
|
|
|
2046
2524
|
} catch {
|
|
2047
2525
|
transcriptTail = [];
|
|
2048
2526
|
}
|
|
2527
|
+
await accountTailUsage(directory, goalID, freshRuntime, transcriptTail ?? []);
|
|
2049
2528
|
let verification;
|
|
2050
2529
|
try {
|
|
2051
2530
|
const artifactDir = freshGoal.config.artifactDir;
|
|
@@ -2122,17 +2601,17 @@ function createGoalService(host) {
|
|
|
2122
2601
|
}
|
|
2123
2602
|
async function resumeUnlocked(directory, goalID) {
|
|
2124
2603
|
let resumed = false;
|
|
2125
|
-
const state = await mutateState(directory, `goal.resume:${goalID}`, async (
|
|
2126
|
-
const
|
|
2127
|
-
if (!
|
|
2128
|
-
return
|
|
2129
|
-
if (!canTransition(
|
|
2130
|
-
return
|
|
2131
|
-
assertWorkspaceWriteAvailable(
|
|
2132
|
-
|
|
2133
|
-
|
|
2604
|
+
const state = await mutateState(directory, `goal.resume:${goalID}`, async (state) => {
|
|
2605
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
2606
|
+
if (!goal)
|
|
2607
|
+
return state;
|
|
2608
|
+
if (!canTransition(goal.status, "active", "user"))
|
|
2609
|
+
return state;
|
|
2610
|
+
assertWorkspaceWriteAvailable(state, goal);
|
|
2611
|
+
goal.status = "active";
|
|
2612
|
+
goal.updatedAt = new Date().toISOString();
|
|
2134
2613
|
resumed = true;
|
|
2135
|
-
return
|
|
2614
|
+
return state;
|
|
2136
2615
|
});
|
|
2137
2616
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2138
2617
|
if (!goal || !resumed)
|
|
@@ -2162,15 +2641,15 @@ function createGoalService(host) {
|
|
|
2162
2641
|
}
|
|
2163
2642
|
async function retryUnlocked(directory, goalID) {
|
|
2164
2643
|
let retried = false;
|
|
2165
|
-
const state = await mutateState(directory, `goal.retry:${goalID}`, async (
|
|
2166
|
-
const
|
|
2167
|
-
if (!
|
|
2168
|
-
return
|
|
2169
|
-
assertWorkspaceWriteAvailable(
|
|
2170
|
-
|
|
2171
|
-
|
|
2644
|
+
const state = await mutateState(directory, `goal.retry:${goalID}`, async (state) => {
|
|
2645
|
+
const goal = state.goals.find((g) => g.id === goalID);
|
|
2646
|
+
if (!goal || goal.status !== "blocked")
|
|
2647
|
+
return state;
|
|
2648
|
+
assertWorkspaceWriteAvailable(state, goal);
|
|
2649
|
+
goal.status = "active";
|
|
2650
|
+
goal.updatedAt = new Date().toISOString();
|
|
2172
2651
|
retried = true;
|
|
2173
|
-
const runtime =
|
|
2652
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
2174
2653
|
if (runtime) {
|
|
2175
2654
|
runtime.consecutiveFailures = 0;
|
|
2176
2655
|
runtime.lastError = undefined;
|
|
@@ -2180,7 +2659,7 @@ function createGoalService(host) {
|
|
|
2180
2659
|
runtime.phase = "idle";
|
|
2181
2660
|
runtime.updatedAt = new Date().toISOString();
|
|
2182
2661
|
}
|
|
2183
|
-
return
|
|
2662
|
+
return state;
|
|
2184
2663
|
});
|
|
2185
2664
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2186
2665
|
if (!goal || !retried)
|
|
@@ -2357,7 +2836,10 @@ function createGoalService(host) {
|
|
|
2357
2836
|
function nudge(directory, goalID) {
|
|
2358
2837
|
return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
|
|
2359
2838
|
}
|
|
2360
|
-
|
|
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 };
|
|
2361
2843
|
}
|
|
2362
2844
|
|
|
2363
2845
|
// src/application/schedule-worker.ts
|
|
@@ -2474,155 +2956,6 @@ function createScheduleWorker(options) {
|
|
|
2474
2956
|
return { start, stop, isRunning, tick };
|
|
2475
2957
|
}
|
|
2476
2958
|
|
|
2477
|
-
// src/server/host-adapter.ts
|
|
2478
|
-
var recentParentNotifies = new Map;
|
|
2479
|
-
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
2480
|
-
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
2481
|
-
const now = Date.now();
|
|
2482
|
-
const last = recentParentNotifies.get(key);
|
|
2483
|
-
if (last !== undefined && now - last < 60000)
|
|
2484
|
-
return true;
|
|
2485
|
-
recentParentNotifies.set(key, now);
|
|
2486
|
-
if (recentParentNotifies.size > 200) {
|
|
2487
|
-
for (const [k, t] of recentParentNotifies.entries())
|
|
2488
|
-
if (now - t > 60000)
|
|
2489
|
-
recentParentNotifies.delete(k);
|
|
2490
|
-
}
|
|
2491
|
-
return false;
|
|
2492
|
-
}
|
|
2493
|
-
function createRealHost(client, directory) {
|
|
2494
|
-
return {
|
|
2495
|
-
async createWorker({ parentID, title, agent }) {
|
|
2496
|
-
try {
|
|
2497
|
-
const body = { parentID, title };
|
|
2498
|
-
if (agent)
|
|
2499
|
-
body.agent = agent;
|
|
2500
|
-
const result = await withTimeout(client.session.create({ body }), 1e4, "OpenCode session.create");
|
|
2501
|
-
const data = result?.data;
|
|
2502
|
-
if (result?.error || !data?.id) {
|
|
2503
|
-
const detail = describeError(result?.error || "response contained no session ID");
|
|
2504
|
-
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
2505
|
-
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
2506
|
-
}
|
|
2507
|
-
await logServerEvent(directory, "worker.created", { parentID, workerSessionID: data.id, title });
|
|
2508
|
-
return data.id;
|
|
2509
|
-
} catch (error) {
|
|
2510
|
-
if (error instanceof Error && error.message.startsWith("OpenCode session.create failed"))
|
|
2511
|
-
throw error;
|
|
2512
|
-
const detail = describeError(error);
|
|
2513
|
-
await logServerEvent(directory, "worker.create.failed", { parentID, title, detail });
|
|
2514
|
-
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
2515
|
-
}
|
|
2516
|
-
},
|
|
2517
|
-
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
2518
|
-
const body = {
|
|
2519
|
-
parts: [{ type: "text", text: prompt }]
|
|
2520
|
-
};
|
|
2521
|
-
if (messageID) {
|
|
2522
|
-
const collapsed = messageID.replace(/^(msg-)+/, "msg-");
|
|
2523
|
-
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
2524
|
-
}
|
|
2525
|
-
if (model)
|
|
2526
|
-
body.model = model;
|
|
2527
|
-
if (agent)
|
|
2528
|
-
body.agent = agent;
|
|
2529
|
-
const result = await withTimeout(client.session.promptAsync({
|
|
2530
|
-
path: { id: sessionID },
|
|
2531
|
-
body
|
|
2532
|
-
}), 1e4, "OpenCode session.promptAsync");
|
|
2533
|
-
if (result?.error) {
|
|
2534
|
-
const detail = describeError(result.error);
|
|
2535
|
-
await logServerEvent(directory, "worker.prompt.failed", { sessionID, detail });
|
|
2536
|
-
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
|
|
2537
|
-
}
|
|
2538
|
-
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2539
|
-
return { messageID: result?.data?.messageID };
|
|
2540
|
-
},
|
|
2541
|
-
async sessionStatus(sessionID) {
|
|
2542
|
-
try {
|
|
2543
|
-
const result = await client.session.status({});
|
|
2544
|
-
const data = result?.data;
|
|
2545
|
-
if (!data || typeof data !== "object")
|
|
2546
|
-
return "unknown";
|
|
2547
|
-
const status = data[sessionID];
|
|
2548
|
-
if (!status || typeof status !== "object")
|
|
2549
|
-
return "unknown";
|
|
2550
|
-
const type = status.type;
|
|
2551
|
-
if (type === "busy" || type === "retry")
|
|
2552
|
-
return type;
|
|
2553
|
-
return "idle";
|
|
2554
|
-
} catch {
|
|
2555
|
-
return "unknown";
|
|
2556
|
-
}
|
|
2557
|
-
},
|
|
2558
|
-
async abortSession(sessionID) {
|
|
2559
|
-
try {
|
|
2560
|
-
await client.session.abort({ path: { id: sessionID } });
|
|
2561
|
-
} catch {}
|
|
2562
|
-
},
|
|
2563
|
-
async readMessages(sessionID, limit = 10) {
|
|
2564
|
-
try {
|
|
2565
|
-
const result = await client.session.messages({
|
|
2566
|
-
path: { id: sessionID },
|
|
2567
|
-
query: { limit }
|
|
2568
|
-
});
|
|
2569
|
-
const data = result?.data;
|
|
2570
|
-
if (!Array.isArray(data))
|
|
2571
|
-
return [];
|
|
2572
|
-
return data.map((m) => ({
|
|
2573
|
-
role: m.info?.role || "assistant",
|
|
2574
|
-
content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
|
|
2575
|
-
`) || "",
|
|
2576
|
-
timestamp: m.info?.time?.completed || m.info?.time?.created ? new Date(m.info.time.completed || m.info.time.created).toISOString() : undefined,
|
|
2577
|
-
messageID: m.info?.id || m.id,
|
|
2578
|
-
parentMessageID: m.info?.parentID,
|
|
2579
|
-
completedAt: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined
|
|
2580
|
-
}));
|
|
2581
|
-
} catch {
|
|
2582
|
-
return [];
|
|
2583
|
-
}
|
|
2584
|
-
},
|
|
2585
|
-
async compactSession(sessionID) {
|
|
2586
|
-
try {
|
|
2587
|
-
await client.session.compact({ sessionID });
|
|
2588
|
-
} catch {}
|
|
2589
|
-
},
|
|
2590
|
-
async notifyOwner(ownerSessionID, message) {
|
|
2591
|
-
if (shouldDedupParentNotify(ownerSessionID, message)) {
|
|
2592
|
-
await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2593
|
-
return;
|
|
2594
|
-
}
|
|
2595
|
-
try {
|
|
2596
|
-
const result = await withTimeout(client.session.promptAsync({
|
|
2597
|
-
path: { id: ownerSessionID },
|
|
2598
|
-
body: { parts: [{ type: "text", text: message }] }
|
|
2599
|
-
}), 1e4, "OpenCode parent notify");
|
|
2600
|
-
if (result?.error) {
|
|
2601
|
-
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(result.error) });
|
|
2602
|
-
} else {
|
|
2603
|
-
await logServerEvent(directory, "parent.notified", { ownerSessionID, preview: message.slice(0, 160) });
|
|
2604
|
-
}
|
|
2605
|
-
} catch (error) {
|
|
2606
|
-
await logServerEvent(directory, "parent.notify.failed", { ownerSessionID, detail: describeError(error) });
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
};
|
|
2610
|
-
}
|
|
2611
|
-
async function withTimeout(promise, timeoutMs, operation) {
|
|
2612
|
-
let timer;
|
|
2613
|
-
try {
|
|
2614
|
-
return await Promise.race([
|
|
2615
|
-
promise,
|
|
2616
|
-
new Promise((_, reject) => {
|
|
2617
|
-
timer = setTimeout(() => reject(new Error(`${operation} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
2618
|
-
})
|
|
2619
|
-
]);
|
|
2620
|
-
} finally {
|
|
2621
|
-
if (timer)
|
|
2622
|
-
clearTimeout(timer);
|
|
2623
|
-
}
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
2959
|
// src/server/goal-tools.ts
|
|
2627
2960
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
2628
2961
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
@@ -2643,11 +2976,13 @@ var execAsync = promisify(execChild);
|
|
|
2643
2976
|
function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
2644
2977
|
return {
|
|
2645
2978
|
loopd_create_goal: tool({
|
|
2646
|
-
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.",
|
|
2647
2980
|
args: {
|
|
2648
2981
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
2649
2982
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
2650
|
-
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."),
|
|
2651
2986
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
2652
2987
|
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
2653
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."),
|
|
@@ -2676,6 +3011,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2676
3011
|
};
|
|
2677
3012
|
if (args.agent)
|
|
2678
3013
|
config.agent = args.agent;
|
|
3014
|
+
if (args.model)
|
|
3015
|
+
config.model = args.model;
|
|
2679
3016
|
if (args.checks)
|
|
2680
3017
|
config.checks = args.checks;
|
|
2681
3018
|
if (args.checkCwd)
|
|
@@ -2716,6 +3053,16 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2716
3053
|
output: JSON.stringify({ ok: false, message: "scheduleMaxRuns requires scheduleEveryMs", errorCode: "invalid_schedule" })
|
|
2717
3054
|
};
|
|
2718
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
|
+
}
|
|
2719
3066
|
const resolution = resolveGoalCreationConfig({
|
|
2720
3067
|
directory: dir,
|
|
2721
3068
|
objective: args.objective,
|
|
@@ -2737,7 +3084,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2737
3084
|
name: args.name,
|
|
2738
3085
|
objective: args.objective,
|
|
2739
3086
|
ownerSessionID: sessionID,
|
|
2740
|
-
config: resolution.config
|
|
3087
|
+
config: resolution.config,
|
|
3088
|
+
costBudget
|
|
2741
3089
|
});
|
|
2742
3090
|
return {
|
|
2743
3091
|
title: "Goal created",
|
|
@@ -2747,6 +3095,8 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2747
3095
|
workerSessionID: worker.workerSessionID,
|
|
2748
3096
|
artifactDir: goal.config.artifactDir,
|
|
2749
3097
|
agent: resolution.config.agent,
|
|
3098
|
+
model: resolution.config.model,
|
|
3099
|
+
costBudget: goal.costBudget,
|
|
2750
3100
|
checks: resolution.config.checks || [],
|
|
2751
3101
|
workspaceWrite: resolution.config.workspaceWrite,
|
|
2752
3102
|
defaultsApplied: resolution.defaultsApplied,
|
|
@@ -2861,9 +3211,9 @@ function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
|
2861
3211
|
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
2862
3212
|
const checkResults = await runCompletionChecks(goal.config.checks, cwd);
|
|
2863
3213
|
if (!checkResults.passed) {
|
|
2864
|
-
const
|
|
2865
|
-
if (
|
|
2866
|
-
|
|
3214
|
+
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
3215
|
+
if (runtime) {
|
|
3216
|
+
runtime.evaluatorRejectionCount = (runtime.evaluatorRejectionCount || 0) + 1;
|
|
2867
3217
|
const failureDetails = checkResults.failures.map((f) => {
|
|
2868
3218
|
const stdoutSnippet = f.stdout ? `
|
|
2869
3219
|
Stdout: ${f.stdout.slice(0, 500)}` : "";
|
|
@@ -2874,7 +3224,7 @@ Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
|
2874
3224
|
}).join(`
|
|
2875
3225
|
|
|
2876
3226
|
`);
|
|
2877
|
-
|
|
3227
|
+
runtime.lastRejectionDetails = `Rejection #${runtime.evaluatorRejectionCount} at ${new Date().toISOString()}
|
|
2878
3228
|
|
|
2879
3229
|
Working directory: ${cwd}
|
|
2880
3230
|
|
|
@@ -2882,8 +3232,8 @@ ${failureDetails}`;
|
|
|
2882
3232
|
const attemptID = randomUUID5();
|
|
2883
3233
|
const verificationAttempt = {
|
|
2884
3234
|
id: attemptID,
|
|
2885
|
-
sequence:
|
|
2886
|
-
runGeneration:
|
|
3235
|
+
sequence: runtime.evaluatorRejectionCount,
|
|
3236
|
+
runGeneration: runtime.runGeneration,
|
|
2887
3237
|
claimedSummary: args.summary,
|
|
2888
3238
|
claimedEvidence: args.evidence,
|
|
2889
3239
|
startedAt: new Date().toISOString(),
|
|
@@ -2897,15 +3247,15 @@ ${failureDetails}`;
|
|
|
2897
3247
|
stdout: f.stdout
|
|
2898
3248
|
}))
|
|
2899
3249
|
};
|
|
2900
|
-
|
|
2901
|
-
|
|
3250
|
+
runtime.lastVerificationAttempt = verificationAttempt;
|
|
3251
|
+
runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
|
|
2902
3252
|
const rejectEvent = {
|
|
2903
3253
|
version: 1,
|
|
2904
3254
|
eventID: randomUUID5(),
|
|
2905
3255
|
goalID: goal.id,
|
|
2906
3256
|
type: "goal.completion_rejected",
|
|
2907
3257
|
attemptID,
|
|
2908
|
-
rejectionCount:
|
|
3258
|
+
rejectionCount: runtime.evaluatorRejectionCount,
|
|
2909
3259
|
failedCheckCount: checkResults.failures.length,
|
|
2910
3260
|
failureSummary: failureDetails.slice(0, 500),
|
|
2911
3261
|
timestamp: new Date().toISOString(),
|
|
@@ -2913,16 +3263,16 @@ ${failureDetails}`;
|
|
|
2913
3263
|
};
|
|
2914
3264
|
await appendEvent(dir, rejectEvent);
|
|
2915
3265
|
const maxRejections = goal.config.maxEvaluatorRejections || 3;
|
|
2916
|
-
if (
|
|
3266
|
+
if (runtime.evaluatorRejectionCount >= maxRejections) {
|
|
2917
3267
|
goal.status = "blocked";
|
|
2918
3268
|
goal.updatedAt = new Date().toISOString();
|
|
2919
3269
|
goal.blocker = {
|
|
2920
|
-
reason: `Evaluator rejected ${
|
|
3270
|
+
reason: `Evaluator rejected ${runtime.evaluatorRejectionCount} time(s). Last failure:
|
|
2921
3271
|
${failureDetails.slice(0, 500)}`,
|
|
2922
3272
|
needed: "Fix the failing checks and retry the goal.",
|
|
2923
3273
|
at: new Date().toISOString()
|
|
2924
3274
|
};
|
|
2925
|
-
|
|
3275
|
+
runtime.forceFinishRequested = undefined;
|
|
2926
3276
|
await appendEvent(dir, {
|
|
2927
3277
|
version: 1,
|
|
2928
3278
|
eventID: randomUUID5(),
|
|
@@ -2934,10 +3284,10 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2934
3284
|
revision: state.revision
|
|
2935
3285
|
});
|
|
2936
3286
|
} else {
|
|
2937
|
-
|
|
2938
|
-
|
|
3287
|
+
runtime.forceFinishRequested = false;
|
|
3288
|
+
runtime.freeRetryPending = true;
|
|
2939
3289
|
}
|
|
2940
|
-
|
|
3290
|
+
runtime.updatedAt = new Date().toISOString();
|
|
2941
3291
|
await writeState(dir, state);
|
|
2942
3292
|
}
|
|
2943
3293
|
return {
|
|
@@ -2946,7 +3296,7 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2946
3296
|
passed: false,
|
|
2947
3297
|
failedChecks: checkResults.failures,
|
|
2948
3298
|
message: "Evaluator rejected completion. Fix the issues above and try again.",
|
|
2949
|
-
rejectionCount:
|
|
3299
|
+
rejectionCount: runtime?.evaluatorRejectionCount || 0,
|
|
2950
3300
|
status: goal.status
|
|
2951
3301
|
})
|
|
2952
3302
|
};
|
|
@@ -2959,11 +3309,17 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
2959
3309
|
evidence: args.evidence,
|
|
2960
3310
|
at: new Date().toISOString()
|
|
2961
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;
|
|
2962
3316
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
2963
3317
|
if (runtime) {
|
|
2964
3318
|
Object.assign(runtime, releaseLease(runtime));
|
|
2965
3319
|
runtime.activeRunID = undefined;
|
|
2966
3320
|
runtime.lastError = undefined;
|
|
3321
|
+
runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
|
|
3322
|
+
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
2967
3323
|
runtime.updatedAt = new Date().toISOString();
|
|
2968
3324
|
const schedule = goal.config.schedule;
|
|
2969
3325
|
if (schedule && typeof schedule.everyMs === "number" && schedule.everyMs >= 1000) {
|
|
@@ -3048,11 +3404,17 @@ ${failureDetails.slice(0, 500)}`,
|
|
|
3048
3404
|
needed: args.needed,
|
|
3049
3405
|
at: new Date().toISOString()
|
|
3050
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;
|
|
3051
3411
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
3052
3412
|
if (runtime) {
|
|
3053
3413
|
Object.assign(runtime, releaseLease(runtime));
|
|
3054
3414
|
runtime.activeRunID = undefined;
|
|
3055
3415
|
runtime.lastError = undefined;
|
|
3416
|
+
runtime.turnTokensUsed = (runtime.turnTokensUsed ?? 0) + finalUsage.tokenDelta;
|
|
3417
|
+
runtime.accountedMessageIDs = [...runtime.accountedMessageIDs ?? [], ...finalUsage.counted].slice(-200);
|
|
3056
3418
|
runtime.updatedAt = new Date().toISOString();
|
|
3057
3419
|
}
|
|
3058
3420
|
await writeState(dir, state);
|
|
@@ -3102,6 +3464,7 @@ function formatGoalStructured(goal, runtime) {
|
|
|
3102
3464
|
checkCwd: goal.config.checkCwd,
|
|
3103
3465
|
workspaceWrite: goal.config.workspaceWrite,
|
|
3104
3466
|
agent: goal.config.agent,
|
|
3467
|
+
model: goal.config.model,
|
|
3105
3468
|
maxTurns: goal.config.maxTurns,
|
|
3106
3469
|
maxNoProgress: goal.config.maxNoProgress,
|
|
3107
3470
|
maxFailures: goal.config.maxFailures,
|
|
@@ -3113,6 +3476,9 @@ function formatGoalStructured(goal, runtime) {
|
|
|
3113
3476
|
completionEvidence: goal.completionEvidence,
|
|
3114
3477
|
blocker: goal.blocker,
|
|
3115
3478
|
tokensUsed: goal.tokensUsed,
|
|
3479
|
+
tokenBudget: goal.tokenBudget,
|
|
3480
|
+
costUsed: goal.costUsed ?? 0,
|
|
3481
|
+
costBudget: goal.costBudget,
|
|
3116
3482
|
timeUsedSeconds: goal.timeUsedSeconds
|
|
3117
3483
|
};
|
|
3118
3484
|
if (runtime) {
|
|
@@ -3212,6 +3578,8 @@ function ownerTools(options) {
|
|
|
3212
3578
|
turn: runtime?.runCount ?? 0,
|
|
3213
3579
|
budgetTurnCount: runtime?.budgetTurnCount ?? 0,
|
|
3214
3580
|
maxTurns: g.config.maxTurns,
|
|
3581
|
+
agent: g.config.agent,
|
|
3582
|
+
model: g.config.model,
|
|
3215
3583
|
lastProgress: g.lastProgress?.summary?.slice(0, 120),
|
|
3216
3584
|
lastProgressAt: g.lastProgress?.at,
|
|
3217
3585
|
blocker: g.blocker?.reason?.slice(0, 120),
|
|
@@ -3232,7 +3600,7 @@ function ownerTools(options) {
|
|
|
3232
3600
|
}
|
|
3233
3601
|
}),
|
|
3234
3602
|
inspect_background_goal: tool2({
|
|
3235
|
-
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.",
|
|
3236
3604
|
args: {
|
|
3237
3605
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal."),
|
|
3238
3606
|
includeTranscript: tool2.schema.boolean().optional().describe("Include live transcript tail (adds ~100ms). Default true. Set false for fast metadata-only."),
|
|
@@ -3287,12 +3655,16 @@ function ownerTools(options) {
|
|
|
3287
3655
|
checkCwd: goal.config.checkCwd,
|
|
3288
3656
|
workspaceWrite: goal.config.workspaceWrite,
|
|
3289
3657
|
agent: goal.config.agent,
|
|
3658
|
+
model: goal.config.model,
|
|
3290
3659
|
schedule: goal.config.schedule
|
|
3291
3660
|
},
|
|
3292
3661
|
lastProgress: goal.lastProgress,
|
|
3293
3662
|
completionEvidence: goal.completionEvidence,
|
|
3294
3663
|
blocker: goal.blocker,
|
|
3295
3664
|
tokensUsed: goal.tokensUsed,
|
|
3665
|
+
tokenBudget: goal.tokenBudget,
|
|
3666
|
+
costUsed: goal.costUsed ?? 0,
|
|
3667
|
+
costBudget: goal.costBudget,
|
|
3296
3668
|
timeUsedSeconds: goal.timeUsedSeconds,
|
|
3297
3669
|
progressHistory,
|
|
3298
3670
|
pendingInbox,
|
|
@@ -3637,8 +4009,8 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3637
4009
|
"tool.execute.before": async (input, _output) => {
|
|
3638
4010
|
const activeWorkers = goalService.getActiveWorkers();
|
|
3639
4011
|
let matchedGoalID;
|
|
3640
|
-
for (const [goalID,
|
|
3641
|
-
if (
|
|
4012
|
+
for (const [goalID, worker] of activeWorkers) {
|
|
4013
|
+
if (worker.workerSessionID === input.sessionID) {
|
|
3642
4014
|
matchedGoalID = goalID;
|
|
3643
4015
|
break;
|
|
3644
4016
|
}
|
|
@@ -3661,8 +4033,8 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3661
4033
|
}
|
|
3662
4034
|
const activeWorkers = goalService.getActiveWorkers();
|
|
3663
4035
|
let matchedGoalID;
|
|
3664
|
-
for (const [goalID,
|
|
3665
|
-
if (
|
|
4036
|
+
for (const [goalID, worker] of activeWorkers) {
|
|
4037
|
+
if (worker.workerSessionID === input.sessionID) {
|
|
3666
4038
|
matchedGoalID = goalID;
|
|
3667
4039
|
break;
|
|
3668
4040
|
}
|
|
@@ -3688,20 +4060,20 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3688
4060
|
const goalID = parsed.goalID;
|
|
3689
4061
|
if (!goalID)
|
|
3690
4062
|
return;
|
|
3691
|
-
|
|
4063
|
+
await Promise.resolve();
|
|
3692
4064
|
const state = await readState(directory);
|
|
3693
4065
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
3694
4066
|
if (!goal)
|
|
3695
4067
|
return;
|
|
3696
4068
|
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
3697
4069
|
const notifyType = parsed.status === "complete" ? "complete" : "blocked";
|
|
3698
|
-
if (runtime && !
|
|
4070
|
+
if (runtime && !shouldNotifyParent(runtime, notifyType))
|
|
3699
4071
|
return;
|
|
3700
4072
|
if (runtime) {
|
|
3701
4073
|
await mutateState(directory, `notify-parent:${goalID}`, async (s) => {
|
|
3702
4074
|
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
3703
4075
|
if (rt)
|
|
3704
|
-
|
|
4076
|
+
markParentNotified(rt, notifyType);
|
|
3705
4077
|
return s;
|
|
3706
4078
|
});
|
|
3707
4079
|
}
|
|
@@ -3719,9 +4091,11 @@ var server = async ({ client, directory }, pluginOptions) => {
|
|
|
3719
4091
|
};
|
|
3720
4092
|
function parsePluginDefaults(options) {
|
|
3721
4093
|
const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
|
|
4094
|
+
const model = typeof options?.defaultModel === "string" ? options.defaultModel.trim() : "";
|
|
3722
4095
|
const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
|
|
3723
4096
|
return {
|
|
3724
4097
|
defaultAgent: agent || undefined,
|
|
4098
|
+
defaultModel: model || undefined,
|
|
3725
4099
|
defaultChecks: checks.length > 0 ? checks : undefined
|
|
3726
4100
|
};
|
|
3727
4101
|
}
|