@bojackduy/opencode-loopd 1.5.2 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -8
- package/README.md +23 -0
- package/commands/goal.md +15 -10
- package/dist/server.js +1349 -462
- package/dist/tui.js +121 -71
- package/package.json +1 -1
- package/skills/loopd/SKILL.md +76 -23
package/dist/server.js
CHANGED
|
@@ -13,32 +13,141 @@ var __export = (target, all) => {
|
|
|
13
13
|
set: __exportSetter.bind(all, name)
|
|
14
14
|
});
|
|
15
15
|
};
|
|
16
|
-
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
16
|
|
|
18
|
-
// src/
|
|
19
|
-
var
|
|
20
|
-
__export(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
readState: () => readState,
|
|
33
|
-
recoverStaleProcessing: () => recoverStaleProcessing,
|
|
34
|
-
writeControlRequest: () => writeControlRequest,
|
|
35
|
-
writeControlResponse: () => writeControlResponse,
|
|
36
|
-
writeState: () => writeState
|
|
17
|
+
// 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
|
|
37
31
|
});
|
|
32
|
+
function createRuntimeState(goalID) {
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
return {
|
|
35
|
+
goalID,
|
|
36
|
+
phase: "idle",
|
|
37
|
+
consecutiveFailures: 0,
|
|
38
|
+
runCount: 0,
|
|
39
|
+
budgetTurnCount: 0,
|
|
40
|
+
noProgressCount: 0,
|
|
41
|
+
progressDuringTurn: false,
|
|
42
|
+
unknownStatusCount: 0,
|
|
43
|
+
runGeneration: 0,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function acquireLease(rt, timeoutMs) {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const expires = new Date(now + timeoutMs).toISOString();
|
|
51
|
+
return {
|
|
52
|
+
...rt,
|
|
53
|
+
phase: "running",
|
|
54
|
+
leaseExpiresAt: expires,
|
|
55
|
+
turnStartedAt: new Date(now).toISOString(),
|
|
56
|
+
progressDuringTurn: false,
|
|
57
|
+
turnTokensUsed: 0,
|
|
58
|
+
runGeneration: rt.runGeneration + 1,
|
|
59
|
+
lastActivityAt: new Date(now).toISOString(),
|
|
60
|
+
idleCandidateAt: undefined,
|
|
61
|
+
idleCandidateGeneration: undefined,
|
|
62
|
+
activePromptObservedAt: undefined,
|
|
63
|
+
activeAssistantMessageID: undefined,
|
|
64
|
+
activeAssistantCompletedAt: undefined,
|
|
65
|
+
activeToolCallIDs: [],
|
|
66
|
+
updatedAt: new Date(now).toISOString()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function releaseLease(rt) {
|
|
70
|
+
return {
|
|
71
|
+
...rt,
|
|
72
|
+
phase: "idle",
|
|
73
|
+
leaseExpiresAt: undefined,
|
|
74
|
+
turnStartedAt: undefined,
|
|
75
|
+
activePromptMessageID: undefined,
|
|
76
|
+
activePromptObservedAt: undefined,
|
|
77
|
+
activeAssistantMessageID: undefined,
|
|
78
|
+
activeAssistantCompletedAt: undefined,
|
|
79
|
+
idleCandidateAt: undefined,
|
|
80
|
+
idleCandidateGeneration: undefined,
|
|
81
|
+
activeToolCallIDs: [],
|
|
82
|
+
updatedAt: new Date().toISOString()
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function leaseIsValid(rt) {
|
|
86
|
+
if (!rt.leaseExpiresAt)
|
|
87
|
+
return false;
|
|
88
|
+
return Date.now() < Date.parse(rt.leaseExpiresAt);
|
|
89
|
+
}
|
|
90
|
+
function markProgress(rt) {
|
|
91
|
+
return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
|
|
92
|
+
}
|
|
93
|
+
function shouldNotifyParent(runtime, type) {
|
|
94
|
+
if (!runtime.lastParentNotifiedAt || !runtime.lastParentNotifiedFor)
|
|
95
|
+
return true;
|
|
96
|
+
if (runtime.lastParentNotifiedFor !== type)
|
|
97
|
+
return true;
|
|
98
|
+
const elapsed = Date.now() - Date.parse(runtime.lastParentNotifiedAt);
|
|
99
|
+
return !Number.isFinite(elapsed) || elapsed > PARENT_NOTIFY_DEDUPE_MS;
|
|
100
|
+
}
|
|
101
|
+
function markParentNotified(runtime, type) {
|
|
102
|
+
runtime.lastParentNotifiedFor = type;
|
|
103
|
+
runtime.lastParentNotifiedAt = new Date().toISOString();
|
|
104
|
+
runtime.updatedAt = new Date().toISOString();
|
|
105
|
+
}
|
|
106
|
+
function recordActivity(rt) {
|
|
107
|
+
return {
|
|
108
|
+
...rt,
|
|
109
|
+
lastActivityAt: new Date().toISOString(),
|
|
110
|
+
idleCandidateAt: undefined,
|
|
111
|
+
idleCandidateGeneration: undefined,
|
|
112
|
+
updatedAt: new Date().toISOString()
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function addToolCall(rt, callID) {
|
|
116
|
+
const ids = new Set(rt.activeToolCallIDs || []);
|
|
117
|
+
ids.add(callID);
|
|
118
|
+
return {
|
|
119
|
+
...rt,
|
|
120
|
+
activeToolCallIDs: Array.from(ids),
|
|
121
|
+
lastActivityAt: new Date().toISOString(),
|
|
122
|
+
idleCandidateAt: undefined,
|
|
123
|
+
idleCandidateGeneration: undefined,
|
|
124
|
+
updatedAt: new Date().toISOString()
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function removeToolCall(rt, callID) {
|
|
128
|
+
const ids = (rt.activeToolCallIDs || []).filter((id) => id !== callID);
|
|
129
|
+
return {
|
|
130
|
+
...rt,
|
|
131
|
+
activeToolCallIDs: ids,
|
|
132
|
+
lastActivityAt: new Date().toISOString(),
|
|
133
|
+
idleCandidateAt: undefined,
|
|
134
|
+
idleCandidateGeneration: undefined,
|
|
135
|
+
updatedAt: new Date().toISOString()
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function hasActiveToolCalls(rt) {
|
|
139
|
+
return (rt.activeToolCallIDs?.length ?? 0) > 0;
|
|
140
|
+
}
|
|
141
|
+
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
142
|
+
|
|
143
|
+
// src/application/control-worker.ts
|
|
144
|
+
import { randomUUID } from "crypto";
|
|
145
|
+
|
|
146
|
+
// src/infrastructure/state-repository.ts
|
|
38
147
|
import { promises as fs } from "fs";
|
|
39
148
|
import path from "path";
|
|
40
149
|
import os from "os";
|
|
41
|
-
|
|
150
|
+
var CURRENT_VERSION = 5;
|
|
42
151
|
function emptyState() {
|
|
43
152
|
return { version: CURRENT_VERSION, revision: 0, goals: [], runtimes: [], commandLedger: [] };
|
|
44
153
|
}
|
|
@@ -58,11 +167,11 @@ function lockDir(directory) {
|
|
|
58
167
|
function lockFile(directory, key) {
|
|
59
168
|
return path.join(lockDir(directory), `${key}.lock`);
|
|
60
169
|
}
|
|
170
|
+
var LOCK_STALE_MS = 1e4;
|
|
61
171
|
async function acquireLock(directory, key, operation) {
|
|
62
172
|
const dir = lockDir(directory);
|
|
63
173
|
await fs.mkdir(dir, { recursive: true });
|
|
64
174
|
const lockPath = lockFile(directory, key);
|
|
65
|
-
const lockID = randomUUID();
|
|
66
175
|
for (let attempt = 0;attempt < 10; attempt++) {
|
|
67
176
|
try {
|
|
68
177
|
try {
|
|
@@ -73,31 +182,44 @@ async function acquireLock(directory, key, operation) {
|
|
|
73
182
|
await fs.rm(lockPath, { force: true });
|
|
74
183
|
}
|
|
75
184
|
} catch {}
|
|
76
|
-
const temp = lockPath + `.${lockID}.tmp`;
|
|
77
185
|
const meta = { pid: process.pid, operation, acquiredAt: new Date().toISOString() };
|
|
78
|
-
await fs.
|
|
186
|
+
const fd = await fs.open(lockPath, "wx");
|
|
79
187
|
try {
|
|
80
|
-
await
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await fs.rm(temp, { force: true });
|
|
84
|
-
if (error?.code !== "EEXIST")
|
|
85
|
-
throw error;
|
|
188
|
+
await fd.writeFile(JSON.stringify(meta), "utf8");
|
|
189
|
+
} finally {
|
|
190
|
+
await fd.close();
|
|
86
191
|
}
|
|
192
|
+
return;
|
|
87
193
|
} catch (error) {
|
|
88
|
-
if (error?.code === "ENOENT") {
|
|
194
|
+
if (error?.code === "EEXIST") {} else if (error?.code === "ENOENT") {
|
|
89
195
|
await fs.mkdir(dir, { recursive: true });
|
|
90
196
|
continue;
|
|
197
|
+
} else {
|
|
198
|
+
throw error;
|
|
91
199
|
}
|
|
92
|
-
throw error;
|
|
93
200
|
}
|
|
94
201
|
await delay(25 * (attempt + 1));
|
|
95
202
|
}
|
|
96
203
|
throw new Error(`failed to acquire lock "${key}" for "${operation}" after retries`);
|
|
97
204
|
}
|
|
98
205
|
async function releaseLock(directory, key) {
|
|
206
|
+
const lockPath = lockFile(directory, key);
|
|
99
207
|
try {
|
|
100
|
-
await fs.
|
|
208
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
209
|
+
const meta = JSON.parse(raw);
|
|
210
|
+
const age = Date.now() - Date.parse(meta.acquiredAt);
|
|
211
|
+
const shouldRelease = meta.pid === process.pid || age > LOCK_STALE_MS;
|
|
212
|
+
if (!shouldRelease)
|
|
213
|
+
return;
|
|
214
|
+
try {
|
|
215
|
+
const raw2 = await fs.readFile(lockPath, "utf8");
|
|
216
|
+
const meta2 = JSON.parse(raw2);
|
|
217
|
+
if (meta2.acquiredAt !== meta.acquiredAt || meta2.pid !== meta.pid)
|
|
218
|
+
return;
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
await fs.rm(lockPath, { force: true });
|
|
101
223
|
} catch {}
|
|
102
224
|
}
|
|
103
225
|
async function readState(directory) {
|
|
@@ -141,6 +263,53 @@ function migrate(state) {
|
|
|
141
263
|
blocker: g.blocker ?? undefined
|
|
142
264
|
}));
|
|
143
265
|
}
|
|
266
|
+
if (result.version < 3) {
|
|
267
|
+
result.version = 3;
|
|
268
|
+
result.runtimes = result.runtimes.map((rt) => {
|
|
269
|
+
const oldTurnCount = rt.turnCount ?? 0;
|
|
270
|
+
const { turnCount: _deprecatedTurnCount, ...rest } = rt;
|
|
271
|
+
return {
|
|
272
|
+
...rest,
|
|
273
|
+
budgetTurnCount: rest.budgetTurnCount ?? oldTurnCount,
|
|
274
|
+
runCount: rest.runCount ?? oldTurnCount,
|
|
275
|
+
runGeneration: rest.runGeneration ?? 0,
|
|
276
|
+
freeRetryPending: rest.freeRetryPending ?? false,
|
|
277
|
+
lastRejectionDetails: rest.lastRejectionDetails ?? undefined,
|
|
278
|
+
activePromptMessageID: rest.activePromptMessageID ?? undefined,
|
|
279
|
+
lastActivityAt: rest.lastActivityAt ?? undefined,
|
|
280
|
+
idleCandidateAt: rest.idleCandidateAt ?? undefined,
|
|
281
|
+
activeToolCallIDs: rest.activeToolCallIDs ?? []
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (result.version < 4) {
|
|
286
|
+
result.version = 4;
|
|
287
|
+
result.runtimes = result.runtimes.map((rt) => ({
|
|
288
|
+
...rt,
|
|
289
|
+
lastVerificationAttempt: rt.lastVerificationAttempt ?? undefined,
|
|
290
|
+
recentVerificationAttempts: rt.recentVerificationAttempts ?? []
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
if (result.version < 5) {
|
|
294
|
+
result.version = 5;
|
|
295
|
+
result.goals = result.goals.map((goal) => ({
|
|
296
|
+
...goal,
|
|
297
|
+
config: {
|
|
298
|
+
...goal.config,
|
|
299
|
+
workspaceWrite: goal.config?.workspaceWrite ?? true
|
|
300
|
+
}
|
|
301
|
+
}));
|
|
302
|
+
result.runtimes = result.runtimes.map((rt) => ({
|
|
303
|
+
...rt,
|
|
304
|
+
activePromptObservedAt: rt.activePromptObservedAt ?? undefined,
|
|
305
|
+
activeAssistantMessageID: rt.activeAssistantMessageID ?? undefined,
|
|
306
|
+
activeAssistantCompletedAt: rt.activeAssistantCompletedAt ?? undefined,
|
|
307
|
+
idleCandidateGeneration: rt.idleCandidateGeneration ?? undefined,
|
|
308
|
+
unknownStatusCount: rt.unknownStatusCount ?? 0,
|
|
309
|
+
lastUnknownStatusAt: rt.lastUnknownStatusAt ?? undefined,
|
|
310
|
+
workerUnreachableNotifiedAt: rt.workerUnreachableNotifiedAt ?? undefined
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
144
313
|
return result;
|
|
145
314
|
}
|
|
146
315
|
async function writeAtomic(target, contents) {
|
|
@@ -213,19 +382,6 @@ function processingFile(directory, requestID) {
|
|
|
213
382
|
function responseFile(directory, requestID) {
|
|
214
383
|
return path.join(controlDir(directory), "responses", `${requestID}.json`);
|
|
215
384
|
}
|
|
216
|
-
async function writeControlRequest(directory, request) {
|
|
217
|
-
const dir = path.join(controlDir(directory), "requests");
|
|
218
|
-
await fs.mkdir(dir, { recursive: true });
|
|
219
|
-
await writeAtomic(requestFile(directory, request.requestID), JSON.stringify(request, null, 2));
|
|
220
|
-
}
|
|
221
|
-
async function readControlRequest(directory, requestID) {
|
|
222
|
-
try {
|
|
223
|
-
const raw = await fs.readFile(requestFile(directory, requestID), "utf8");
|
|
224
|
-
return JSON.parse(raw);
|
|
225
|
-
} catch {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
385
|
async function claimControlRequest(directory, requestID) {
|
|
230
386
|
const src = requestFile(directory, requestID);
|
|
231
387
|
const dst = processingFile(directory, requestID);
|
|
@@ -271,28 +427,6 @@ async function listPendingRequests(directory) {
|
|
|
271
427
|
return [];
|
|
272
428
|
}
|
|
273
429
|
}
|
|
274
|
-
async function recoverStaleProcessing(directory) {
|
|
275
|
-
const dir = path.join(controlDir(directory), "processing");
|
|
276
|
-
try {
|
|
277
|
-
const files = await fs.readdir(dir);
|
|
278
|
-
const recovered = [];
|
|
279
|
-
for (const file of files) {
|
|
280
|
-
if (!file.endsWith(".json"))
|
|
281
|
-
continue;
|
|
282
|
-
const processingPath = path.join(dir, file);
|
|
283
|
-
const requestPath = path.join(controlDir(directory), "requests", file);
|
|
284
|
-
try {
|
|
285
|
-
const raw = await fs.readFile(processingPath, "utf8");
|
|
286
|
-
const request = JSON.parse(raw);
|
|
287
|
-
await fs.rename(processingPath, requestPath);
|
|
288
|
-
recovered.push(request);
|
|
289
|
-
} catch {}
|
|
290
|
-
}
|
|
291
|
-
return recovered;
|
|
292
|
-
} catch {
|
|
293
|
-
return [];
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
430
|
function goalArtifactDir(directory, goalID) {
|
|
297
431
|
return path.join(loopDir(directory), "goals", goalID);
|
|
298
432
|
}
|
|
@@ -329,82 +463,58 @@ async function drainGoalInbox(directory, goalID) {
|
|
|
329
463
|
function delay(ms) {
|
|
330
464
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
331
465
|
}
|
|
332
|
-
var CURRENT_VERSION = 2, LOCK_STALE_MS = 1e4;
|
|
333
|
-
var init_state_repository = () => {};
|
|
334
466
|
|
|
335
|
-
// src/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
function acquireLease(rt, timeoutMs) {
|
|
361
|
-
const now = Date.now();
|
|
362
|
-
const expires = new Date(now + timeoutMs).toISOString();
|
|
363
|
-
return {
|
|
364
|
-
...rt,
|
|
365
|
-
phase: "running",
|
|
366
|
-
leaseExpiresAt: expires,
|
|
367
|
-
turnStartedAt: new Date(now).toISOString(),
|
|
368
|
-
progressDuringTurn: false,
|
|
369
|
-
turnTokensUsed: 0,
|
|
370
|
-
updatedAt: new Date(now).toISOString()
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
function releaseLease(rt) {
|
|
467
|
+
// src/application/goal-policy.ts
|
|
468
|
+
function resolveGoalCreationConfig(input) {
|
|
469
|
+
const requested = input.config || {};
|
|
470
|
+
const defaults = input.defaults || {};
|
|
471
|
+
const explicitAgent = cleanText(requested.agent);
|
|
472
|
+
const defaultAgent = cleanText(defaults.defaultAgent);
|
|
473
|
+
const agent = explicitAgent || defaultAgent;
|
|
474
|
+
if (!agent) {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
errorCode: "missing_agent",
|
|
478
|
+
message: "An agent is required. Pass agent explicitly or configure plugin option defaultAgent."
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const workspaceWrite = requested.workspaceWrite ?? true;
|
|
482
|
+
const explicitChecks = cleanList(requested.checks);
|
|
483
|
+
const defaultChecks = workspaceWrite ? cleanList(defaults.defaultChecks) : [];
|
|
484
|
+
const checks = explicitChecks.length > 0 ? explicitChecks : defaultChecks;
|
|
485
|
+
if (workspaceWrite && checks.length === 0) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
errorCode: "missing_checks",
|
|
489
|
+
message: "Workspace-writing goals require completion checks. Pass checks or configure plugin option defaultChecks."
|
|
490
|
+
};
|
|
491
|
+
}
|
|
374
492
|
return {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
493
|
+
ok: true,
|
|
494
|
+
config: {
|
|
495
|
+
...requested,
|
|
496
|
+
agent,
|
|
497
|
+
workspaceWrite,
|
|
498
|
+
checks: checks.length > 0 ? checks : undefined,
|
|
499
|
+
checkCwd: requested.checkCwd || (workspaceWrite ? input.directory : undefined)
|
|
500
|
+
},
|
|
501
|
+
defaultsApplied: {
|
|
502
|
+
agent: !explicitAgent && Boolean(defaultAgent),
|
|
503
|
+
checks: explicitChecks.length === 0 && defaultChecks.length > 0
|
|
504
|
+
}
|
|
380
505
|
};
|
|
381
506
|
}
|
|
382
|
-
function
|
|
383
|
-
if (
|
|
384
|
-
return
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
function markProgress(rt) {
|
|
388
|
-
return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
|
|
389
|
-
}
|
|
390
|
-
function shouldNotifyParent(runtime, type) {
|
|
391
|
-
if (!runtime.lastParentNotifiedAt || !runtime.lastParentNotifiedFor)
|
|
392
|
-
return true;
|
|
393
|
-
if (runtime.lastParentNotifiedFor !== type)
|
|
394
|
-
return true;
|
|
395
|
-
const elapsed = Date.now() - Date.parse(runtime.lastParentNotifiedAt);
|
|
396
|
-
return !Number.isFinite(elapsed) || elapsed > PARENT_NOTIFY_DEDUPE_MS;
|
|
507
|
+
function cleanText(value) {
|
|
508
|
+
if (typeof value !== "string")
|
|
509
|
+
return;
|
|
510
|
+
const trimmed = value.trim();
|
|
511
|
+
return trimmed || undefined;
|
|
397
512
|
}
|
|
398
|
-
function
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
513
|
+
function cleanList(value) {
|
|
514
|
+
if (!Array.isArray(value))
|
|
515
|
+
return [];
|
|
516
|
+
return value.map(cleanText).filter((item) => Boolean(item));
|
|
402
517
|
}
|
|
403
|
-
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
404
|
-
|
|
405
|
-
// src/application/control-worker.ts
|
|
406
|
-
init_state_repository();
|
|
407
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
408
518
|
|
|
409
519
|
// src/infrastructure/server-log.ts
|
|
410
520
|
import { appendFile } from "fs/promises";
|
|
@@ -548,11 +658,26 @@ function createControlWorker(options) {
|
|
|
548
658
|
};
|
|
549
659
|
break;
|
|
550
660
|
}
|
|
661
|
+
const resolution = resolveGoalCreationConfig({
|
|
662
|
+
directory,
|
|
663
|
+
objective: args.objective,
|
|
664
|
+
config: args.config,
|
|
665
|
+
defaults: options.defaults
|
|
666
|
+
});
|
|
667
|
+
if (!resolution.ok) {
|
|
668
|
+
response = {
|
|
669
|
+
...base,
|
|
670
|
+
ok: false,
|
|
671
|
+
message: resolution.message,
|
|
672
|
+
errorCode: resolution.errorCode
|
|
673
|
+
};
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
551
676
|
const { goal } = await goalSvc.start(directory, {
|
|
552
677
|
name: args.name,
|
|
553
678
|
objective: args.objective,
|
|
554
679
|
ownerSessionID: args.ownerSessionID,
|
|
555
|
-
config:
|
|
680
|
+
config: resolution.config
|
|
556
681
|
});
|
|
557
682
|
const state2 = await readState(directory);
|
|
558
683
|
response = {
|
|
@@ -654,7 +779,7 @@ function createControlWorker(options) {
|
|
|
654
779
|
await writeState(directory, state2);
|
|
655
780
|
await appendEvent(directory, {
|
|
656
781
|
version: 1,
|
|
657
|
-
eventID:
|
|
782
|
+
eventID: randomUUID(),
|
|
658
783
|
goalID: goal.id,
|
|
659
784
|
type: "goal.completed",
|
|
660
785
|
summary: goal.completionEvidence.summary,
|
|
@@ -694,7 +819,7 @@ function createControlWorker(options) {
|
|
|
694
819
|
await writeState(directory, state2);
|
|
695
820
|
await appendEvent(directory, {
|
|
696
821
|
version: 1,
|
|
697
|
-
eventID:
|
|
822
|
+
eventID: randomUUID(),
|
|
698
823
|
goalID: goal.id,
|
|
699
824
|
type: "goal.blocked",
|
|
700
825
|
reason: goal.blocker.reason,
|
|
@@ -740,8 +865,7 @@ function createControlWorker(options) {
|
|
|
740
865
|
}
|
|
741
866
|
|
|
742
867
|
// src/application/loop-engine.ts
|
|
743
|
-
|
|
744
|
-
import { randomUUID as randomUUID3 } from "crypto";
|
|
868
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
745
869
|
|
|
746
870
|
// src/domain/goal.ts
|
|
747
871
|
var MODEL_TRANSITIONS = {
|
|
@@ -780,15 +904,20 @@ function createGoal(input) {
|
|
|
780
904
|
return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
781
905
|
}
|
|
782
906
|
// src/application/loop-engine.ts
|
|
907
|
+
var CONFIRM_IDLE_DURATION_MS = 2000;
|
|
783
908
|
var HANDLED_EVENT_TYPES = new Set([
|
|
784
909
|
"session.idle",
|
|
785
910
|
"session.status",
|
|
786
911
|
"session.error",
|
|
787
|
-
"session.compacted"
|
|
912
|
+
"session.compacted",
|
|
913
|
+
"message.updated",
|
|
914
|
+
"message.part.updated"
|
|
788
915
|
]);
|
|
789
916
|
function createLoopEngine(options) {
|
|
790
917
|
const { directory, host, goalService } = options;
|
|
791
918
|
const maintenanceMs = options.pollIntervalMs ?? 30000;
|
|
919
|
+
const confirmIdleMs = options.confirmIdleMs ?? CONFIRM_IDLE_DURATION_MS;
|
|
920
|
+
const unknownStatusThreshold = Math.max(1, options.unknownStatusThreshold ?? 3);
|
|
792
921
|
let running = false;
|
|
793
922
|
let maintenanceTimer;
|
|
794
923
|
let knownWorkerSessions = new Set;
|
|
@@ -853,7 +982,7 @@ function createLoopEngine(options) {
|
|
|
853
982
|
const type = event.type;
|
|
854
983
|
if (!type || !HANDLED_EVENT_TYPES.has(type))
|
|
855
984
|
return false;
|
|
856
|
-
const sessionID = event
|
|
985
|
+
const sessionID = eventSessionID(event);
|
|
857
986
|
if (!sessionID)
|
|
858
987
|
return false;
|
|
859
988
|
await loadWorkerSessionsIfneeded();
|
|
@@ -868,7 +997,7 @@ function createLoopEngine(options) {
|
|
|
868
997
|
return false;
|
|
869
998
|
if (goal.workerSessionID)
|
|
870
999
|
knownWorkerSessions.add(goal.workerSessionID);
|
|
871
|
-
if (
|
|
1000
|
+
if (goal.status !== "active")
|
|
872
1001
|
return false;
|
|
873
1002
|
switch (type) {
|
|
874
1003
|
case "session.idle":
|
|
@@ -879,98 +1008,253 @@ function createLoopEngine(options) {
|
|
|
879
1008
|
return await handleSessionError(state, goal, event);
|
|
880
1009
|
case "session.compacted":
|
|
881
1010
|
return await handleSessionCompacted(state, goal);
|
|
1011
|
+
case "message.updated":
|
|
1012
|
+
case "message.part.updated":
|
|
1013
|
+
return await handleMessageActivity(goal, event);
|
|
882
1014
|
default:
|
|
883
1015
|
return false;
|
|
884
1016
|
}
|
|
885
1017
|
}
|
|
1018
|
+
function eventSessionID(event) {
|
|
1019
|
+
return event.properties?.sessionID || event.properties?.info?.sessionID || event.properties?.part?.sessionID;
|
|
1020
|
+
}
|
|
1021
|
+
async function handleMessageActivity(goal, event) {
|
|
1022
|
+
let matched = false;
|
|
1023
|
+
await mutateState(directory, `message-activity:${goal.id}`, async (s) => {
|
|
1024
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1025
|
+
if (!rt || rt.phase !== "running" || !rt.activePromptMessageID)
|
|
1026
|
+
return s;
|
|
1027
|
+
if (event.type === "message.updated") {
|
|
1028
|
+
const info = event.properties?.info;
|
|
1029
|
+
if (info?.role === "user" && info.id === rt.activePromptMessageID) {
|
|
1030
|
+
Object.assign(rt, recordActivity(rt));
|
|
1031
|
+
rt.activePromptObservedAt = new Date().toISOString();
|
|
1032
|
+
matched = true;
|
|
1033
|
+
} else if (info?.role === "assistant" && info.parentID === rt.activePromptMessageID) {
|
|
1034
|
+
Object.assign(rt, recordActivity(rt));
|
|
1035
|
+
rt.activeAssistantMessageID = info.id;
|
|
1036
|
+
if (info.time?.completed) {
|
|
1037
|
+
rt.activeAssistantCompletedAt = new Date(info.time.completed).toISOString();
|
|
1038
|
+
}
|
|
1039
|
+
matched = true;
|
|
1040
|
+
}
|
|
1041
|
+
} else {
|
|
1042
|
+
const part = event.properties?.part;
|
|
1043
|
+
if (part?.messageID && part.messageID === rt.activeAssistantMessageID) {
|
|
1044
|
+
Object.assign(rt, recordActivity(rt));
|
|
1045
|
+
matched = true;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return s;
|
|
1049
|
+
});
|
|
1050
|
+
return matched;
|
|
1051
|
+
}
|
|
886
1052
|
async function handleSessionIdle(state, goal) {
|
|
887
|
-
const
|
|
888
|
-
if (
|
|
889
|
-
return false;
|
|
890
|
-
if (inflightContinuations.has(goal.id))
|
|
1053
|
+
const goalID = goal.id;
|
|
1054
|
+
if (inflightContinuations.has(goalID))
|
|
891
1055
|
return false;
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
if (
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1056
|
+
let completedRunID;
|
|
1057
|
+
let confirmation;
|
|
1058
|
+
let afterIdle = await mutateState(directory, `idle:${goalID}`, async (s) => {
|
|
1059
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
1060
|
+
if (!g)
|
|
1061
|
+
return s;
|
|
1062
|
+
if (isTerminal(g.status) || g.status === "paused")
|
|
1063
|
+
return s;
|
|
1064
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1065
|
+
if (!rt)
|
|
1066
|
+
return s;
|
|
1067
|
+
if (rt.phase !== "running")
|
|
1068
|
+
return s;
|
|
1069
|
+
if ((rt.activeToolCallIDs?.length ?? 0) > 0) {
|
|
1070
|
+
rt.idleCandidateAt = undefined;
|
|
1071
|
+
rt.idleCandidateGeneration = undefined;
|
|
1072
|
+
return s;
|
|
1073
|
+
}
|
|
1074
|
+
const now = Date.now();
|
|
1075
|
+
if (!rt.idleCandidateAt || rt.idleCandidateGeneration !== rt.runGeneration) {
|
|
1076
|
+
rt.idleCandidateAt = new Date(now).toISOString();
|
|
1077
|
+
rt.idleCandidateGeneration = rt.runGeneration;
|
|
1078
|
+
return s;
|
|
1079
|
+
}
|
|
1080
|
+
const elapsed = now - Date.parse(rt.idleCandidateAt);
|
|
1081
|
+
if (elapsed < confirmIdleMs)
|
|
1082
|
+
return s;
|
|
1083
|
+
if (rt.lastActivityAt && rt.lastActivityAt > rt.idleCandidateAt) {
|
|
1084
|
+
rt.idleCandidateAt = undefined;
|
|
1085
|
+
rt.idleCandidateGeneration = undefined;
|
|
1086
|
+
return s;
|
|
908
1087
|
}
|
|
1088
|
+
if (rt.activePromptMessageID) {
|
|
1089
|
+
confirmation = {
|
|
1090
|
+
generation: rt.runGeneration,
|
|
1091
|
+
promptMessageID: rt.activePromptMessageID,
|
|
1092
|
+
candidateAt: rt.idleCandidateAt,
|
|
1093
|
+
assistantCompleted: Boolean(rt.activeAssistantCompletedAt)
|
|
1094
|
+
};
|
|
1095
|
+
} else {
|
|
1096
|
+
completedRunID = rt.activeRunID;
|
|
1097
|
+
Object.assign(rt, releaseLease(rt));
|
|
1098
|
+
rt.activeRunID = undefined;
|
|
1099
|
+
rt.lastWorkerStatus = "idle";
|
|
1100
|
+
}
|
|
1101
|
+
return s;
|
|
1102
|
+
});
|
|
1103
|
+
if (confirmation) {
|
|
1104
|
+
const candidate = confirmation;
|
|
1105
|
+
const transcript = await inspectPromptTurn(goal.workerSessionID, candidate.promptMessageID);
|
|
1106
|
+
if (!transcript.latestUserPrompt || !candidate.assistantCompleted && !transcript.assistantCompleted) {
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
afterIdle = await mutateState(directory, `idle.confirm:${goalID}`, async (s) => {
|
|
1110
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
1111
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1112
|
+
if (!g || !rt || isTerminal(g.status) || g.status === "paused")
|
|
1113
|
+
return s;
|
|
1114
|
+
if (rt.phase !== "running")
|
|
1115
|
+
return s;
|
|
1116
|
+
if (rt.runGeneration !== candidate.generation)
|
|
1117
|
+
return s;
|
|
1118
|
+
if (rt.activePromptMessageID !== candidate.promptMessageID)
|
|
1119
|
+
return s;
|
|
1120
|
+
if (rt.idleCandidateGeneration !== candidate.generation)
|
|
1121
|
+
return s;
|
|
1122
|
+
if (rt.idleCandidateAt !== candidate.candidateAt)
|
|
1123
|
+
return s;
|
|
1124
|
+
if (rt.lastActivityAt && rt.lastActivityAt > candidate.candidateAt)
|
|
1125
|
+
return s;
|
|
1126
|
+
if ((rt.activeToolCallIDs?.length ?? 0) > 0)
|
|
1127
|
+
return s;
|
|
1128
|
+
completedRunID = rt.activeRunID;
|
|
1129
|
+
Object.assign(rt, releaseLease(rt));
|
|
1130
|
+
rt.activeRunID = undefined;
|
|
1131
|
+
rt.lastWorkerStatus = "idle";
|
|
1132
|
+
return s;
|
|
1133
|
+
});
|
|
909
1134
|
}
|
|
910
|
-
if (
|
|
1135
|
+
if (completedRunID) {
|
|
1136
|
+
await appendEvent(directory, {
|
|
1137
|
+
version: 1,
|
|
1138
|
+
eventID: randomUUID2(),
|
|
1139
|
+
goalID,
|
|
1140
|
+
type: "run.completed",
|
|
1141
|
+
runID: completedRunID,
|
|
1142
|
+
timestamp: new Date().toISOString(),
|
|
1143
|
+
revision: afterIdle.revision
|
|
1144
|
+
});
|
|
1145
|
+
} else {
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
const freshState = await readState(directory);
|
|
1149
|
+
const freshGoal = freshState.goals.find((g) => g.id === goalID);
|
|
1150
|
+
if (!freshGoal || freshGoal.status !== "active")
|
|
1151
|
+
return false;
|
|
1152
|
+
const freshRuntime = freshState.runtimes.find((r) => r.goalID === goalID);
|
|
1153
|
+
if (!freshRuntime)
|
|
911
1154
|
return false;
|
|
912
|
-
const limitResult = enforceLimits(
|
|
1155
|
+
const limitResult = enforceLimits(freshGoal, freshRuntime);
|
|
913
1156
|
if (limitResult.stop === "force_finish") {
|
|
914
|
-
if (!
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1157
|
+
if (!freshRuntime.forceFinishRequested) {
|
|
1158
|
+
await mutateState(directory, `idle.force-finish:${goalID}`, async (s) => {
|
|
1159
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1160
|
+
if (rt)
|
|
1161
|
+
rt.forceFinishRequested = true;
|
|
1162
|
+
return s;
|
|
1163
|
+
});
|
|
1164
|
+
await goalService.continueTurn(directory, goalID, { forceFinish: true });
|
|
918
1165
|
return true;
|
|
919
1166
|
}
|
|
920
|
-
const blockedKey =
|
|
1167
|
+
const blockedKey = goalID;
|
|
921
1168
|
const nowBlocked = Date.now();
|
|
922
1169
|
const lastBlocked = recentForceFinishBlocked.get(blockedKey);
|
|
923
1170
|
if (lastBlocked !== undefined && nowBlocked - lastBlocked < 60000)
|
|
924
1171
|
return true;
|
|
925
1172
|
recentForceFinishBlocked.set(blockedKey, nowBlocked);
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1173
|
+
let shouldNotifyBlocked = false;
|
|
1174
|
+
const blockedState = await mutateState(directory, `idle.blocked:${goalID}`, async (s) => {
|
|
1175
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
1176
|
+
if (!g)
|
|
1177
|
+
return s;
|
|
1178
|
+
g.status = "blocked";
|
|
1179
|
+
g.updatedAt = new Date().toISOString();
|
|
1180
|
+
g.blocker = {
|
|
1181
|
+
reason: limitResult.reason + " (force-finish ignored)",
|
|
1182
|
+
needed: "User intervention required. Use retry to attempt again.",
|
|
1183
|
+
at: new Date().toISOString()
|
|
1184
|
+
};
|
|
1185
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
1186
|
+
if (rt) {
|
|
1187
|
+
rt.forceFinishRequested = undefined;
|
|
1188
|
+
if (shouldNotifyParent(rt, "stopped")) {
|
|
1189
|
+
markParentNotified(rt, "stopped");
|
|
1190
|
+
shouldNotifyBlocked = true;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return s;
|
|
1194
|
+
});
|
|
938
1195
|
await appendEvent(directory, {
|
|
939
1196
|
version: 1,
|
|
940
|
-
eventID:
|
|
941
|
-
goalID
|
|
1197
|
+
eventID: randomUUID2(),
|
|
1198
|
+
goalID,
|
|
942
1199
|
type: "goal.blocked",
|
|
943
1200
|
reason: limitResult.reason + " (force-finish ignored)",
|
|
944
|
-
needed:
|
|
1201
|
+
needed: "User intervention required. Use retry to attempt again.",
|
|
945
1202
|
timestamp: new Date().toISOString(),
|
|
946
|
-
revision:
|
|
1203
|
+
revision: blockedState.revision
|
|
947
1204
|
});
|
|
948
|
-
if (
|
|
1205
|
+
if (shouldNotifyBlocked) {
|
|
949
1206
|
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${limitResult.reason} (child did not wrap up). Status: blocked. Last progress: ${goal.lastProgress?.summary || "none"}.`);
|
|
950
1207
|
}
|
|
951
1208
|
return true;
|
|
952
1209
|
}
|
|
953
1210
|
if (limitResult.stop === "budget") {
|
|
954
|
-
await
|
|
1211
|
+
const budgetState = await mutateState(directory, `idle.budget:${goalID}`, async (s) => {
|
|
1212
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
1213
|
+
if (g) {
|
|
1214
|
+
g.status = "budget_limited";
|
|
1215
|
+
g.updatedAt = new Date().toISOString();
|
|
1216
|
+
}
|
|
1217
|
+
return s;
|
|
1218
|
+
});
|
|
955
1219
|
await appendEvent(directory, {
|
|
956
1220
|
version: 1,
|
|
957
|
-
eventID:
|
|
958
|
-
goalID
|
|
1221
|
+
eventID: randomUUID2(),
|
|
1222
|
+
goalID,
|
|
959
1223
|
type: "goal.status_changed",
|
|
960
1224
|
from: "active",
|
|
961
|
-
to:
|
|
1225
|
+
to: "budget_limited",
|
|
962
1226
|
timestamp: new Date().toISOString(),
|
|
963
|
-
revision:
|
|
1227
|
+
revision: budgetState.revision
|
|
964
1228
|
});
|
|
965
1229
|
return true;
|
|
966
1230
|
}
|
|
967
|
-
if (shouldCompact(
|
|
968
|
-
await doCompact(
|
|
1231
|
+
if (shouldCompact(freshGoal, freshRuntime)) {
|
|
1232
|
+
await doCompact(freshGoal, freshRuntime);
|
|
969
1233
|
return true;
|
|
970
1234
|
}
|
|
971
|
-
await continueGoal(
|
|
1235
|
+
await continueGoal(goalID);
|
|
972
1236
|
return true;
|
|
973
1237
|
}
|
|
1238
|
+
async function inspectPromptTurn(workerSessionID, promptMessageID) {
|
|
1239
|
+
const noMatch = { latestUserPrompt: false, assistantCompleted: false };
|
|
1240
|
+
if (!workerSessionID)
|
|
1241
|
+
return noMatch;
|
|
1242
|
+
let messages;
|
|
1243
|
+
try {
|
|
1244
|
+
messages = await host.readMessages(workerSessionID, 50);
|
|
1245
|
+
} catch {
|
|
1246
|
+
return noMatch;
|
|
1247
|
+
}
|
|
1248
|
+
const users = messages.filter((message) => message.role === "user");
|
|
1249
|
+
if (users.length === 0)
|
|
1250
|
+
return noMatch;
|
|
1251
|
+
const allTimestamped = users.every((message) => message.timestamp && Number.isFinite(Date.parse(message.timestamp)));
|
|
1252
|
+
const ordered = allTimestamped ? [...users].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) : users;
|
|
1253
|
+
return {
|
|
1254
|
+
latestUserPrompt: ordered.at(-1)?.messageID === promptMessageID,
|
|
1255
|
+
assistantCompleted: messages.some((message) => message.role === "assistant" && message.parentMessageID === promptMessageID && Boolean(message.completedAt))
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
974
1258
|
async function handleSessionStatus(state, goal, event) {
|
|
975
1259
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
976
1260
|
if (!runtime)
|
|
@@ -981,9 +1265,17 @@ function createLoopEngine(options) {
|
|
|
981
1265
|
return false;
|
|
982
1266
|
if (statusType === "idle")
|
|
983
1267
|
return handleSessionIdle(state, goal);
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1268
|
+
await mutateState(directory, `status:${goal.id}`, async (s) => {
|
|
1269
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1270
|
+
if (!rt)
|
|
1271
|
+
return s;
|
|
1272
|
+
if (statusType === "busy" || statusType === "retry") {
|
|
1273
|
+
Object.assign(rt, recordActivity(rt));
|
|
1274
|
+
}
|
|
1275
|
+
rt.lastWorkerStatus = statusType;
|
|
1276
|
+
rt.updatedAt = new Date().toISOString();
|
|
1277
|
+
return s;
|
|
1278
|
+
});
|
|
987
1279
|
return true;
|
|
988
1280
|
}
|
|
989
1281
|
async function handleSessionError(state, goal, event) {
|
|
@@ -992,75 +1284,95 @@ function createLoopEngine(options) {
|
|
|
992
1284
|
return false;
|
|
993
1285
|
const error = event.properties?.error;
|
|
994
1286
|
const message = describeError(error) || "unknown error";
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1287
|
+
let shouldNotify = false;
|
|
1288
|
+
const newState = await mutateState(directory, `error:${goal.id}`, async (s) => {
|
|
1289
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
1290
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1291
|
+
if (!rt)
|
|
1292
|
+
return s;
|
|
1293
|
+
rt.consecutiveFailures += 1;
|
|
1294
|
+
rt.lastError = message;
|
|
1295
|
+
rt.updatedAt = new Date().toISOString();
|
|
1296
|
+
rt.idleCandidateAt = undefined;
|
|
1297
|
+
if (rt.phase === "running") {
|
|
1298
|
+
Object.assign(rt, releaseLease(rt));
|
|
1299
|
+
}
|
|
1300
|
+
if (rt.consecutiveFailures >= (goal.config?.maxFailures || 5)) {
|
|
1301
|
+
if (g) {
|
|
1302
|
+
g.status = "blocked";
|
|
1303
|
+
g.updatedAt = new Date().toISOString();
|
|
1304
|
+
g.blocker = {
|
|
1305
|
+
reason: `Failed ${rt.consecutiveFailures} times. Last error: ${message}`,
|
|
1306
|
+
needed: "User intervention required. Use retry to attempt again.",
|
|
1307
|
+
at: new Date().toISOString()
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
if (shouldNotifyParent(rt, "failed")) {
|
|
1311
|
+
markParentNotified(rt, "failed");
|
|
1312
|
+
shouldNotify = true;
|
|
1313
|
+
}
|
|
1314
|
+
} else {
|
|
1315
|
+
const backoffMs = Math.min(30000, 1000 * Math.pow(2, rt.consecutiveFailures));
|
|
1316
|
+
rt.retryAfter = new Date(Date.now() + backoffMs).toISOString();
|
|
1317
|
+
rt.phase = "waiting_retry";
|
|
1318
|
+
}
|
|
1319
|
+
return s;
|
|
1320
|
+
});
|
|
1001
1321
|
await appendEvent(directory, {
|
|
1002
1322
|
version: 1,
|
|
1003
|
-
eventID:
|
|
1323
|
+
eventID: randomUUID2(),
|
|
1004
1324
|
goalID: goal.id,
|
|
1005
1325
|
type: "run.failed",
|
|
1006
1326
|
runID: runtime.activeRunID || "unknown",
|
|
1007
1327
|
error: message,
|
|
1008
|
-
consecutiveFailures: runtime.consecutiveFailures,
|
|
1328
|
+
consecutiveFailures: runtime.consecutiveFailures + 1,
|
|
1009
1329
|
timestamp: new Date().toISOString(),
|
|
1010
|
-
revision:
|
|
1330
|
+
revision: newState.revision
|
|
1011
1331
|
});
|
|
1012
|
-
const
|
|
1013
|
-
if (
|
|
1014
|
-
goal.status = "blocked";
|
|
1015
|
-
goal.updatedAt = new Date().toISOString();
|
|
1016
|
-
goal.blocker = {
|
|
1017
|
-
reason: `Failed ${runtime.consecutiveFailures} times. Last error: ${message}`,
|
|
1018
|
-
needed: "User intervention required. Use retry to attempt again.",
|
|
1019
|
-
at: new Date().toISOString()
|
|
1020
|
-
};
|
|
1332
|
+
const updatedGoal = newState.goals.find((g) => g.id === goal.id);
|
|
1333
|
+
if (updatedGoal?.status === "blocked") {
|
|
1021
1334
|
await appendEvent(directory, {
|
|
1022
1335
|
version: 1,
|
|
1023
|
-
eventID:
|
|
1336
|
+
eventID: randomUUID2(),
|
|
1024
1337
|
goalID: goal.id,
|
|
1025
1338
|
type: "goal.blocked",
|
|
1026
|
-
reason: `Failed ${runtime.consecutiveFailures} times`,
|
|
1339
|
+
reason: `Failed ${runtime.consecutiveFailures + 1} times`,
|
|
1027
1340
|
needed: "User intervention required",
|
|
1028
1341
|
timestamp: new Date().toISOString(),
|
|
1029
|
-
revision:
|
|
1342
|
+
revision: newState.revision
|
|
1030
1343
|
});
|
|
1031
|
-
if (
|
|
1032
|
-
|
|
1033
|
-
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures} failures. Last error: ${message}.`);
|
|
1344
|
+
if (shouldNotify) {
|
|
1345
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures + 1} failures. Last error: ${message}.`);
|
|
1034
1346
|
}
|
|
1035
|
-
} else {
|
|
1036
|
-
const backoffMs = Math.min(30000, 1000 * Math.pow(2, runtime.consecutiveFailures));
|
|
1037
|
-
runtime.retryAfter = new Date(Date.now() + backoffMs).toISOString();
|
|
1038
|
-
runtime.phase = "waiting_retry";
|
|
1039
1347
|
}
|
|
1040
|
-
await writeState(directory, state);
|
|
1041
1348
|
return true;
|
|
1042
1349
|
}
|
|
1043
1350
|
async function handleSessionCompacted(state, goal) {
|
|
1044
1351
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1045
1352
|
if (!runtime)
|
|
1046
1353
|
return false;
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1354
|
+
const newState = await mutateState(directory, `compacted:${goal.id}`, async (s) => {
|
|
1355
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1356
|
+
if (!rt)
|
|
1357
|
+
return s;
|
|
1358
|
+
rt.lastCompactAt = new Date().toISOString();
|
|
1359
|
+
rt.updatedAt = new Date().toISOString();
|
|
1360
|
+
return s;
|
|
1361
|
+
});
|
|
1050
1362
|
await appendEvent(directory, {
|
|
1051
1363
|
version: 1,
|
|
1052
|
-
eventID:
|
|
1364
|
+
eventID: randomUUID2(),
|
|
1053
1365
|
goalID: goal.id,
|
|
1054
1366
|
type: "compaction.completed",
|
|
1055
1367
|
timestamp: new Date().toISOString(),
|
|
1056
|
-
revision:
|
|
1368
|
+
revision: newState.revision
|
|
1057
1369
|
});
|
|
1058
1370
|
return true;
|
|
1059
1371
|
}
|
|
1060
1372
|
function enforceLimits(goal, runtime) {
|
|
1061
1373
|
const noResult = { stop: "none", blocked: false, event: "goal.status_changed", reason: "" };
|
|
1062
1374
|
const maxTurns = goal.config?.maxTurns;
|
|
1063
|
-
if (maxTurns && runtime.
|
|
1375
|
+
if (maxTurns && runtime.budgetTurnCount >= maxTurns) {
|
|
1064
1376
|
return {
|
|
1065
1377
|
stop: "force_finish",
|
|
1066
1378
|
blocked: true,
|
|
@@ -1093,19 +1405,23 @@ function createLoopEngine(options) {
|
|
|
1093
1405
|
const compactEvery = goal.config?.compactEvery;
|
|
1094
1406
|
if (!compactEvery)
|
|
1095
1407
|
return false;
|
|
1096
|
-
return runtime.
|
|
1408
|
+
return runtime.runCount > 0 && runtime.runCount % compactEvery === 0;
|
|
1097
1409
|
}
|
|
1098
1410
|
async function doCompact(goal, runtime) {
|
|
1099
1411
|
if (!goal.workerSessionID)
|
|
1100
1412
|
return;
|
|
1101
1413
|
const prevPhase = runtime.phase;
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1414
|
+
const state = await mutateState(directory, `compact.start:${goal.id}`, async (s) => {
|
|
1415
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1416
|
+
if (!rt)
|
|
1417
|
+
return s;
|
|
1418
|
+
rt.phase = "compacting";
|
|
1419
|
+
rt.lastCompactAt = new Date().toISOString();
|
|
1420
|
+
return s;
|
|
1421
|
+
});
|
|
1106
1422
|
await appendEvent(directory, {
|
|
1107
1423
|
version: 1,
|
|
1108
|
-
eventID:
|
|
1424
|
+
eventID: randomUUID2(),
|
|
1109
1425
|
goalID: goal.id,
|
|
1110
1426
|
type: "compaction.started",
|
|
1111
1427
|
timestamp: new Date().toISOString(),
|
|
@@ -1114,39 +1430,99 @@ function createLoopEngine(options) {
|
|
|
1114
1430
|
try {
|
|
1115
1431
|
await host.compactSession(goal.workerSessionID);
|
|
1116
1432
|
} catch {}
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
}
|
|
1433
|
+
await mutateState(directory, `compact.end:${goal.id}`, async (s) => {
|
|
1434
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1435
|
+
if (rt)
|
|
1436
|
+
rt.phase = prevPhase;
|
|
1437
|
+
return s;
|
|
1438
|
+
});
|
|
1123
1439
|
}
|
|
1124
1440
|
async function maintenance() {
|
|
1125
1441
|
syncWorkerSessionsFromService();
|
|
1126
1442
|
if (knownWorkerSessions.size === 0)
|
|
1127
1443
|
return;
|
|
1128
1444
|
const state = await readState(directory);
|
|
1129
|
-
const hasActiveGoals = state.goals.some((g) =>
|
|
1445
|
+
const hasActiveGoals = state.goals.some((g) => g.status === "active");
|
|
1130
1446
|
if (!hasActiveGoals)
|
|
1131
1447
|
return;
|
|
1132
1448
|
for (const goal of state.goals) {
|
|
1133
|
-
if (
|
|
1449
|
+
if (goal.status !== "active")
|
|
1134
1450
|
continue;
|
|
1135
1451
|
const runtime = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1136
1452
|
if (!runtime)
|
|
1137
1453
|
continue;
|
|
1138
1454
|
if (runtime.phase === "waiting_retry" && runtime.retryAfter) {
|
|
1139
1455
|
if (Date.now() >= Date.parse(runtime.retryAfter)) {
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1456
|
+
await mutateState(directory, `retry-ready:${goal.id}`, async (s) => {
|
|
1457
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1458
|
+
if (rt) {
|
|
1459
|
+
rt.retryAfter = undefined;
|
|
1460
|
+
rt.phase = "idle";
|
|
1461
|
+
}
|
|
1462
|
+
return s;
|
|
1463
|
+
});
|
|
1143
1464
|
goalService.continueTurn(directory, goal.id).catch(() => {});
|
|
1144
1465
|
}
|
|
1145
1466
|
}
|
|
1146
1467
|
if ((runtime.phase === "running" || runtime.phase === "idle") && goal.workerSessionID) {
|
|
1468
|
+
if (runtime.phase === "idle" && runtime.activeRunID) {
|
|
1469
|
+
await mutateState(directory, `maintenance.clear-stale-run:${goal.id}`, async (s) => {
|
|
1470
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1471
|
+
if (rt?.phase === "idle" && rt.activeRunID) {
|
|
1472
|
+
Object.assign(rt, releaseLease(rt));
|
|
1473
|
+
rt.activeRunID = undefined;
|
|
1474
|
+
rt.lastWorkerStatus = "idle";
|
|
1475
|
+
}
|
|
1476
|
+
return s;
|
|
1477
|
+
});
|
|
1478
|
+
await logServerEvent(directory, "maintenance.stale-run-cleared", { goalID: goal.id });
|
|
1479
|
+
}
|
|
1147
1480
|
const status = await host.sessionStatus(goal.workerSessionID);
|
|
1481
|
+
if (status === "unknown") {
|
|
1482
|
+
let shouldNotify = false;
|
|
1483
|
+
const unknownState = await mutateState(directory, `maintenance.unknown-status:${goal.id}`, async (s) => {
|
|
1484
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1485
|
+
if (!rt)
|
|
1486
|
+
return s;
|
|
1487
|
+
rt.unknownStatusCount = Math.min(unknownStatusThreshold, (rt.unknownStatusCount ?? 0) + 1);
|
|
1488
|
+
rt.lastUnknownStatusAt = new Date().toISOString();
|
|
1489
|
+
if (rt.unknownStatusCount >= unknownStatusThreshold && !rt.workerUnreachableNotifiedAt) {
|
|
1490
|
+
rt.workerUnreachableNotifiedAt = new Date().toISOString();
|
|
1491
|
+
shouldNotify = true;
|
|
1492
|
+
}
|
|
1493
|
+
rt.updatedAt = new Date().toISOString();
|
|
1494
|
+
return s;
|
|
1495
|
+
});
|
|
1496
|
+
const unknownRuntime = unknownState.runtimes.find((r) => r.goalID === goal.id);
|
|
1497
|
+
if (shouldNotify) {
|
|
1498
|
+
await logServerEvent(directory, "maintenance.worker-unreachable", {
|
|
1499
|
+
goalID: goal.id,
|
|
1500
|
+
workerSessionID: goal.workerSessionID,
|
|
1501
|
+
count: unknownRuntime?.unknownStatusCount
|
|
1502
|
+
});
|
|
1503
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" worker is unreachable after ${unknownRuntime?.unknownStatusCount ?? unknownStatusThreshold} status checks. The goal remains active; use inspect_background_goal, nudge_goal, pause_goal, or resume_goal to recover it.`);
|
|
1504
|
+
}
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if ((runtime.unknownStatusCount ?? 0) > 0 || runtime.workerUnreachableNotifiedAt) {
|
|
1508
|
+
await mutateState(directory, `maintenance.status-recovered:${goal.id}`, async (s) => {
|
|
1509
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
1510
|
+
if (rt) {
|
|
1511
|
+
rt.unknownStatusCount = 0;
|
|
1512
|
+
rt.lastUnknownStatusAt = undefined;
|
|
1513
|
+
rt.workerUnreachableNotifiedAt = undefined;
|
|
1514
|
+
rt.updatedAt = new Date().toISOString();
|
|
1515
|
+
}
|
|
1516
|
+
return s;
|
|
1517
|
+
});
|
|
1518
|
+
await logServerEvent(directory, "maintenance.worker-recovered", { goalID: goal.id });
|
|
1519
|
+
}
|
|
1148
1520
|
if (status === "idle") {
|
|
1149
|
-
|
|
1521
|
+
if (runtime.phase === "idle") {
|
|
1522
|
+
await continueGoal(goal.id);
|
|
1523
|
+
} else {
|
|
1524
|
+
await handleSessionIdle(state, goal);
|
|
1525
|
+
}
|
|
1150
1526
|
continue;
|
|
1151
1527
|
}
|
|
1152
1528
|
}
|
|
@@ -1156,8 +1532,7 @@ function createLoopEngine(options) {
|
|
|
1156
1532
|
}
|
|
1157
1533
|
|
|
1158
1534
|
// src/application/goal-service.ts
|
|
1159
|
-
import { randomUUID as
|
|
1160
|
-
init_state_repository();
|
|
1535
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1161
1536
|
import * as path2 from "path";
|
|
1162
1537
|
import { promises as fs2 } from "fs";
|
|
1163
1538
|
|
|
@@ -1178,11 +1553,13 @@ function createWorkerManager(host) {
|
|
|
1178
1553
|
},
|
|
1179
1554
|
async continueWorker(worker, goal, runtime, context) {
|
|
1180
1555
|
const prompt = buildContinuationSteering(goal, runtime, context);
|
|
1181
|
-
await host.promptWorker({
|
|
1556
|
+
const result = await host.promptWorker({
|
|
1182
1557
|
sessionID: worker.workerSessionID,
|
|
1183
1558
|
prompt,
|
|
1559
|
+
messageID: runtime.activePromptMessageID,
|
|
1184
1560
|
agent: goal.config.agent
|
|
1185
1561
|
});
|
|
1562
|
+
return result;
|
|
1186
1563
|
},
|
|
1187
1564
|
async isIdle(workerSessionID) {
|
|
1188
1565
|
const status = await host.sessionStatus(workerSessionID);
|
|
@@ -1211,11 +1588,11 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
1211
1588
|
`Exception: if the objective explicitly specifies a different output directory, follow the objective instead.`
|
|
1212
1589
|
];
|
|
1213
1590
|
}
|
|
1214
|
-
if (runtime.
|
|
1591
|
+
if (runtime.runCount <= 1) {
|
|
1215
1592
|
parts.push(`You are a worker for an active goal.`, ``, `Call get_goal to read the authoritative objective, acceptance criteria, and current state.`, `Perform one concrete batch of work. After durable verification:`, ``, `- Call report_goal_progress if work remains.`, `- Call complete_goal only if ALL acceptance criteria pass with concrete evidence.`, `- Call block_goal only for a real external blocker requiring user intervention.`, `- Use the built-in question tool when you need clarification only the user can provide.`, ``, `Do not ask questions unnecessarily. Make reasonable assumptions and work directly.`);
|
|
1216
1593
|
parts.push(...outputLocationBlock());
|
|
1217
1594
|
} else {
|
|
1218
|
-
parts.push(`This is continuation
|
|
1595
|
+
parts.push(`This is continuation run ${runtime.runCount} for the goal below.`, ``, `## GOAL (user-provided data)`, goal.objective);
|
|
1219
1596
|
const progress = context?.progressHistory;
|
|
1220
1597
|
if (progress && progress.length > 0) {
|
|
1221
1598
|
parts.push(``, `## PROGRESS SO FAR`);
|
|
@@ -1243,22 +1620,32 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
1243
1620
|
parts.push(...outputLocationBlock());
|
|
1244
1621
|
if (context?.verification) {
|
|
1245
1622
|
const v = context.verification;
|
|
1246
|
-
parts.push(``, `## VERIFICATION (deterministic pre-screen)`);
|
|
1247
|
-
if (v.checksPassed !== undefined) {
|
|
1248
|
-
if (v.checksPassed)
|
|
1249
|
-
parts.push(`- checks: all passed`);
|
|
1250
|
-
else if (v.failedChecks?.length)
|
|
1251
|
-
parts.push(`- checks FAILED: ${v.failedChecks.join(", ")} \u2014 fix before claiming completion`);
|
|
1252
|
-
else
|
|
1253
|
-
parts.push(`- checks: not yet run`);
|
|
1254
|
-
}
|
|
1255
|
-
if (v.artifactSummary)
|
|
1256
|
-
parts.push(`- artifacts: ${v.artifactSummary}`);
|
|
1257
1623
|
if (v.evaluatorRejectionCount && v.evaluatorRejectionCount > 0) {
|
|
1258
|
-
parts.push(
|
|
1624
|
+
parts.push(``, `## HOST VERDICT: COMPLETION REJECTED`);
|
|
1625
|
+
parts.push(`Rejection #${v.evaluatorRejectionCount}`);
|
|
1626
|
+
if (v.lastRejectionDetails) {
|
|
1627
|
+
parts.push(v.lastRejectionDetails);
|
|
1628
|
+
}
|
|
1629
|
+
parts.push(``, `Required action:`);
|
|
1630
|
+
parts.push(`- Fix the behavior causing the command(s) above to fail.`);
|
|
1631
|
+
parts.push(`- Do NOT merely rewrite the completion evidence.`);
|
|
1632
|
+
parts.push(`- Rerun the command from the stated directory.`);
|
|
1633
|
+
parts.push(`- Call complete_goal only after the command passes.`);
|
|
1634
|
+
} else {
|
|
1635
|
+
parts.push(``, `## VERIFICATION (deterministic pre-screen)`);
|
|
1636
|
+
if (v.checksPassed !== undefined) {
|
|
1637
|
+
if (v.checksPassed)
|
|
1638
|
+
parts.push(`- checks: all passed`);
|
|
1639
|
+
else if (v.failedChecks?.length)
|
|
1640
|
+
parts.push(`- checks FAILED: ${v.failedChecks.join(", ")} \u2014 fix before claiming completion`);
|
|
1641
|
+
else
|
|
1642
|
+
parts.push(`- checks: not yet run`);
|
|
1643
|
+
}
|
|
1644
|
+
if (v.artifactSummary)
|
|
1645
|
+
parts.push(`- artifacts: ${v.artifactSummary}`);
|
|
1259
1646
|
}
|
|
1260
1647
|
}
|
|
1261
|
-
parts.push(``, `## COMPLETION
|
|
1648
|
+
parts.push(``, `## COMPLETION REVIEW`, `You are the semantic reviewer. The host is the acceptance authority.`, `Before proposing completion:`, `1. Derive concrete requirements from the objective and any referenced files/plans/specs/issues.`, `2. For each requirement, identify authoritative evidence: files, command output, test results.`, `3. Judge each: proves | contradicts | incomplete | missing.`, `4. Only call complete_goal when you have verified every requirement yourself.`, `5. If objective and checks appear contradictory, call block_goal \u2014 do not silently violate either.`);
|
|
1262
1649
|
if (context?.forceFinish) {
|
|
1263
1650
|
parts.push(``, `## FINAL REPORT REQUIRED \u2014 STOPPING SOON`, `The system requires you to wrap up now. Do NOT start new work.`, `Call complete_goal NOW with:`, `- summary: a specific semantic summary of what was accomplished (files changed, results, key findings)`, `- evidence: concrete proof (commands run, files created, checks passed)`, `If you cannot complete truthfully, call block_goal with the reason \u2014 do not fabricate evidence.`);
|
|
1264
1651
|
} else {
|
|
@@ -1279,9 +1666,119 @@ function buildContinuationSteering(goal, runtime, context) {
|
|
|
1279
1666
|
function createGoalService(host) {
|
|
1280
1667
|
const workers = createWorkerManager(host);
|
|
1281
1668
|
const sessions = new Map;
|
|
1669
|
+
const goalOperations = new Map;
|
|
1670
|
+
async function withGoalOperation(goalID, fn) {
|
|
1671
|
+
const previous = goalOperations.get(goalID) || Promise.resolve();
|
|
1672
|
+
let release;
|
|
1673
|
+
const gate = new Promise((resolve) => {
|
|
1674
|
+
release = resolve;
|
|
1675
|
+
});
|
|
1676
|
+
const current = previous.catch(() => {}).then(() => gate);
|
|
1677
|
+
goalOperations.set(goalID, current);
|
|
1678
|
+
await previous.catch(() => {});
|
|
1679
|
+
try {
|
|
1680
|
+
return await fn();
|
|
1681
|
+
} finally {
|
|
1682
|
+
release();
|
|
1683
|
+
if (goalOperations.get(goalID) === current)
|
|
1684
|
+
goalOperations.delete(goalID);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function assertWorkspaceWriteAvailable(state, goal) {
|
|
1688
|
+
if (!goal.config.workspaceWrite)
|
|
1689
|
+
return;
|
|
1690
|
+
const activeWriter = state.goals.find((item) => item.id !== goal.id && item.status === "active" && item.config.workspaceWrite);
|
|
1691
|
+
if (activeWriter) {
|
|
1692
|
+
throw new Error(`Workspace-writing goal "${activeWriter.name}" (${activeWriter.id}) is already active. ` + "Pause, block, complete, or clear it before activating another workspace-writing goal.");
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
async function recordPromptFailure(directory, goalID, error, blockImmediately = false) {
|
|
1696
|
+
const detail = describeError(error);
|
|
1697
|
+
let runID = "unknown";
|
|
1698
|
+
let failureCount = 0;
|
|
1699
|
+
let blocked = false;
|
|
1700
|
+
let blockerNeeded = "Retry after the OpenCode worker/session API is available.";
|
|
1701
|
+
const state = await mutateState(directory, `turn.prompt-failed:${goalID}`, async (s) => {
|
|
1702
|
+
const goal = s.goals.find((item) => item.id === goalID);
|
|
1703
|
+
const rt = s.runtimes.find((item) => item.goalID === goalID);
|
|
1704
|
+
if (!goal || !rt)
|
|
1705
|
+
return s;
|
|
1706
|
+
runID = rt.activeRunID || "unknown";
|
|
1707
|
+
failureCount = rt.consecutiveFailures + 1;
|
|
1708
|
+
Object.assign(rt, releaseLease(rt));
|
|
1709
|
+
rt.activeRunID = undefined;
|
|
1710
|
+
rt.consecutiveFailures = failureCount;
|
|
1711
|
+
rt.lastError = detail;
|
|
1712
|
+
blocked = blockImmediately || failureCount >= (goal.config.maxFailures || 5);
|
|
1713
|
+
if (blocked) {
|
|
1714
|
+
blockerNeeded = blockImmediately ? "Retry after the OpenCode worker/session API is available." : "Fix the underlying error and use retry_goal to attempt again.";
|
|
1715
|
+
goal.status = "blocked";
|
|
1716
|
+
goal.blocker = {
|
|
1717
|
+
reason: `Worker prompt delivery failed: ${detail}`,
|
|
1718
|
+
needed: blockerNeeded,
|
|
1719
|
+
at: new Date().toISOString()
|
|
1720
|
+
};
|
|
1721
|
+
} else {
|
|
1722
|
+
const backoffMs = Math.min(30000, 1000 * Math.pow(2, failureCount));
|
|
1723
|
+
rt.phase = "waiting_retry";
|
|
1724
|
+
rt.retryAfter = new Date(Date.now() + backoffMs).toISOString();
|
|
1725
|
+
}
|
|
1726
|
+
goal.updatedAt = new Date().toISOString();
|
|
1727
|
+
rt.updatedAt = new Date().toISOString();
|
|
1728
|
+
return s;
|
|
1729
|
+
});
|
|
1730
|
+
await appendEvent(directory, {
|
|
1731
|
+
version: 1,
|
|
1732
|
+
eventID: randomUUID3(),
|
|
1733
|
+
goalID,
|
|
1734
|
+
type: "run.failed",
|
|
1735
|
+
runID,
|
|
1736
|
+
error: detail,
|
|
1737
|
+
consecutiveFailures: failureCount,
|
|
1738
|
+
timestamp: new Date().toISOString(),
|
|
1739
|
+
revision: state.revision
|
|
1740
|
+
});
|
|
1741
|
+
if (blocked) {
|
|
1742
|
+
await appendEvent(directory, {
|
|
1743
|
+
version: 1,
|
|
1744
|
+
eventID: randomUUID3(),
|
|
1745
|
+
goalID,
|
|
1746
|
+
type: "goal.blocked",
|
|
1747
|
+
reason: `Worker prompt delivery failed: ${detail}`,
|
|
1748
|
+
needed: blockerNeeded,
|
|
1749
|
+
timestamp: new Date().toISOString(),
|
|
1750
|
+
revision: state.revision
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
async function ensureWorkerSession(directory, goal) {
|
|
1755
|
+
let session = sessions.get(goal.id);
|
|
1756
|
+
if (session)
|
|
1757
|
+
return session;
|
|
1758
|
+
if (goal.workerSessionID) {
|
|
1759
|
+
session = {
|
|
1760
|
+
goalID: goal.id,
|
|
1761
|
+
workerSessionID: goal.workerSessionID,
|
|
1762
|
+
startedAt: goal.createdAt
|
|
1763
|
+
};
|
|
1764
|
+
sessions.set(goal.id, session);
|
|
1765
|
+
return session;
|
|
1766
|
+
}
|
|
1767
|
+
session = await workers.createWorker(goal);
|
|
1768
|
+
sessions.set(goal.id, session);
|
|
1769
|
+
await mutateState(directory, `goal.set-worker:${goal.id}`, async (s) => {
|
|
1770
|
+
const persisted = s.goals.find((item) => item.id === goal.id);
|
|
1771
|
+
if (persisted)
|
|
1772
|
+
persisted.workerSessionID = session.workerSessionID;
|
|
1773
|
+
return s;
|
|
1774
|
+
});
|
|
1775
|
+
return session;
|
|
1776
|
+
}
|
|
1282
1777
|
async function start(directory, input) {
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1778
|
+
const id = randomUUID3();
|
|
1779
|
+
return withGoalOperation(id, () => startUnlocked(directory, input, id));
|
|
1780
|
+
}
|
|
1781
|
+
async function startUnlocked(directory, input, id) {
|
|
1285
1782
|
const goal = createGoal({
|
|
1286
1783
|
id,
|
|
1287
1784
|
name: input.name,
|
|
@@ -1290,6 +1787,7 @@ function createGoalService(host) {
|
|
|
1290
1787
|
ownerSessionID: input.ownerSessionID,
|
|
1291
1788
|
config: {
|
|
1292
1789
|
maxTurns: 50,
|
|
1790
|
+
workspaceWrite: true,
|
|
1293
1791
|
...input.config
|
|
1294
1792
|
}
|
|
1295
1793
|
});
|
|
@@ -1298,86 +1796,109 @@ function createGoalService(host) {
|
|
|
1298
1796
|
if (!goal.config.progressFile)
|
|
1299
1797
|
goal.config.progressFile = path2.join(artifactDir, "progress.md");
|
|
1300
1798
|
await ensureGoalArtifactDir(directory, id);
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1799
|
+
const state1 = await mutateState(directory, `goal.create:${id}`, async (state) => {
|
|
1800
|
+
assertWorkspaceWriteAvailable(state, goal);
|
|
1801
|
+
state.goals.push(goal);
|
|
1802
|
+
state.runtimes.push(createRuntimeState(id));
|
|
1803
|
+
const runtime2 = state.runtimes.find((r) => r.goalID === id);
|
|
1804
|
+
if (runtime2)
|
|
1805
|
+
runtime2.phase = "queued";
|
|
1806
|
+
return state;
|
|
1807
|
+
});
|
|
1808
|
+
let runtime = state1.runtimes.find((r) => r.goalID === id);
|
|
1308
1809
|
let worker;
|
|
1309
1810
|
try {
|
|
1310
1811
|
worker = await workers.createWorker(goal);
|
|
1311
1812
|
} catch (error) {
|
|
1312
1813
|
const detail = describeError(error);
|
|
1313
|
-
goal.
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1814
|
+
const blockedState = await mutateState(directory, `goal.blocked:${id}`, async (state) => {
|
|
1815
|
+
const g = state.goals.find((item) => item.id === id);
|
|
1816
|
+
if (!g)
|
|
1817
|
+
return state;
|
|
1818
|
+
g.status = "blocked";
|
|
1819
|
+
g.updatedAt = new Date().toISOString();
|
|
1820
|
+
g.blocker = {
|
|
1821
|
+
reason: detail,
|
|
1822
|
+
needed: "Start the goal again from a valid OpenCode session after correcting the worker creation error.",
|
|
1823
|
+
at: new Date().toISOString()
|
|
1824
|
+
};
|
|
1825
|
+
const rt = state.runtimes.find((item) => item.goalID === id);
|
|
1826
|
+
if (rt) {
|
|
1827
|
+
rt.phase = "idle";
|
|
1828
|
+
rt.lastError = detail;
|
|
1829
|
+
rt.updatedAt = new Date().toISOString();
|
|
1830
|
+
}
|
|
1831
|
+
return state;
|
|
1832
|
+
});
|
|
1326
1833
|
await appendEvent(directory, {
|
|
1327
1834
|
version: 1,
|
|
1328
|
-
eventID:
|
|
1835
|
+
eventID: randomUUID3(),
|
|
1329
1836
|
goalID: id,
|
|
1330
1837
|
type: "goal.blocked",
|
|
1331
1838
|
reason: detail,
|
|
1332
|
-
needed: goal.blocker
|
|
1839
|
+
needed: goal.blocker?.needed || "",
|
|
1333
1840
|
timestamp: new Date().toISOString(),
|
|
1334
|
-
revision:
|
|
1841
|
+
revision: blockedState.revision
|
|
1335
1842
|
});
|
|
1336
1843
|
await logServerEvent(directory, "goal.start.failed", { goalID: id, ownerSessionID: input.ownerSessionID, detail });
|
|
1337
1844
|
throw error;
|
|
1338
1845
|
}
|
|
1339
1846
|
sessions.set(id, worker);
|
|
1340
|
-
goal.
|
|
1341
|
-
|
|
1847
|
+
const state2 = await mutateState(directory, `goal.worker-assign:${id}`, async (state) => {
|
|
1848
|
+
const g = state.goals.find((item) => item.id === id);
|
|
1849
|
+
if (!g)
|
|
1850
|
+
return state;
|
|
1851
|
+
g.workerSessionID = worker.workerSessionID;
|
|
1852
|
+
goal.workerSessionID = worker.workerSessionID;
|
|
1853
|
+
const rt = state.runtimes.find((item) => item.goalID === id);
|
|
1854
|
+
if (rt) {
|
|
1855
|
+
Object.assign(rt, acquireLease(rt, g.config.timeoutMs || 300000));
|
|
1856
|
+
rt.activeRunID = randomUUID3();
|
|
1857
|
+
rt.activePromptMessageID = `msg-${randomUUID3()}`;
|
|
1858
|
+
rt.runCount = 1;
|
|
1859
|
+
rt.budgetTurnCount = 1;
|
|
1860
|
+
rt.lastRunAt = new Date().toISOString();
|
|
1861
|
+
}
|
|
1862
|
+
return state;
|
|
1863
|
+
});
|
|
1864
|
+
runtime = state2.runtimes.find((r) => r.goalID === id);
|
|
1342
1865
|
await appendEvent(directory, {
|
|
1343
1866
|
version: 1,
|
|
1344
|
-
eventID:
|
|
1867
|
+
eventID: randomUUID3(),
|
|
1345
1868
|
goalID: id,
|
|
1346
1869
|
type: "goal.created",
|
|
1347
1870
|
name: input.name,
|
|
1348
1871
|
objective: input.objective,
|
|
1349
1872
|
ownerSessionID: input.ownerSessionID,
|
|
1350
1873
|
timestamp: new Date().toISOString(),
|
|
1351
|
-
revision:
|
|
1874
|
+
revision: state2.revision
|
|
1352
1875
|
});
|
|
1353
1876
|
if (runtime) {
|
|
1354
|
-
const runID = randomUUID4();
|
|
1355
|
-
Object.assign(runtime, acquireLease(runtime, goal.config.timeoutMs || 300000));
|
|
1356
|
-
runtime.activeRunID = runID;
|
|
1357
|
-
runtime.turnCount = 1;
|
|
1358
|
-
runtime.runCount = 1;
|
|
1359
|
-
runtime.lastRunAt = new Date().toISOString();
|
|
1360
|
-
await writeState(directory, state);
|
|
1361
1877
|
await appendEvent(directory, {
|
|
1362
1878
|
version: 1,
|
|
1363
|
-
eventID:
|
|
1879
|
+
eventID: randomUUID3(),
|
|
1364
1880
|
goalID: id,
|
|
1365
1881
|
type: "run.started",
|
|
1366
|
-
runID,
|
|
1367
|
-
turnCount: runtime.
|
|
1882
|
+
runID: runtime.activeRunID,
|
|
1883
|
+
turnCount: runtime.runCount,
|
|
1368
1884
|
timestamp: new Date().toISOString(),
|
|
1369
|
-
revision:
|
|
1885
|
+
revision: state2.revision
|
|
1370
1886
|
});
|
|
1371
|
-
|
|
1887
|
+
try {
|
|
1888
|
+
await workers.continueWorker(worker, goal, runtime);
|
|
1889
|
+
} catch (error) {
|
|
1890
|
+
await recordPromptFailure(directory, id, error, true);
|
|
1891
|
+
throw error;
|
|
1892
|
+
}
|
|
1372
1893
|
}
|
|
1373
1894
|
return { goal, worker };
|
|
1374
1895
|
}
|
|
1375
|
-
async function
|
|
1376
|
-
const
|
|
1377
|
-
const goal =
|
|
1378
|
-
if (!goal ||
|
|
1896
|
+
async function continueTurnUnlocked(directory, goalID, opts) {
|
|
1897
|
+
const preState = await readState(directory);
|
|
1898
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
1899
|
+
if (!goal || goal.status !== "active")
|
|
1379
1900
|
return;
|
|
1380
|
-
const runtime =
|
|
1901
|
+
const runtime = preState.runtimes.find((r) => r.goalID === goalID);
|
|
1381
1902
|
if (!runtime)
|
|
1382
1903
|
return;
|
|
1383
1904
|
if (runtime.phase === "running" && leaseIsValid(runtime))
|
|
@@ -1393,24 +1914,43 @@ function createGoalService(host) {
|
|
|
1393
1914
|
}
|
|
1394
1915
|
if (!session)
|
|
1395
1916
|
return;
|
|
1396
|
-
if (!await workers.isIdle(session.workerSessionID))
|
|
1917
|
+
if (!opts?.force && !await workers.isIdle(session.workerSessionID))
|
|
1918
|
+
return;
|
|
1919
|
+
let acquired = false;
|
|
1920
|
+
const state = await mutateState(directory, `turn.acquire:${goalID}`, async (s) => {
|
|
1921
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
1922
|
+
if (!g || g.status !== "active")
|
|
1923
|
+
return s;
|
|
1924
|
+
const rt = s.runtimes.find((item) => item.goalID === goalID);
|
|
1925
|
+
if (!rt)
|
|
1926
|
+
return s;
|
|
1927
|
+
if (rt.phase === "running" && leaseIsValid(rt))
|
|
1928
|
+
return s;
|
|
1929
|
+
const timeoutMs = g.config.timeoutMs || 300000;
|
|
1930
|
+
Object.assign(rt, acquireLease(rt, timeoutMs));
|
|
1931
|
+
rt.activeRunID = randomUUID3();
|
|
1932
|
+
rt.activePromptMessageID = `msg-${randomUUID3()}`;
|
|
1933
|
+
rt.runCount += 1;
|
|
1934
|
+
if (rt.freeRetryPending) {
|
|
1935
|
+
rt.freeRetryPending = false;
|
|
1936
|
+
} else {
|
|
1937
|
+
rt.budgetTurnCount += 1;
|
|
1938
|
+
}
|
|
1939
|
+
rt.lastRunAt = new Date().toISOString();
|
|
1940
|
+
acquired = true;
|
|
1941
|
+
return s;
|
|
1942
|
+
});
|
|
1943
|
+
const freshGoal = state.goals.find((g) => g.id === goalID);
|
|
1944
|
+
const freshRuntime = state.runtimes.find((r) => r.goalID === goalID);
|
|
1945
|
+
if (!acquired || !freshGoal || freshGoal.status !== "active" || !freshRuntime?.activeRunID)
|
|
1397
1946
|
return;
|
|
1398
|
-
const timeoutMs = goal.config.timeoutMs || 300000;
|
|
1399
|
-
const leased = acquireLease(runtime, timeoutMs);
|
|
1400
|
-
Object.assign(runtime, leased);
|
|
1401
|
-
const runID = randomUUID4();
|
|
1402
|
-
runtime.activeRunID = runID;
|
|
1403
|
-
runtime.turnCount += 1;
|
|
1404
|
-
runtime.runCount += 1;
|
|
1405
|
-
runtime.lastRunAt = new Date().toISOString();
|
|
1406
|
-
await writeState(directory, state);
|
|
1407
1947
|
await appendEvent(directory, {
|
|
1408
1948
|
version: 1,
|
|
1409
|
-
eventID:
|
|
1949
|
+
eventID: randomUUID3(),
|
|
1410
1950
|
goalID,
|
|
1411
1951
|
type: "run.started",
|
|
1412
|
-
runID,
|
|
1413
|
-
turnCount:
|
|
1952
|
+
runID: freshRuntime.activeRunID,
|
|
1953
|
+
turnCount: freshRuntime.runCount,
|
|
1414
1954
|
timestamp: new Date().toISOString(),
|
|
1415
1955
|
revision: state.revision
|
|
1416
1956
|
});
|
|
@@ -1423,13 +1963,13 @@ function createGoalService(host) {
|
|
|
1423
1963
|
}));
|
|
1424
1964
|
let transcriptTail;
|
|
1425
1965
|
try {
|
|
1426
|
-
transcriptTail = await host.readMessages(
|
|
1966
|
+
transcriptTail = await host.readMessages(freshGoal.workerSessionID, 5);
|
|
1427
1967
|
} catch {
|
|
1428
1968
|
transcriptTail = [];
|
|
1429
1969
|
}
|
|
1430
1970
|
let verification;
|
|
1431
1971
|
try {
|
|
1432
|
-
const artifactDir =
|
|
1972
|
+
const artifactDir = freshGoal.config.artifactDir;
|
|
1433
1973
|
if (artifactDir) {
|
|
1434
1974
|
try {
|
|
1435
1975
|
const files = await fs2.readdir(artifactDir);
|
|
@@ -1438,12 +1978,15 @@ function createGoalService(host) {
|
|
|
1438
1978
|
verification = { artifactSummary: "no artifacts yet" };
|
|
1439
1979
|
}
|
|
1440
1980
|
}
|
|
1441
|
-
if (
|
|
1442
|
-
const c = `checks configured: ${
|
|
1981
|
+
if (freshGoal.config.checks?.length) {
|
|
1982
|
+
const c = `checks configured: ${freshGoal.config.checks.length} \u2014 run them before claiming completion`;
|
|
1443
1983
|
verification = { ...verification || {}, failedChecks: [c], checksPassed: undefined };
|
|
1444
1984
|
}
|
|
1445
|
-
if (
|
|
1446
|
-
verification = { ...verification || {}, evaluatorRejectionCount:
|
|
1985
|
+
if (freshRuntime.evaluatorRejectionCount && freshRuntime.evaluatorRejectionCount > 0) {
|
|
1986
|
+
verification = { ...verification || {}, evaluatorRejectionCount: freshRuntime.evaluatorRejectionCount };
|
|
1987
|
+
}
|
|
1988
|
+
if (freshRuntime.lastRejectionDetails) {
|
|
1989
|
+
verification = { ...verification || {}, lastRejectionDetails: freshRuntime.lastRejectionDetails };
|
|
1447
1990
|
}
|
|
1448
1991
|
} catch {}
|
|
1449
1992
|
const context = {
|
|
@@ -1453,17 +1996,31 @@ function createGoalService(host) {
|
|
|
1453
1996
|
forceFinish: opts?.forceFinish || undefined,
|
|
1454
1997
|
verification
|
|
1455
1998
|
};
|
|
1456
|
-
|
|
1999
|
+
try {
|
|
2000
|
+
await workers.continueWorker(session, freshGoal, freshRuntime, context);
|
|
2001
|
+
} catch (error) {
|
|
2002
|
+
await recordPromptFailure(directory, goalID, error);
|
|
2003
|
+
throw error;
|
|
2004
|
+
}
|
|
1457
2005
|
}
|
|
1458
|
-
async function
|
|
1459
|
-
const
|
|
1460
|
-
const goal =
|
|
2006
|
+
async function pauseUnlocked(directory, goalID) {
|
|
2007
|
+
const preState = await readState(directory);
|
|
2008
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
1461
2009
|
if (!goal)
|
|
1462
2010
|
return;
|
|
1463
2011
|
if (!canTransition(goal.status, "paused", "user"))
|
|
1464
2012
|
return;
|
|
1465
|
-
goal.
|
|
1466
|
-
|
|
2013
|
+
const state = await mutateState(directory, `goal.pause:${goalID}`, async (s) => {
|
|
2014
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
2015
|
+
if (!g)
|
|
2016
|
+
return s;
|
|
2017
|
+
g.status = "paused";
|
|
2018
|
+
g.updatedAt = new Date().toISOString();
|
|
2019
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2020
|
+
if (rt)
|
|
2021
|
+
Object.assign(rt, releaseLease(rt));
|
|
2022
|
+
return s;
|
|
2023
|
+
});
|
|
1467
2024
|
const session = sessions.get(goalID) || (goal.workerSessionID ? {
|
|
1468
2025
|
goalID: goal.id,
|
|
1469
2026
|
workerSessionID: goal.workerSessionID,
|
|
@@ -1473,14 +2030,9 @@ function createGoalService(host) {
|
|
|
1473
2030
|
await workers.abortWorker(session.workerSessionID);
|
|
1474
2031
|
sessions.delete(goalID);
|
|
1475
2032
|
}
|
|
1476
|
-
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
1477
|
-
if (runtime) {
|
|
1478
|
-
Object.assign(runtime, releaseLease(runtime));
|
|
1479
|
-
}
|
|
1480
|
-
await writeState(directory, state);
|
|
1481
2033
|
await appendEvent(directory, {
|
|
1482
2034
|
version: 1,
|
|
1483
|
-
eventID:
|
|
2035
|
+
eventID: randomUUID3(),
|
|
1484
2036
|
goalID,
|
|
1485
2037
|
type: "goal.status_changed",
|
|
1486
2038
|
from: "active",
|
|
@@ -1489,25 +2041,37 @@ function createGoalService(host) {
|
|
|
1489
2041
|
revision: state.revision
|
|
1490
2042
|
});
|
|
1491
2043
|
}
|
|
1492
|
-
async function
|
|
1493
|
-
|
|
2044
|
+
async function resumeUnlocked(directory, goalID) {
|
|
2045
|
+
let resumed = false;
|
|
2046
|
+
const state = await mutateState(directory, `goal.resume:${goalID}`, async (state2) => {
|
|
2047
|
+
const goal2 = state2.goals.find((g) => g.id === goalID);
|
|
2048
|
+
if (!goal2)
|
|
2049
|
+
return state2;
|
|
2050
|
+
if (!canTransition(goal2.status, "active", "user"))
|
|
2051
|
+
return state2;
|
|
2052
|
+
assertWorkspaceWriteAvailable(state2, goal2);
|
|
2053
|
+
goal2.status = "active";
|
|
2054
|
+
goal2.updatedAt = new Date().toISOString();
|
|
2055
|
+
resumed = true;
|
|
2056
|
+
return state2;
|
|
2057
|
+
});
|
|
1494
2058
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
1495
|
-
if (!goal)
|
|
2059
|
+
if (!goal || !resumed)
|
|
1496
2060
|
return;
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
2061
|
+
try {
|
|
2062
|
+
await ensureWorkerSession(directory, goal);
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
await mutateState(directory, `goal.resume-rollback:${goalID}`, async (s) => {
|
|
2065
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
2066
|
+
if (g?.status === "active")
|
|
2067
|
+
g.status = "paused";
|
|
2068
|
+
return s;
|
|
2069
|
+
});
|
|
2070
|
+
throw error;
|
|
1506
2071
|
}
|
|
1507
|
-
await writeState(directory, state);
|
|
1508
2072
|
await appendEvent(directory, {
|
|
1509
2073
|
version: 1,
|
|
1510
|
-
eventID:
|
|
2074
|
+
eventID: randomUUID3(),
|
|
1511
2075
|
goalID,
|
|
1512
2076
|
type: "goal.status_changed",
|
|
1513
2077
|
from: "paused",
|
|
@@ -1515,29 +2079,47 @@ function createGoalService(host) {
|
|
|
1515
2079
|
timestamp: new Date().toISOString(),
|
|
1516
2080
|
revision: state.revision
|
|
1517
2081
|
});
|
|
1518
|
-
await
|
|
2082
|
+
await continueTurnUnlocked(directory, goalID);
|
|
1519
2083
|
}
|
|
1520
|
-
async function
|
|
1521
|
-
|
|
2084
|
+
async function retryUnlocked(directory, goalID) {
|
|
2085
|
+
let retried = false;
|
|
2086
|
+
const state = await mutateState(directory, `goal.retry:${goalID}`, async (state2) => {
|
|
2087
|
+
const goal2 = state2.goals.find((g) => g.id === goalID);
|
|
2088
|
+
if (!goal2 || goal2.status !== "blocked")
|
|
2089
|
+
return state2;
|
|
2090
|
+
assertWorkspaceWriteAvailable(state2, goal2);
|
|
2091
|
+
goal2.status = "active";
|
|
2092
|
+
goal2.updatedAt = new Date().toISOString();
|
|
2093
|
+
retried = true;
|
|
2094
|
+
const runtime = state2.runtimes.find((r) => r.goalID === goalID);
|
|
2095
|
+
if (runtime) {
|
|
2096
|
+
runtime.consecutiveFailures = 0;
|
|
2097
|
+
runtime.lastError = undefined;
|
|
2098
|
+
runtime.forceFinishRequested = undefined;
|
|
2099
|
+
runtime.lastParentNotifiedAt = undefined;
|
|
2100
|
+
runtime.lastParentNotifiedFor = undefined;
|
|
2101
|
+
runtime.phase = "idle";
|
|
2102
|
+
runtime.updatedAt = new Date().toISOString();
|
|
2103
|
+
}
|
|
2104
|
+
return state2;
|
|
2105
|
+
});
|
|
1522
2106
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
1523
|
-
if (!goal ||
|
|
2107
|
+
if (!goal || !retried)
|
|
1524
2108
|
return;
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
runtime.updatedAt = new Date().toISOString();
|
|
2109
|
+
try {
|
|
2110
|
+
await ensureWorkerSession(directory, goal);
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
await mutateState(directory, `goal.retry-rollback:${goalID}`, async (s) => {
|
|
2113
|
+
const g = s.goals.find((item) => item.id === goalID);
|
|
2114
|
+
if (g?.status === "active")
|
|
2115
|
+
g.status = "blocked";
|
|
2116
|
+
return s;
|
|
2117
|
+
});
|
|
2118
|
+
throw error;
|
|
1536
2119
|
}
|
|
1537
|
-
await writeState(directory, state);
|
|
1538
2120
|
await appendEvent(directory, {
|
|
1539
2121
|
version: 1,
|
|
1540
|
-
eventID:
|
|
2122
|
+
eventID: randomUUID3(),
|
|
1541
2123
|
goalID,
|
|
1542
2124
|
type: "goal.status_changed",
|
|
1543
2125
|
from: "blocked",
|
|
@@ -1545,9 +2127,9 @@ function createGoalService(host) {
|
|
|
1545
2127
|
timestamp: new Date().toISOString(),
|
|
1546
2128
|
revision: state.revision
|
|
1547
2129
|
});
|
|
1548
|
-
await
|
|
2130
|
+
await continueTurnUnlocked(directory, goalID);
|
|
1549
2131
|
}
|
|
1550
|
-
async function
|
|
2132
|
+
async function clearUnlocked(directory, goalID) {
|
|
1551
2133
|
const state = await readState(directory);
|
|
1552
2134
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
1553
2135
|
if (!goal)
|
|
@@ -1561,17 +2143,19 @@ function createGoalService(host) {
|
|
|
1561
2143
|
await workers.abortWorker(session.workerSessionID);
|
|
1562
2144
|
sessions.delete(goalID);
|
|
1563
2145
|
}
|
|
2146
|
+
await mutateState(directory, `goal.clear:${goalID}`, async (s) => {
|
|
2147
|
+
s.goals = s.goals.filter((g) => g.id !== goalID);
|
|
2148
|
+
s.runtimes = s.runtimes.filter((r) => r.goalID !== goalID);
|
|
2149
|
+
return s;
|
|
2150
|
+
});
|
|
1564
2151
|
await appendEvent(directory, {
|
|
1565
2152
|
version: 1,
|
|
1566
|
-
eventID:
|
|
2153
|
+
eventID: randomUUID3(),
|
|
1567
2154
|
goalID,
|
|
1568
2155
|
type: "goal.cleared",
|
|
1569
2156
|
timestamp: new Date().toISOString(),
|
|
1570
2157
|
revision: state.revision
|
|
1571
2158
|
});
|
|
1572
|
-
state.goals = state.goals.filter((g) => g.id !== goalID);
|
|
1573
|
-
state.runtimes = state.runtimes.filter((r) => r.goalID !== goalID);
|
|
1574
|
-
await writeState(directory, state);
|
|
1575
2159
|
}
|
|
1576
2160
|
function getWorker(goalID) {
|
|
1577
2161
|
return sessions.get(goalID);
|
|
@@ -1587,29 +2171,42 @@ function createGoalService(host) {
|
|
|
1587
2171
|
if (goal.status === "paused")
|
|
1588
2172
|
continue;
|
|
1589
2173
|
if (!goal.workerSessionID) {
|
|
2174
|
+
let worker;
|
|
1590
2175
|
try {
|
|
1591
|
-
|
|
2176
|
+
worker = await workers.createWorker(goal);
|
|
1592
2177
|
sessions.set(goal.id, worker);
|
|
1593
|
-
goal.workerSessionID = worker.workerSessionID;
|
|
1594
|
-
goal.updatedAt = new Date().toISOString();
|
|
1595
2178
|
} catch (error) {
|
|
1596
2179
|
const detail = describeError(error);
|
|
1597
|
-
goal.
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2180
|
+
await mutateState(directory, `reconcile.block:${goal.id}`, async (s) => {
|
|
2181
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
2182
|
+
if (!g)
|
|
2183
|
+
return s;
|
|
2184
|
+
g.status = "blocked";
|
|
2185
|
+
g.updatedAt = new Date().toISOString();
|
|
2186
|
+
g.blocker = {
|
|
2187
|
+
reason: detail,
|
|
2188
|
+
needed: "Clear this goal and start it again from a valid OpenCode session.",
|
|
2189
|
+
at: new Date().toISOString()
|
|
2190
|
+
};
|
|
2191
|
+
const rt = s.runtimes.find((item) => item.goalID === goal.id);
|
|
2192
|
+
if (rt) {
|
|
2193
|
+
rt.phase = "idle";
|
|
2194
|
+
rt.lastError = detail;
|
|
2195
|
+
rt.updatedAt = new Date().toISOString();
|
|
2196
|
+
}
|
|
2197
|
+
return s;
|
|
2198
|
+
});
|
|
1610
2199
|
await logServerEvent(directory, "goal.reconcile.failed", { goalID: goal.id, ownerSessionID: goal.ownerSessionID, detail });
|
|
1611
2200
|
continue;
|
|
1612
2201
|
}
|
|
2202
|
+
await mutateState(directory, `reconcile.set-worker:${goal.id}`, async (s) => {
|
|
2203
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
2204
|
+
if (g) {
|
|
2205
|
+
g.workerSessionID = worker.workerSessionID;
|
|
2206
|
+
g.updatedAt = new Date().toISOString();
|
|
2207
|
+
}
|
|
2208
|
+
return s;
|
|
2209
|
+
});
|
|
1613
2210
|
}
|
|
1614
2211
|
if (goal.workerSessionID && !sessions.has(goal.id)) {
|
|
1615
2212
|
sessions.set(goal.id, {
|
|
@@ -1618,18 +2215,70 @@ function createGoalService(host) {
|
|
|
1618
2215
|
startedAt: goal.createdAt
|
|
1619
2216
|
});
|
|
1620
2217
|
}
|
|
1621
|
-
const
|
|
1622
|
-
if (
|
|
2218
|
+
const preRt = state.runtimes.find((r) => r.goalID === goal.id);
|
|
2219
|
+
if (preRt?.phase === "running" && !leaseIsValid(preRt)) {
|
|
1623
2220
|
const session = sessions.get(goal.id);
|
|
1624
2221
|
if (session && await workers.isIdle(session.workerSessionID)) {
|
|
1625
|
-
|
|
1626
|
-
|
|
2222
|
+
await mutateState(directory, `reconcile.release-lease:${goal.id}`, async (s) => {
|
|
2223
|
+
const rt = s.runtimes.find((r) => r.goalID === goal.id);
|
|
2224
|
+
if (rt) {
|
|
2225
|
+
Object.assign(rt, releaseLease(rt));
|
|
2226
|
+
const g = s.goals.find((item) => item.id === goal.id);
|
|
2227
|
+
if (g)
|
|
2228
|
+
g.updatedAt = new Date().toISOString();
|
|
2229
|
+
}
|
|
2230
|
+
return s;
|
|
2231
|
+
});
|
|
1627
2232
|
}
|
|
1628
2233
|
}
|
|
1629
2234
|
}
|
|
1630
|
-
await writeState(directory, state);
|
|
1631
2235
|
}
|
|
1632
|
-
|
|
2236
|
+
async function nudgeUnlocked(directory, goalID) {
|
|
2237
|
+
const preState = await readState(directory);
|
|
2238
|
+
const goal = preState.goals.find((g) => g.id === goalID);
|
|
2239
|
+
if (!goal)
|
|
2240
|
+
return { ok: false, message: "Goal not found." };
|
|
2241
|
+
if (goal.status !== "active") {
|
|
2242
|
+
return { ok: false, message: `Goal is ${goal.status}; resume or retry it before nudging.` };
|
|
2243
|
+
}
|
|
2244
|
+
const cleared = await mutateState(directory, `goal.nudge:${goalID}`, async (s) => {
|
|
2245
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
2246
|
+
if (!rt)
|
|
2247
|
+
return s;
|
|
2248
|
+
rt.phase = "idle";
|
|
2249
|
+
rt.activeRunID = undefined;
|
|
2250
|
+
rt.idleCandidateAt = undefined;
|
|
2251
|
+
rt.activePromptMessageID = undefined;
|
|
2252
|
+
rt.activeToolCallIDs = [];
|
|
2253
|
+
rt.updatedAt = new Date().toISOString();
|
|
2254
|
+
return s;
|
|
2255
|
+
});
|
|
2256
|
+
const freshGoal = cleared.goals.find((g) => g.id === goalID);
|
|
2257
|
+
if (!freshGoal || !freshGoal.workerSessionID) {
|
|
2258
|
+
return { ok: false, message: "Goal has no worker session. Use resume_goal or retry_goal." };
|
|
2259
|
+
}
|
|
2260
|
+
await continueTurnUnlocked(directory, goalID, { force: true });
|
|
2261
|
+
return { ok: true, message: `Re-prompted worker for "${freshGoal.name}".` };
|
|
2262
|
+
}
|
|
2263
|
+
function continueTurn(directory, goalID, opts) {
|
|
2264
|
+
return withGoalOperation(goalID, () => continueTurnUnlocked(directory, goalID, opts));
|
|
2265
|
+
}
|
|
2266
|
+
function pause(directory, goalID) {
|
|
2267
|
+
return withGoalOperation(goalID, () => pauseUnlocked(directory, goalID));
|
|
2268
|
+
}
|
|
2269
|
+
function resume(directory, goalID) {
|
|
2270
|
+
return withGoalOperation(goalID, () => resumeUnlocked(directory, goalID));
|
|
2271
|
+
}
|
|
2272
|
+
function retry(directory, goalID) {
|
|
2273
|
+
return withGoalOperation(goalID, () => retryUnlocked(directory, goalID));
|
|
2274
|
+
}
|
|
2275
|
+
function clear(directory, goalID) {
|
|
2276
|
+
return withGoalOperation(goalID, () => clearUnlocked(directory, goalID));
|
|
2277
|
+
}
|
|
2278
|
+
function nudge(directory, goalID) {
|
|
2279
|
+
return withGoalOperation(goalID, () => nudgeUnlocked(directory, goalID));
|
|
2280
|
+
}
|
|
2281
|
+
return { start, continueTurn, nudge, pause, resume, retry, clear, getWorker, getActiveWorkers, reconcile };
|
|
1633
2282
|
}
|
|
1634
2283
|
|
|
1635
2284
|
// src/server/host-adapter.ts
|
|
@@ -1672,10 +2321,14 @@ function createRealHost(client, directory) {
|
|
|
1672
2321
|
throw new Error(`OpenCode session.create failed for parent "${parentID}": ${detail}`);
|
|
1673
2322
|
}
|
|
1674
2323
|
},
|
|
1675
|
-
async promptWorker({ sessionID, prompt, model, agent }) {
|
|
2324
|
+
async promptWorker({ sessionID, prompt, messageID, model, agent }) {
|
|
1676
2325
|
const body = {
|
|
1677
2326
|
parts: [{ type: "text", text: prompt }]
|
|
1678
2327
|
};
|
|
2328
|
+
if (messageID) {
|
|
2329
|
+
const collapsed = messageID.replace(/^(msg-)+/, "msg-");
|
|
2330
|
+
body.messageID = collapsed.startsWith("msg-") ? collapsed : `msg-${messageID}`;
|
|
2331
|
+
}
|
|
1679
2332
|
if (model)
|
|
1680
2333
|
body.model = model;
|
|
1681
2334
|
if (agent)
|
|
@@ -1690,22 +2343,23 @@ function createRealHost(client, directory) {
|
|
|
1690
2343
|
throw new Error(`OpenCode session.promptAsync failed for worker "${sessionID}": ${detail}`);
|
|
1691
2344
|
}
|
|
1692
2345
|
await logServerEvent(directory, "worker.prompted", { sessionID });
|
|
2346
|
+
return { messageID: result?.data?.messageID };
|
|
1693
2347
|
},
|
|
1694
2348
|
async sessionStatus(sessionID) {
|
|
1695
2349
|
try {
|
|
1696
2350
|
const result = await client.session.status({});
|
|
1697
2351
|
const data = result?.data;
|
|
1698
2352
|
if (!data || typeof data !== "object")
|
|
1699
|
-
return "
|
|
2353
|
+
return "unknown";
|
|
1700
2354
|
const status = data[sessionID];
|
|
1701
2355
|
if (!status || typeof status !== "object")
|
|
1702
|
-
return "
|
|
2356
|
+
return "unknown";
|
|
1703
2357
|
const type = status.type;
|
|
1704
2358
|
if (type === "busy" || type === "retry")
|
|
1705
2359
|
return type;
|
|
1706
2360
|
return "idle";
|
|
1707
2361
|
} catch {
|
|
1708
|
-
return "
|
|
2362
|
+
return "unknown";
|
|
1709
2363
|
}
|
|
1710
2364
|
},
|
|
1711
2365
|
async abortSession(sessionID) {
|
|
@@ -1726,8 +2380,10 @@ function createRealHost(client, directory) {
|
|
|
1726
2380
|
role: m.info?.role || "assistant",
|
|
1727
2381
|
content: m.parts?.filter((p) => p.type === "text").map((p) => p.text).join(`
|
|
1728
2382
|
`) || "",
|
|
1729
|
-
timestamp: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined,
|
|
1730
|
-
messageID: m.id
|
|
2383
|
+
timestamp: m.info?.time?.completed || m.info?.time?.created ? new Date(m.info.time.completed || m.info.time.created).toISOString() : undefined,
|
|
2384
|
+
messageID: m.info?.id || m.id,
|
|
2385
|
+
parentMessageID: m.info?.parentID,
|
|
2386
|
+
completedAt: m.info?.time?.completed ? new Date(m.info.time.completed).toISOString() : undefined
|
|
1731
2387
|
}));
|
|
1732
2388
|
} catch {
|
|
1733
2389
|
return [];
|
|
@@ -1775,27 +2431,39 @@ async function withTimeout(promise, timeoutMs, operation) {
|
|
|
1775
2431
|
}
|
|
1776
2432
|
|
|
1777
2433
|
// src/server/goal-tools.ts
|
|
1778
|
-
|
|
1779
|
-
import { randomUUID as randomUUID5 } from "crypto";
|
|
2434
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1780
2435
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2436
|
+
// src/domain/verification.ts
|
|
2437
|
+
var MAX_RECENT_ATTEMPTS = 10;
|
|
2438
|
+
function appendVerificationAttempt(recent, attempt) {
|
|
2439
|
+
const next = [...recent, attempt];
|
|
2440
|
+
if (next.length > MAX_RECENT_ATTEMPTS) {
|
|
2441
|
+
return next.slice(next.length - MAX_RECENT_ATTEMPTS);
|
|
2442
|
+
}
|
|
2443
|
+
return next;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// src/server/goal-tools.ts
|
|
1781
2447
|
import { exec as execChild } from "child_process";
|
|
1782
2448
|
import { promisify } from "util";
|
|
1783
2449
|
var execAsync = promisify(execChild);
|
|
1784
|
-
function goalTools(dir, goalService, hostSessionID) {
|
|
2450
|
+
function goalTools(dir, goalService, hostSessionID, defaults = {}) {
|
|
1785
2451
|
return {
|
|
1786
2452
|
loopd_create_goal: tool({
|
|
1787
|
-
description: "Create a new background loop goal. The engine spawns a dedicated worker session
|
|
2453
|
+
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. " + "Specify 'agent' or configure plugin defaultAgent.",
|
|
1788
2454
|
args: {
|
|
1789
2455
|
name: tool.schema.string().describe("Short goal name (used in the dashboard)."),
|
|
1790
2456
|
objective: tool.schema.string().describe("What the goal should accomplish, in detail."),
|
|
2457
|
+
agent: tool.schema.string().optional().describe("Agent to run the worker as. Required unless the plugin has defaultAgent configured."),
|
|
1791
2458
|
checks: tool.schema.array(tool.schema.string()).optional().describe('Shell commands that must pass for completion to be accepted. E.g. ["npm test"].'),
|
|
2459
|
+
checkCwd: tool.schema.string().optional().describe("Directory where completion checks run. Workspace-writing goals default to the project root."),
|
|
2460
|
+
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."),
|
|
1792
2461
|
progressFile: tool.schema.string().optional().describe("Markdown file the worker reads/writes as its transaction state."),
|
|
1793
2462
|
maxTurns: tool.schema.number().optional().describe("Max turns before auto-block."),
|
|
1794
2463
|
maxNoProgress: tool.schema.number().optional().describe("Block after N turns without progress."),
|
|
1795
2464
|
maxFailures: tool.schema.number().optional().describe("Block after N consecutive failures."),
|
|
1796
2465
|
compactEvery: tool.schema.number().optional().describe("Compact the worker session every N turns."),
|
|
1797
|
-
timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms.")
|
|
1798
|
-
agent: tool.schema.string().optional().describe('Agent to run the worker as (e.g. "dumb-agent", "build"). Defaults to primary agent.')
|
|
2466
|
+
timeoutMs: tool.schema.number().optional().describe("Per-turn timeout in ms.")
|
|
1799
2467
|
},
|
|
1800
2468
|
execute: async (args, context) => {
|
|
1801
2469
|
const sessionID = context?.sessionID || hostSessionID;
|
|
@@ -1811,8 +2479,14 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1811
2479
|
const config = {
|
|
1812
2480
|
maxTurns: 50
|
|
1813
2481
|
};
|
|
2482
|
+
if (args.agent)
|
|
2483
|
+
config.agent = args.agent;
|
|
1814
2484
|
if (args.checks)
|
|
1815
2485
|
config.checks = args.checks;
|
|
2486
|
+
if (args.checkCwd)
|
|
2487
|
+
config.checkCwd = args.checkCwd;
|
|
2488
|
+
if (args.workspaceWrite !== undefined)
|
|
2489
|
+
config.workspaceWrite = args.workspaceWrite;
|
|
1816
2490
|
if (args.progressFile)
|
|
1817
2491
|
config.progressFile = args.progressFile;
|
|
1818
2492
|
if (args.maxTurns !== undefined)
|
|
@@ -1825,14 +2499,28 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1825
2499
|
config.compactEvery = args.compactEvery;
|
|
1826
2500
|
if (args.timeoutMs !== undefined)
|
|
1827
2501
|
config.timeoutMs = args.timeoutMs;
|
|
1828
|
-
|
|
1829
|
-
|
|
2502
|
+
const resolution = resolveGoalCreationConfig({
|
|
2503
|
+
directory: dir,
|
|
2504
|
+
objective: args.objective,
|
|
2505
|
+
config,
|
|
2506
|
+
defaults
|
|
2507
|
+
});
|
|
2508
|
+
if (!resolution.ok) {
|
|
2509
|
+
return {
|
|
2510
|
+
title: "Goal not created",
|
|
2511
|
+
output: JSON.stringify({
|
|
2512
|
+
ok: false,
|
|
2513
|
+
message: resolution.message,
|
|
2514
|
+
errorCode: resolution.errorCode
|
|
2515
|
+
})
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
1830
2518
|
try {
|
|
1831
2519
|
const { goal, worker } = await goalService.start(dir, {
|
|
1832
2520
|
name: args.name,
|
|
1833
2521
|
objective: args.objective,
|
|
1834
2522
|
ownerSessionID: sessionID,
|
|
1835
|
-
config
|
|
2523
|
+
config: resolution.config
|
|
1836
2524
|
});
|
|
1837
2525
|
return {
|
|
1838
2526
|
title: "Goal created",
|
|
@@ -1841,8 +2529,12 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1841
2529
|
goalID: goal.id,
|
|
1842
2530
|
workerSessionID: worker.workerSessionID,
|
|
1843
2531
|
artifactDir: goal.config.artifactDir,
|
|
2532
|
+
agent: resolution.config.agent,
|
|
2533
|
+
checks: resolution.config.checks || [],
|
|
2534
|
+
workspaceWrite: resolution.config.workspaceWrite,
|
|
2535
|
+
defaultsApplied: resolution.defaultsApplied,
|
|
1844
2536
|
name: args.name,
|
|
1845
|
-
message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>
|
|
2537
|
+
message: `Goal "${args.name}" created and started in the background. Artifacts: ${goal.config.artifactDir}. Monitor with /loop (<leader>o).`
|
|
1846
2538
|
})
|
|
1847
2539
|
};
|
|
1848
2540
|
} catch (error) {
|
|
@@ -1859,7 +2551,7 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1859
2551
|
}
|
|
1860
2552
|
}),
|
|
1861
2553
|
get_goal: tool({
|
|
1862
|
-
description: "Get the current goal state. Call at the start of every
|
|
2554
|
+
description: "Get the current goal contract and state. Call at the start of every turn to retrieve the objective, checks, limits, and recent failures (including HOST VERDICT if the last completion was rejected). Returns structured JSON with config and runtime (phase, runGeneration, rejectionCount).",
|
|
1863
2555
|
args: {},
|
|
1864
2556
|
execute: async (_args, context) => {
|
|
1865
2557
|
const state = await readState(dir);
|
|
@@ -1882,7 +2574,7 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1882
2574
|
}
|
|
1883
2575
|
}),
|
|
1884
2576
|
report_goal_progress: tool({
|
|
1885
|
-
description: "Report meaningful progress
|
|
2577
|
+
description: "Report meaningful progress (resets consecutiveFailures/noProgressCount). Call after durable state changes (file writes, verifications) \u2014 not after thinking. The engine uses this to avoid force-finish.",
|
|
1886
2578
|
args: {
|
|
1887
2579
|
summary: tool.schema.string().describe("What was accomplished."),
|
|
1888
2580
|
next: tool.schema.string().describe("The next concrete step."),
|
|
@@ -1912,7 +2604,7 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1912
2604
|
await writeState(dir, state);
|
|
1913
2605
|
const event = {
|
|
1914
2606
|
version: 1,
|
|
1915
|
-
eventID:
|
|
2607
|
+
eventID: randomUUID4(),
|
|
1916
2608
|
goalID: goal.id,
|
|
1917
2609
|
type: "goal.progress",
|
|
1918
2610
|
summary: args.summary,
|
|
@@ -1927,13 +2619,13 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1927
2619
|
goalName: goal.name,
|
|
1928
2620
|
summary: args.summary,
|
|
1929
2621
|
next: args.next,
|
|
1930
|
-
turn: runtime?.
|
|
2622
|
+
turn: runtime?.runCount
|
|
1931
2623
|
})
|
|
1932
2624
|
};
|
|
1933
2625
|
}
|
|
1934
2626
|
}),
|
|
1935
2627
|
complete_goal: tool({
|
|
1936
|
-
description: "
|
|
2628
|
+
description: "Propose completion. Host runs checks from checkCwd (writers default to project root) \u2014 if any fail, host rejects (rejectionCount++, freeRetryPending if <3, blocked after 3 with HOST VERDICT). Only call when every requirement is proved with evidence; the host decides, not the model.",
|
|
1937
2629
|
args: {
|
|
1938
2630
|
summary: tool.schema.string().describe("What was completed."),
|
|
1939
2631
|
evidence: tool.schema.string().describe("Concrete evidence of completion.")
|
|
@@ -1949,16 +2641,84 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1949
2641
|
return { title: "Invalid transition", output: `Cannot complete goal in ${goal.status} state.` };
|
|
1950
2642
|
}
|
|
1951
2643
|
if (goal.config.checks?.length) {
|
|
1952
|
-
const
|
|
2644
|
+
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
2645
|
+
const checkResults = await runCompletionChecks(goal.config.checks, cwd);
|
|
1953
2646
|
if (!checkResults.passed) {
|
|
1954
2647
|
const runtime2 = state.runtimes.find((r) => r.goalID === goal.id);
|
|
1955
2648
|
if (runtime2) {
|
|
1956
2649
|
runtime2.evaluatorRejectionCount = (runtime2.evaluatorRejectionCount || 0) + 1;
|
|
1957
|
-
|
|
1958
|
-
|
|
2650
|
+
const failureDetails = checkResults.failures.map((f) => {
|
|
2651
|
+
const stdoutSnippet = f.stdout ? `
|
|
2652
|
+
Stdout: ${f.stdout.slice(0, 500)}` : "";
|
|
2653
|
+
const stderrSnippet = f.stderr ? `
|
|
2654
|
+
Stderr: ${f.stderr.slice(0, 500)}` : "";
|
|
2655
|
+
return `Command: ${f.command}
|
|
2656
|
+
Exit code: ${f.exitCode}${stdoutSnippet}${stderrSnippet}`;
|
|
2657
|
+
}).join(`
|
|
2658
|
+
|
|
2659
|
+
`);
|
|
2660
|
+
runtime2.lastRejectionDetails = `Rejection #${runtime2.evaluatorRejectionCount} at ${new Date().toISOString()}
|
|
2661
|
+
|
|
2662
|
+
Working directory: ${cwd}
|
|
2663
|
+
|
|
2664
|
+
${failureDetails}`;
|
|
2665
|
+
const attemptID = randomUUID4();
|
|
2666
|
+
const verificationAttempt = {
|
|
2667
|
+
id: attemptID,
|
|
2668
|
+
sequence: runtime2.evaluatorRejectionCount,
|
|
2669
|
+
runGeneration: runtime2.runGeneration,
|
|
2670
|
+
claimedSummary: args.summary,
|
|
2671
|
+
claimedEvidence: args.evidence,
|
|
2672
|
+
startedAt: new Date().toISOString(),
|
|
2673
|
+
completedAt: new Date().toISOString(),
|
|
2674
|
+
status: "failed",
|
|
2675
|
+
cwd,
|
|
2676
|
+
checks: checkResults.failures.map((f) => ({
|
|
2677
|
+
command: f.command,
|
|
2678
|
+
exitCode: f.exitCode,
|
|
2679
|
+
stderr: f.stderr,
|
|
2680
|
+
stdout: f.stdout
|
|
2681
|
+
}))
|
|
2682
|
+
};
|
|
2683
|
+
runtime2.lastVerificationAttempt = verificationAttempt;
|
|
2684
|
+
runtime2.recentVerificationAttempts = appendVerificationAttempt(runtime2.recentVerificationAttempts || [], verificationAttempt);
|
|
2685
|
+
const rejectEvent = {
|
|
2686
|
+
version: 1,
|
|
2687
|
+
eventID: randomUUID4(),
|
|
2688
|
+
goalID: goal.id,
|
|
2689
|
+
type: "goal.completion_rejected",
|
|
2690
|
+
attemptID,
|
|
2691
|
+
rejectionCount: runtime2.evaluatorRejectionCount,
|
|
2692
|
+
failedCheckCount: checkResults.failures.length,
|
|
2693
|
+
failureSummary: failureDetails.slice(0, 500),
|
|
2694
|
+
timestamp: new Date().toISOString(),
|
|
2695
|
+
revision: state.revision
|
|
2696
|
+
};
|
|
2697
|
+
await appendEvent(dir, rejectEvent);
|
|
2698
|
+
const maxRejections = goal.config.maxEvaluatorRejections || 3;
|
|
2699
|
+
if (runtime2.evaluatorRejectionCount >= maxRejections) {
|
|
2700
|
+
goal.status = "blocked";
|
|
2701
|
+
goal.updatedAt = new Date().toISOString();
|
|
2702
|
+
goal.blocker = {
|
|
2703
|
+
reason: `Evaluator rejected ${runtime2.evaluatorRejectionCount} time(s). Last failure:
|
|
2704
|
+
${failureDetails.slice(0, 500)}`,
|
|
2705
|
+
needed: "Fix the failing checks and retry the goal.",
|
|
2706
|
+
at: new Date().toISOString()
|
|
2707
|
+
};
|
|
2708
|
+
runtime2.forceFinishRequested = undefined;
|
|
2709
|
+
await appendEvent(dir, {
|
|
2710
|
+
version: 1,
|
|
2711
|
+
eventID: randomUUID4(),
|
|
2712
|
+
goalID: goal.id,
|
|
2713
|
+
type: "goal.blocked",
|
|
2714
|
+
reason: goal.blocker.reason,
|
|
2715
|
+
needed: goal.blocker.needed,
|
|
2716
|
+
timestamp: new Date().toISOString(),
|
|
2717
|
+
revision: state.revision
|
|
2718
|
+
});
|
|
1959
2719
|
} else {
|
|
1960
2720
|
runtime2.forceFinishRequested = false;
|
|
1961
|
-
runtime2.
|
|
2721
|
+
runtime2.freeRetryPending = true;
|
|
1962
2722
|
}
|
|
1963
2723
|
runtime2.updatedAt = new Date().toISOString();
|
|
1964
2724
|
await writeState(dir, state);
|
|
@@ -1969,7 +2729,8 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1969
2729
|
passed: false,
|
|
1970
2730
|
failedChecks: checkResults.failures,
|
|
1971
2731
|
message: "Evaluator rejected completion. Fix the issues above and try again.",
|
|
1972
|
-
rejectionCount: runtime2?.evaluatorRejectionCount || 0
|
|
2732
|
+
rejectionCount: runtime2?.evaluatorRejectionCount || 0,
|
|
2733
|
+
status: goal.status
|
|
1973
2734
|
})
|
|
1974
2735
|
};
|
|
1975
2736
|
}
|
|
@@ -1985,11 +2746,31 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
1985
2746
|
if (runtime) {
|
|
1986
2747
|
runtime.phase = "idle";
|
|
1987
2748
|
runtime.lastError = undefined;
|
|
2749
|
+
const attemptID = randomUUID4();
|
|
2750
|
+
const cwd = goal.config.checkCwd || goal.config.artifactDir || dir;
|
|
2751
|
+
const checks = (goal.config.checks || []).map((cmd) => ({
|
|
2752
|
+
command: cmd,
|
|
2753
|
+
exitCode: 0
|
|
2754
|
+
}));
|
|
2755
|
+
const verificationAttempt = {
|
|
2756
|
+
id: attemptID,
|
|
2757
|
+
sequence: (runtime.evaluatorRejectionCount || 0) + 1,
|
|
2758
|
+
runGeneration: runtime.runGeneration,
|
|
2759
|
+
claimedSummary: args.summary,
|
|
2760
|
+
claimedEvidence: args.evidence,
|
|
2761
|
+
startedAt: new Date().toISOString(),
|
|
2762
|
+
completedAt: new Date().toISOString(),
|
|
2763
|
+
status: "passed",
|
|
2764
|
+
cwd,
|
|
2765
|
+
checks
|
|
2766
|
+
};
|
|
2767
|
+
runtime.lastVerificationAttempt = verificationAttempt;
|
|
2768
|
+
runtime.recentVerificationAttempts = appendVerificationAttempt(runtime.recentVerificationAttempts || [], verificationAttempt);
|
|
1988
2769
|
}
|
|
1989
2770
|
await writeState(dir, state);
|
|
1990
2771
|
const event = {
|
|
1991
2772
|
version: 1,
|
|
1992
|
-
eventID:
|
|
2773
|
+
eventID: randomUUID4(),
|
|
1993
2774
|
goalID: goal.id,
|
|
1994
2775
|
type: "goal.completed",
|
|
1995
2776
|
summary: args.summary,
|
|
@@ -2011,7 +2792,7 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
2011
2792
|
}
|
|
2012
2793
|
}),
|
|
2013
2794
|
block_goal: tool({
|
|
2014
|
-
description: "Mark
|
|
2795
|
+
description: "Mark blocked for a real external blocker (missing creds, contradictory objective vs checks). Use only when you cannot proceed \u2014 the engine will not auto-continue blocked goals until resume/retry.",
|
|
2015
2796
|
args: {
|
|
2016
2797
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
2017
2798
|
needed: tool.schema.string().describe("What is needed to unblock.")
|
|
@@ -2041,7 +2822,7 @@ function goalTools(dir, goalService, hostSessionID) {
|
|
|
2041
2822
|
await writeState(dir, state);
|
|
2042
2823
|
const event = {
|
|
2043
2824
|
version: 1,
|
|
2044
|
-
eventID:
|
|
2825
|
+
eventID: randomUUID4(),
|
|
2045
2826
|
goalID: goal.id,
|
|
2046
2827
|
type: "goal.blocked",
|
|
2047
2828
|
reason: args.reason,
|
|
@@ -2082,6 +2863,9 @@ function formatGoalStructured(goal, runtime) {
|
|
|
2082
2863
|
progressFile: goal.config.progressFile,
|
|
2083
2864
|
includeFiles: goal.config.includeFiles,
|
|
2084
2865
|
checks: goal.config.checks,
|
|
2866
|
+
checkCwd: goal.config.checkCwd,
|
|
2867
|
+
workspaceWrite: goal.config.workspaceWrite,
|
|
2868
|
+
agent: goal.config.agent,
|
|
2085
2869
|
maxTurns: goal.config.maxTurns,
|
|
2086
2870
|
maxNoProgress: goal.config.maxNoProgress,
|
|
2087
2871
|
maxFailures: goal.config.maxFailures,
|
|
@@ -2097,28 +2881,43 @@ function formatGoalStructured(goal, runtime) {
|
|
|
2097
2881
|
if (runtime) {
|
|
2098
2882
|
output.runtime = {
|
|
2099
2883
|
phase: runtime.phase,
|
|
2100
|
-
turnCount: runtime.turnCount,
|
|
2101
2884
|
runCount: runtime.runCount,
|
|
2885
|
+
budgetTurnCount: runtime.budgetTurnCount,
|
|
2886
|
+
runGeneration: runtime.runGeneration,
|
|
2887
|
+
evaluatorRejectionCount: runtime.evaluatorRejectionCount,
|
|
2888
|
+
freeRetryPending: runtime.freeRetryPending,
|
|
2889
|
+
lastRejectionDetails: runtime.lastRejectionDetails,
|
|
2102
2890
|
consecutiveFailures: runtime.consecutiveFailures,
|
|
2103
2891
|
noProgressCount: runtime.noProgressCount,
|
|
2104
2892
|
lastError: runtime.lastError,
|
|
2105
2893
|
lastProgressAt: runtime.lastProgressAt,
|
|
2106
2894
|
lastRunAt: runtime.lastRunAt,
|
|
2107
|
-
lastCompactAt: runtime.lastCompactAt
|
|
2895
|
+
lastCompactAt: runtime.lastCompactAt,
|
|
2896
|
+
lastActivityAt: runtime.lastActivityAt,
|
|
2897
|
+
activePromptMessageID: runtime.activePromptMessageID,
|
|
2898
|
+
activeAssistantMessageID: runtime.activeAssistantMessageID,
|
|
2899
|
+
activeAssistantCompletedAt: runtime.activeAssistantCompletedAt,
|
|
2900
|
+
idleCandidateGeneration: runtime.idleCandidateGeneration,
|
|
2901
|
+
unknownStatusCount: runtime.unknownStatusCount,
|
|
2902
|
+
lastUnknownStatusAt: runtime.lastUnknownStatusAt,
|
|
2903
|
+
workerUnreachableNotifiedAt: runtime.workerUnreachableNotifiedAt,
|
|
2904
|
+
lastVerificationAttempt: runtime.lastVerificationAttempt,
|
|
2905
|
+
recentVerificationAttempts: runtime.recentVerificationAttempts
|
|
2108
2906
|
};
|
|
2109
2907
|
}
|
|
2110
2908
|
return JSON.stringify(output, null, 2);
|
|
2111
2909
|
}
|
|
2112
|
-
async function runCompletionChecks(checks) {
|
|
2910
|
+
async function runCompletionChecks(checks, cwd) {
|
|
2113
2911
|
const failures = [];
|
|
2114
2912
|
for (const cmd of checks) {
|
|
2115
2913
|
try {
|
|
2116
|
-
await execAsync(cmd, { timeout: 30000 });
|
|
2914
|
+
const { stdout, stderr } = await execAsync(cmd, { timeout: 30000, cwd });
|
|
2117
2915
|
} catch (error) {
|
|
2118
2916
|
failures.push({
|
|
2119
2917
|
command: cmd,
|
|
2120
|
-
exitCode: error.code
|
|
2121
|
-
stderr: error.stderr || error.message || "unknown error"
|
|
2918
|
+
exitCode: error.code ?? 1,
|
|
2919
|
+
stderr: String(error.stderr || error.message || "unknown error").slice(0, 1000),
|
|
2920
|
+
stdout: String(error.stdout || "").slice(0, 1000)
|
|
2122
2921
|
});
|
|
2123
2922
|
}
|
|
2124
2923
|
}
|
|
@@ -2129,13 +2928,12 @@ async function runCompletionChecks(checks) {
|
|
|
2129
2928
|
}
|
|
2130
2929
|
|
|
2131
2930
|
// src/server/owner-tools.ts
|
|
2132
|
-
init_state_repository();
|
|
2133
2931
|
import { tool as tool2 } from "@opencode-ai/plugin/tool";
|
|
2134
2932
|
function ownerTools(options) {
|
|
2135
2933
|
const { directory, host, goalService } = options;
|
|
2136
2934
|
return {
|
|
2137
2935
|
list_background_goals: tool2({
|
|
2138
|
-
description: "List all background
|
|
2936
|
+
description: "List all active background goals owned by this session. Shows contract (name, status, phase, turn, last progress, blocker) \u2014 only goals with your ownerSessionID appear.",
|
|
2139
2937
|
args: {},
|
|
2140
2938
|
execute: async (_args, context) => {
|
|
2141
2939
|
const state = await readState(directory);
|
|
@@ -2164,7 +2962,7 @@ function ownerTools(options) {
|
|
|
2164
2962
|
name: g.name,
|
|
2165
2963
|
status: g.status,
|
|
2166
2964
|
phase: runtime?.phase ?? "unknown",
|
|
2167
|
-
turn: runtime?.
|
|
2965
|
+
turn: runtime?.runCount ?? 0,
|
|
2168
2966
|
lastProgress: g.lastProgress?.summary?.slice(0, 120),
|
|
2169
2967
|
lastProgressAt: g.lastProgress?.at,
|
|
2170
2968
|
blocker: g.blocker?.reason?.slice(0, 120)
|
|
@@ -2177,7 +2975,7 @@ function ownerTools(options) {
|
|
|
2177
2975
|
}
|
|
2178
2976
|
}),
|
|
2179
2977
|
inspect_background_goal: tool2({
|
|
2180
|
-
description: "Inspect a
|
|
2978
|
+
description: "Inspect a goal\u2019s full contract and runtime: objective, config{agent,checks,checkCwd,workspaceWrite,limits}, progress, blocker, and runtime{phase,runCount,budgetTurnCount,runGeneration,evaluatorRejectionCount,unknownStatusCount,lastActivityAt,activePromptMessageID}. The source for recovery decisions.",
|
|
2181
2979
|
args: {
|
|
2182
2980
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to inspect the first active goal.")
|
|
2183
2981
|
},
|
|
@@ -2207,7 +3005,10 @@ function ownerTools(options) {
|
|
|
2207
3005
|
maxFailures: goal.config.maxFailures,
|
|
2208
3006
|
timeoutMs: goal.config.timeoutMs,
|
|
2209
3007
|
progressFile: goal.config.progressFile,
|
|
2210
|
-
checks: goal.config.checks
|
|
3008
|
+
checks: goal.config.checks,
|
|
3009
|
+
checkCwd: goal.config.checkCwd,
|
|
3010
|
+
workspaceWrite: goal.config.workspaceWrite,
|
|
3011
|
+
agent: goal.config.agent
|
|
2211
3012
|
},
|
|
2212
3013
|
lastProgress: goal.lastProgress,
|
|
2213
3014
|
completionEvidence: goal.completionEvidence,
|
|
@@ -2216,8 +3017,11 @@ function ownerTools(options) {
|
|
|
2216
3017
|
timeUsedSeconds: goal.timeUsedSeconds,
|
|
2217
3018
|
runtime: runtime ? {
|
|
2218
3019
|
phase: runtime.phase,
|
|
2219
|
-
turnCount: runtime.turnCount,
|
|
2220
3020
|
runCount: runtime.runCount,
|
|
3021
|
+
budgetTurnCount: runtime.budgetTurnCount,
|
|
3022
|
+
runGeneration: runtime.runGeneration,
|
|
3023
|
+
evaluatorRejectionCount: runtime.evaluatorRejectionCount,
|
|
3024
|
+
freeRetryPending: runtime.freeRetryPending,
|
|
2221
3025
|
consecutiveFailures: runtime.consecutiveFailures,
|
|
2222
3026
|
lastError: runtime.lastError,
|
|
2223
3027
|
lastProgressAt: runtime.lastProgressAt,
|
|
@@ -2228,7 +3032,7 @@ function ownerTools(options) {
|
|
|
2228
3032
|
}
|
|
2229
3033
|
}),
|
|
2230
3034
|
read_goal_transcript: tool2({
|
|
2231
|
-
description: "Read the last N messages from
|
|
3035
|
+
description: "Read the last N messages from the worker transcript (role, content, messageID). Shows HOST VERDICT, steering, and whether the worker\u2019s last prompt was correlated. Use to debug why a completion was rejected or why a worker is stuck.",
|
|
2232
3036
|
args: {
|
|
2233
3037
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to read the first active goal."),
|
|
2234
3038
|
limit: tool2.schema.number().optional().describe("Max messages to return (default: 20).")
|
|
@@ -2277,7 +3081,7 @@ function ownerTools(options) {
|
|
|
2277
3081
|
}
|
|
2278
3082
|
}),
|
|
2279
3083
|
send_goal_input: tool2({
|
|
2280
|
-
description: "Send
|
|
3084
|
+
description: "Send an inbox message to the worker (injected as ## USER INSTRUCTIONS on next turn). Use to answer `question`, redirect, or refine scope. Does NOT itself re-prompt \u2014 the engine re-prompts on next idle/maintenance; use nudge_goal if the worker is stuck with no activity.",
|
|
2281
3085
|
args: {
|
|
2282
3086
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to target the first active goal."),
|
|
2283
3087
|
message: tool2.schema.string().describe("Message to send to the worker.")
|
|
@@ -2305,7 +3109,7 @@ function ownerTools(options) {
|
|
|
2305
3109
|
}
|
|
2306
3110
|
}),
|
|
2307
3111
|
pause_goal: tool2({
|
|
2308
|
-
description: "Pause
|
|
3112
|
+
description: "Pause an active goal: status active \u2192 paused, releaseLease, abortWorker, per-goal mutex. Frees the workspaceWrite slot. Use to investigate or to free the single-writer slot.",
|
|
2309
3113
|
args: {
|
|
2310
3114
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to pause the first active goal.")
|
|
2311
3115
|
},
|
|
@@ -2349,7 +3153,7 @@ function ownerTools(options) {
|
|
|
2349
3153
|
}
|
|
2350
3154
|
}),
|
|
2351
3155
|
resume_goal: tool2({
|
|
2352
|
-
description: "Resume a paused
|
|
3156
|
+
description: "Resume a paused (\u2192active, reuses existing worker if sessionStatus still idle/busy) or retry a blocked (\u2192active, resets consecutiveFailures/forceFinish). Fails with 'already active' if another workspaceWrite writer is active. Per-goal mutex.",
|
|
2353
3157
|
args: {
|
|
2354
3158
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to resume the first paused/blocked goal.")
|
|
2355
3159
|
},
|
|
@@ -2399,8 +3203,41 @@ function ownerTools(options) {
|
|
|
2399
3203
|
}
|
|
2400
3204
|
}
|
|
2401
3205
|
}),
|
|
3206
|
+
nudge_goal: tool2({
|
|
3207
|
+
description: "Force re-prompt a stuck active worker (the hardened recovery). Clears stale activeRunID/idleCandidate/generation/lease (phase\u2192idle) and calls continueTurn({force:true}) even if sessionStatus is not idle. Use when unknownStatusCount\u22653, lastActivityAt is stale, or maintenance notified 'worker is unreachable'. Per-goal mutex.",
|
|
3208
|
+
args: {
|
|
3209
|
+
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to nudge the first active goal.")
|
|
3210
|
+
},
|
|
3211
|
+
execute: async (args, context) => {
|
|
3212
|
+
const state = await readState(directory);
|
|
3213
|
+
const ownerID = context?.sessionID;
|
|
3214
|
+
const goal = args.goal_id ? state.goals.find((g) => g.id === args.goal_id && g.ownerSessionID === ownerID) : state.goals.find((g) => g.ownerSessionID === ownerID && g.status !== "complete");
|
|
3215
|
+
if (!goal) {
|
|
3216
|
+
return {
|
|
3217
|
+
title: "No goal found",
|
|
3218
|
+
output: JSON.stringify({ ok: false, message: "No matching active goal for this session." })
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
3221
|
+
try {
|
|
3222
|
+
const result = await goalService.nudge(directory, goal.id);
|
|
3223
|
+
return {
|
|
3224
|
+
title: result.ok ? "Goal nudged" : "Nudge failed",
|
|
3225
|
+
output: JSON.stringify({ ...result, goalID: goal.id, goalName: goal.name })
|
|
3226
|
+
};
|
|
3227
|
+
} catch (error) {
|
|
3228
|
+
return {
|
|
3229
|
+
title: "Nudge failed",
|
|
3230
|
+
output: JSON.stringify({
|
|
3231
|
+
ok: false,
|
|
3232
|
+
goalName: goal.name,
|
|
3233
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3234
|
+
})
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
}),
|
|
2402
3239
|
clear_goal: tool2({
|
|
2403
|
-
description: "Clear a
|
|
3240
|
+
description: "Clear a goal: aborts worker and removes goal+runtime+ledger (cannot be undone). Use when the goal is no longer needed or to free a stuck writer slot after inspection.",
|
|
2404
3241
|
args: {
|
|
2405
3242
|
goal_id: tool2.schema.string().optional().describe("Goal ID. Omit to clear the first active goal.")
|
|
2406
3243
|
},
|
|
@@ -2439,16 +3276,17 @@ function ownerTools(options) {
|
|
|
2439
3276
|
})
|
|
2440
3277
|
};
|
|
2441
3278
|
}
|
|
2442
|
-
|
|
2443
3279
|
// src/server/plugin.ts
|
|
2444
3280
|
var PLUGIN_ID = "opencode-loopd.server";
|
|
2445
|
-
var server = async ({ client, directory }) => {
|
|
3281
|
+
var server = async ({ client, directory }, pluginOptions) => {
|
|
3282
|
+
const defaults = parsePluginDefaults(pluginOptions);
|
|
2446
3283
|
const host = createRealHost(client, directory);
|
|
2447
3284
|
const goalService = createGoalService(host);
|
|
2448
3285
|
const worker = createControlWorker({
|
|
2449
3286
|
directory,
|
|
2450
3287
|
goalService,
|
|
2451
|
-
pollIntervalMs: 1000
|
|
3288
|
+
pollIntervalMs: 1000,
|
|
3289
|
+
defaults
|
|
2452
3290
|
});
|
|
2453
3291
|
const engine = createLoopEngine({
|
|
2454
3292
|
directory,
|
|
@@ -2482,12 +3320,50 @@ var server = async ({ client, directory }) => {
|
|
|
2482
3320
|
if (type?.startsWith("session."))
|
|
2483
3321
|
reconcileInBackground();
|
|
2484
3322
|
},
|
|
2485
|
-
tool: { ...goalTools(directory, goalService), ...ownerTools({ directory, host, goalService }) },
|
|
3323
|
+
tool: { ...goalTools(directory, goalService, undefined, defaults), ...ownerTools({ directory, host, goalService }) },
|
|
3324
|
+
"tool.execute.before": async (input, _output) => {
|
|
3325
|
+
const activeWorkers = goalService.getActiveWorkers();
|
|
3326
|
+
let matchedGoalID;
|
|
3327
|
+
for (const [goalID, worker2] of activeWorkers) {
|
|
3328
|
+
if (worker2.workerSessionID === input.sessionID) {
|
|
3329
|
+
matchedGoalID = goalID;
|
|
3330
|
+
break;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
if (!matchedGoalID)
|
|
3334
|
+
return;
|
|
3335
|
+
try {
|
|
3336
|
+
await mutateState(directory, `tool-call.start:${matchedGoalID}:${input.callID}`, async (s) => {
|
|
3337
|
+
const runtime = s.runtimes.find((r) => r.goalID === matchedGoalID);
|
|
3338
|
+
if (runtime)
|
|
3339
|
+
Object.assign(runtime, addToolCall(runtime, input.callID));
|
|
3340
|
+
return s;
|
|
3341
|
+
});
|
|
3342
|
+
} catch {}
|
|
3343
|
+
},
|
|
2486
3344
|
"tool.execute.after": async (input, output) => {
|
|
2487
3345
|
if (input.tool === "loopd_create_goal" || input.tool === "get_goal" || input.tool === "report_goal_progress") {
|
|
2488
3346
|
ensureStarted();
|
|
2489
3347
|
reconcileInBackground();
|
|
2490
3348
|
}
|
|
3349
|
+
const activeWorkers = goalService.getActiveWorkers();
|
|
3350
|
+
let matchedGoalID;
|
|
3351
|
+
for (const [goalID, worker2] of activeWorkers) {
|
|
3352
|
+
if (worker2.workerSessionID === input.sessionID) {
|
|
3353
|
+
matchedGoalID = goalID;
|
|
3354
|
+
break;
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
if (matchedGoalID) {
|
|
3358
|
+
try {
|
|
3359
|
+
await mutateState(directory, `tool-call.end:${matchedGoalID}:${input.callID}`, async (s) => {
|
|
3360
|
+
const runtime = s.runtimes.find((r) => r.goalID === matchedGoalID);
|
|
3361
|
+
if (runtime)
|
|
3362
|
+
Object.assign(runtime, removeToolCall(runtime, input.callID));
|
|
3363
|
+
return s;
|
|
3364
|
+
});
|
|
3365
|
+
} catch {}
|
|
3366
|
+
}
|
|
2491
3367
|
if (input.tool === "complete_goal" || input.tool === "block_goal") {
|
|
2492
3368
|
try {
|
|
2493
3369
|
const raw = output?.output;
|
|
@@ -2499,9 +3375,8 @@ var server = async ({ client, directory }) => {
|
|
|
2499
3375
|
const goalID = parsed.goalID;
|
|
2500
3376
|
if (!goalID)
|
|
2501
3377
|
return;
|
|
2502
|
-
const { readState: readState2, writeState: writeState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
|
|
2503
3378
|
const { shouldNotifyParent: shouldNotifyParent2, markParentNotified: markParentNotified2 } = await Promise.resolve().then(() => exports_runtime);
|
|
2504
|
-
const state = await
|
|
3379
|
+
const state = await readState(directory);
|
|
2505
3380
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2506
3381
|
if (!goal)
|
|
2507
3382
|
return;
|
|
@@ -2510,8 +3385,12 @@ var server = async ({ client, directory }) => {
|
|
|
2510
3385
|
if (runtime && !shouldNotifyParent2(runtime, notifyType))
|
|
2511
3386
|
return;
|
|
2512
3387
|
if (runtime) {
|
|
2513
|
-
|
|
2514
|
-
|
|
3388
|
+
await mutateState(directory, `notify-parent:${goalID}`, async (s) => {
|
|
3389
|
+
const rt = s.runtimes.find((r) => r.goalID === goalID);
|
|
3390
|
+
if (rt)
|
|
3391
|
+
markParentNotified2(rt, notifyType);
|
|
3392
|
+
return s;
|
|
3393
|
+
});
|
|
2515
3394
|
}
|
|
2516
3395
|
const message = parsed.status === "complete" ? `Loop goal "${goal.name}" completed: ${parsed.summary || ""}. Evidence: ${parsed.evidence || ""}. Artifacts: ${goal.config.artifactDir || "n/a"}.` : `Loop goal "${goal.name}" blocked: ${parsed.reason || ""}. Needed: ${parsed.needed || ""}.`;
|
|
2517
3396
|
await host.notifyOwner(goal.ownerSessionID, message);
|
|
@@ -2524,6 +3403,14 @@ var server = async ({ client, directory }) => {
|
|
|
2524
3403
|
}
|
|
2525
3404
|
};
|
|
2526
3405
|
};
|
|
3406
|
+
function parsePluginDefaults(options) {
|
|
3407
|
+
const agent = typeof options?.defaultAgent === "string" ? options.defaultAgent.trim() : "";
|
|
3408
|
+
const checks = Array.isArray(options?.defaultChecks) ? options.defaultChecks.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : [];
|
|
3409
|
+
return {
|
|
3410
|
+
defaultAgent: agent || undefined,
|
|
3411
|
+
defaultChecks: checks.length > 0 ? checks : undefined
|
|
3412
|
+
};
|
|
3413
|
+
}
|
|
2527
3414
|
var plugin_default = {
|
|
2528
3415
|
id: PLUGIN_ID,
|
|
2529
3416
|
server
|