orchestrar 0.3.3 → 0.3.5
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/orchestrator.js +156 -74
- package/package.json +1 -1
package/orchestrator.js
CHANGED
|
@@ -12,10 +12,19 @@ const DEFAULT_REVIEW_TIMEOUT_MS = 60 * 60 * 1000;
|
|
|
12
12
|
const DEFAULT_SESSION_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
13
13
|
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
|
|
14
14
|
const DEFAULT_MAX_REVIEW_ITERATIONS = 20;
|
|
15
|
+
const DEFAULT_DEBUG_ENABLED = true;
|
|
16
|
+
const DEBUG_ENABLED = parseBoolean(
|
|
17
|
+
process.env.ORCHESTRATOR_DEBUG,
|
|
18
|
+
DEFAULT_DEBUG_ENABLED
|
|
19
|
+
);
|
|
15
20
|
|
|
16
21
|
async function main() {
|
|
17
22
|
const root = process.cwd();
|
|
23
|
+
logDebug(`Starting orchestrator in ${root}`);
|
|
18
24
|
const docs = await resolveDocs(root);
|
|
25
|
+
logDebug(
|
|
26
|
+
`Docs resolved: prd=${docs.prd}, spec=${docs.spec}, plan=${docs.plan}`
|
|
27
|
+
);
|
|
19
28
|
const planPath = docs.plan;
|
|
20
29
|
const { createOpencode } = await import("@opencode-ai/sdk");
|
|
21
30
|
|
|
@@ -38,13 +47,15 @@ async function runWorkInstance(createOpencode, docs, root) {
|
|
|
38
47
|
const { client, server } = await createOpencode({
|
|
39
48
|
config: buildConfig(DEFAULT_MODEL),
|
|
40
49
|
});
|
|
50
|
+
logDebug(`OpenCode server started at ${server.url}`);
|
|
41
51
|
try {
|
|
42
|
-
|
|
52
|
+
logDebug("Creating milestone session");
|
|
53
|
+
const session = await callClient(
|
|
54
|
+
"session.create",
|
|
43
55
|
client.session.create({
|
|
44
56
|
query: { directory: root },
|
|
45
57
|
body: { title: "Milestone Orchestrator" },
|
|
46
|
-
})
|
|
47
|
-
"session.create"
|
|
58
|
+
})
|
|
48
59
|
);
|
|
49
60
|
|
|
50
61
|
const sessionID = extractSessionID(session, "session.create (work instance)");
|
|
@@ -58,7 +69,7 @@ async function runWorkInstance(createOpencode, docs, root) {
|
|
|
58
69
|
);
|
|
59
70
|
await waitForMessageComplete(client, sessionID, workMessageID, root);
|
|
60
71
|
|
|
61
|
-
await runReviewLoop(
|
|
72
|
+
await runReviewLoop(client, sessionID, root);
|
|
62
73
|
|
|
63
74
|
const markTasksMessageID = await sendPrompt(
|
|
64
75
|
client,
|
|
@@ -76,13 +87,15 @@ async function runCommitInstance(createOpencode, root) {
|
|
|
76
87
|
const { client, server } = await createOpencode({
|
|
77
88
|
config: buildConfig(COMMIT_MODEL),
|
|
78
89
|
});
|
|
90
|
+
logDebug(`OpenCode server started at ${server.url}`);
|
|
79
91
|
try {
|
|
80
|
-
|
|
92
|
+
logDebug("Creating commit session");
|
|
93
|
+
const session = await callClient(
|
|
94
|
+
"session.create",
|
|
81
95
|
client.session.create({
|
|
82
96
|
query: { directory: root },
|
|
83
97
|
body: { title: "Commit & Push" },
|
|
84
|
-
})
|
|
85
|
-
"session.create"
|
|
98
|
+
})
|
|
86
99
|
);
|
|
87
100
|
|
|
88
101
|
const sessionID = extractSessionID(session, "session.create (commit instance)");
|
|
@@ -100,15 +113,20 @@ async function runCommitInstance(createOpencode, root) {
|
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
async function runReviewLoop(
|
|
116
|
+
async function runReviewLoop(client, sessionID, root) {
|
|
104
117
|
const maxIterations = parseNumber(
|
|
105
118
|
process.env.ORCHESTRATOR_MAX_REVIEW_ITERATIONS,
|
|
106
119
|
DEFAULT_MAX_REVIEW_ITERATIONS
|
|
107
120
|
);
|
|
121
|
+
logDebug(
|
|
122
|
+
`Review loop configured: maxIterations=${maxIterations}, command=${
|
|
123
|
+
process.env.ORCHESTRATOR_REVIEW_COMMAND || DEFAULT_REVIEW_COMMAND_NAME
|
|
124
|
+
}`
|
|
125
|
+
);
|
|
108
126
|
|
|
109
127
|
for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
|
|
110
128
|
logStep(`Running review (${iteration}/${maxIterations})`);
|
|
111
|
-
const reviewResult = await runReviewCommand(
|
|
129
|
+
const reviewResult = await runReviewCommand(client, root);
|
|
112
130
|
if (isFindingsEmpty(reviewResult)) {
|
|
113
131
|
logStep("Review clean; no findings.");
|
|
114
132
|
return;
|
|
@@ -132,7 +150,7 @@ async function runReviewLoop(createOpencode, client, sessionID, root) {
|
|
|
132
150
|
);
|
|
133
151
|
}
|
|
134
152
|
|
|
135
|
-
async function runReviewCommand(
|
|
153
|
+
async function runReviewCommand(client, root) {
|
|
136
154
|
const commandName =
|
|
137
155
|
process.env.ORCHESTRATOR_REVIEW_COMMAND || DEFAULT_REVIEW_COMMAND_NAME;
|
|
138
156
|
const commandArguments =
|
|
@@ -142,65 +160,61 @@ async function runReviewCommand(createOpencode, root) {
|
|
|
142
160
|
process.env.ORCHESTRATOR_REVIEW_TIMEOUT_MS,
|
|
143
161
|
DEFAULT_REVIEW_TIMEOUT_MS
|
|
144
162
|
);
|
|
163
|
+
logDebug(
|
|
164
|
+
`Starting review command: name=${commandName}, args=${commandArguments ||
|
|
165
|
+
"<none>"}, timeoutMs=${timeoutMs}`
|
|
166
|
+
);
|
|
167
|
+
const session = await callClient(
|
|
168
|
+
"session.create",
|
|
169
|
+
client.session.create({
|
|
170
|
+
query: { directory: root },
|
|
171
|
+
body: { title: "Review" },
|
|
172
|
+
})
|
|
173
|
+
);
|
|
145
174
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
175
|
+
const sessionID = extractSessionID(session, "session.create (review instance)");
|
|
176
|
+
const commandResult = await callClient(
|
|
177
|
+
"session.command",
|
|
178
|
+
client.session.command({
|
|
179
|
+
path: { id: sessionID },
|
|
180
|
+
query: { directory: root },
|
|
181
|
+
body: {
|
|
182
|
+
command: commandName,
|
|
183
|
+
arguments: commandArguments,
|
|
184
|
+
agent: DEFAULT_AGENT,
|
|
185
|
+
model: DEFAULT_MODEL,
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
);
|
|
149
189
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
190
|
+
const commandMessageID = extractMessageID(commandResult);
|
|
191
|
+
await waitForMessageComplete(
|
|
192
|
+
client,
|
|
193
|
+
sessionID,
|
|
194
|
+
commandMessageID,
|
|
195
|
+
root,
|
|
196
|
+
timeoutMs
|
|
197
|
+
);
|
|
158
198
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
199
|
+
let parts = commandResult?.parts ?? [];
|
|
200
|
+
const messageID = extractMessageID(commandResult);
|
|
201
|
+
if (messageID) {
|
|
202
|
+
const message = await callClient(
|
|
203
|
+
"session.message",
|
|
204
|
+
client.session.message({
|
|
205
|
+
path: { id: sessionID, messageID },
|
|
163
206
|
query: { directory: root },
|
|
164
|
-
|
|
165
|
-
command: commandName,
|
|
166
|
-
arguments: commandArguments,
|
|
167
|
-
agent: DEFAULT_AGENT,
|
|
168
|
-
model: DEFAULT_MODEL,
|
|
169
|
-
},
|
|
170
|
-
}),
|
|
171
|
-
"session.command"
|
|
207
|
+
})
|
|
172
208
|
);
|
|
209
|
+
parts = message?.parts ?? parts;
|
|
210
|
+
}
|
|
173
211
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
commandMessageID,
|
|
179
|
-
root,
|
|
180
|
-
timeoutMs
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
let parts = commandResult?.parts ?? [];
|
|
184
|
-
const messageID = extractMessageID(commandResult);
|
|
185
|
-
if (messageID) {
|
|
186
|
-
const message = await unwrap(
|
|
187
|
-
client.session.message({
|
|
188
|
-
path: { id: sessionID, messageID },
|
|
189
|
-
query: { directory: root },
|
|
190
|
-
}),
|
|
191
|
-
"session.message"
|
|
192
|
-
);
|
|
193
|
-
parts = message?.parts ?? parts;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const output = collectCommandOutput(parts);
|
|
197
|
-
if (!output.trim()) {
|
|
198
|
-
throw new Error("Review command produced no output.");
|
|
199
|
-
}
|
|
200
|
-
return extractReviewJson(output);
|
|
201
|
-
} finally {
|
|
202
|
-
await disposeInstance(client, server, root);
|
|
212
|
+
const output = collectCommandOutput(parts);
|
|
213
|
+
logDebug(`Review command output length: ${output.length}`);
|
|
214
|
+
if (!output.trim()) {
|
|
215
|
+
throw new Error("Review command produced no output.");
|
|
203
216
|
}
|
|
217
|
+
return extractReviewJson(output);
|
|
204
218
|
}
|
|
205
219
|
|
|
206
220
|
async function sendPrompt(
|
|
@@ -212,7 +226,11 @@ async function sendPrompt(
|
|
|
212
226
|
agentSpec = DEFAULT_AGENT
|
|
213
227
|
) {
|
|
214
228
|
const model = parseModelSpec(modelSpec);
|
|
215
|
-
|
|
229
|
+
logDebug(
|
|
230
|
+
`Sending prompt to session ${sessionID}: ${summarizeText(text, 160)}`
|
|
231
|
+
);
|
|
232
|
+
const response = await callClient(
|
|
233
|
+
"session.prompt",
|
|
216
234
|
client.session.prompt({
|
|
217
235
|
path: { id: sessionID },
|
|
218
236
|
query: { directory: root },
|
|
@@ -221,8 +239,7 @@ async function sendPrompt(
|
|
|
221
239
|
model,
|
|
222
240
|
parts: [{ type: "text", text }],
|
|
223
241
|
},
|
|
224
|
-
})
|
|
225
|
-
"session.prompt"
|
|
242
|
+
})
|
|
226
243
|
);
|
|
227
244
|
|
|
228
245
|
return extractMessageID(response);
|
|
@@ -239,18 +256,28 @@ async function waitForSessionIdle(client, sessionID, root, timeoutOverrideMs) {
|
|
|
239
256
|
process.env.ORCHESTRATOR_STATUS_POLL_INTERVAL_MS,
|
|
240
257
|
DEFAULT_STATUS_POLL_INTERVAL_MS
|
|
241
258
|
);
|
|
259
|
+
logDebug(
|
|
260
|
+
`Waiting for session ${sessionID} idle (timeoutMs=${timeoutMs}, pollMs=${pollIntervalMs})`
|
|
261
|
+
);
|
|
242
262
|
|
|
243
263
|
const start = Date.now();
|
|
244
264
|
let lastKnownSessions = [];
|
|
265
|
+
let attempt = 0;
|
|
245
266
|
while (Date.now() - start < timeoutMs) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
"session.status"
|
|
267
|
+
attempt += 1;
|
|
268
|
+
const statusMap = await callClient(
|
|
269
|
+
"session.status",
|
|
270
|
+
client.session.status({ query: { directory: root } })
|
|
249
271
|
);
|
|
250
272
|
if (statusMap && typeof statusMap === "object") {
|
|
251
273
|
lastKnownSessions = Object.keys(statusMap);
|
|
252
274
|
}
|
|
253
275
|
const status = statusMap?.[sessionID];
|
|
276
|
+
logDebug(
|
|
277
|
+
`Session status poll ${attempt}: session=${sessionID}, status=${
|
|
278
|
+
status?.type || "<missing>"
|
|
279
|
+
}, known=${lastKnownSessions.length}`
|
|
280
|
+
);
|
|
254
281
|
if (!status) {
|
|
255
282
|
await delay(pollIntervalMs);
|
|
256
283
|
continue;
|
|
@@ -291,17 +318,27 @@ async function waitForMessageComplete(
|
|
|
291
318
|
process.env.ORCHESTRATOR_STATUS_POLL_INTERVAL_MS,
|
|
292
319
|
DEFAULT_STATUS_POLL_INTERVAL_MS
|
|
293
320
|
);
|
|
321
|
+
logDebug(
|
|
322
|
+
`Waiting for message ${messageID} (timeoutMs=${timeoutMs}, pollMs=${pollIntervalMs})`
|
|
323
|
+
);
|
|
294
324
|
|
|
295
325
|
const start = Date.now();
|
|
326
|
+
let attempt = 0;
|
|
296
327
|
while (Date.now() - start < timeoutMs) {
|
|
297
|
-
|
|
328
|
+
attempt += 1;
|
|
329
|
+
const message = await callClient(
|
|
330
|
+
"session.message",
|
|
298
331
|
client.session.message({
|
|
299
332
|
path: { id: sessionID, messageID },
|
|
300
333
|
query: { directory: root },
|
|
301
|
-
})
|
|
302
|
-
"session.message"
|
|
334
|
+
})
|
|
303
335
|
);
|
|
304
336
|
const info = message?.info ?? message;
|
|
337
|
+
logDebug(
|
|
338
|
+
`Message poll ${attempt}: message=${messageID}, completed=${
|
|
339
|
+
info?.time?.completed ? "yes" : "no"
|
|
340
|
+
}, finish=${info?.finish || "<none>"}`
|
|
341
|
+
);
|
|
305
342
|
if (info?.error) {
|
|
306
343
|
throw new Error(
|
|
307
344
|
`Session message ${messageID} failed: ${formatError(info.error)}`
|
|
@@ -463,6 +500,20 @@ function parseNumber(value, fallback) {
|
|
|
463
500
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
464
501
|
}
|
|
465
502
|
|
|
503
|
+
function parseBoolean(value, fallback) {
|
|
504
|
+
if (value === undefined || value === null || value === "") {
|
|
505
|
+
return fallback;
|
|
506
|
+
}
|
|
507
|
+
const normalized = String(value).trim().toLowerCase();
|
|
508
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
return fallback;
|
|
515
|
+
}
|
|
516
|
+
|
|
466
517
|
function buildConfig(model) {
|
|
467
518
|
return {
|
|
468
519
|
model,
|
|
@@ -489,9 +540,9 @@ function parseModelSpec(spec) {
|
|
|
489
540
|
|
|
490
541
|
async function disposeInstance(client, server, root) {
|
|
491
542
|
try {
|
|
492
|
-
await
|
|
493
|
-
|
|
494
|
-
|
|
543
|
+
await callClient(
|
|
544
|
+
"instance.dispose",
|
|
545
|
+
client.instance.dispose({ query: { directory: root } })
|
|
495
546
|
);
|
|
496
547
|
} catch (error) {
|
|
497
548
|
logStep(`Instance dispose failed: ${formatError(error)}`);
|
|
@@ -536,6 +587,19 @@ async function unwrap(result, label) {
|
|
|
536
587
|
return result;
|
|
537
588
|
}
|
|
538
589
|
|
|
590
|
+
async function callClient(label, promise) {
|
|
591
|
+
const start = Date.now();
|
|
592
|
+
logDebug(`${label} -> start`);
|
|
593
|
+
try {
|
|
594
|
+
const data = await unwrap(promise, label);
|
|
595
|
+
logDebug(`${label} -> ok (${Date.now() - start}ms)`);
|
|
596
|
+
return data;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
logDebug(`${label} -> error (${Date.now() - start}ms): ${formatError(error)}`);
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
539
603
|
function extractSessionID(session, context) {
|
|
540
604
|
if (!session || typeof session !== "object") {
|
|
541
605
|
throw new Error(`${context} did not return a session object.`);
|
|
@@ -629,6 +693,24 @@ function logStep(message) {
|
|
|
629
693
|
console.log(`[orchestrator] ${message}`);
|
|
630
694
|
}
|
|
631
695
|
|
|
696
|
+
function logDebug(message) {
|
|
697
|
+
if (!DEBUG_ENABLED) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
console.log(`[orchestrator][debug] ${message}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function summarizeText(text, maxLength) {
|
|
704
|
+
if (typeof text !== "string") {
|
|
705
|
+
return "<non-text>";
|
|
706
|
+
}
|
|
707
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
708
|
+
if (trimmed.length <= maxLength) {
|
|
709
|
+
return trimmed;
|
|
710
|
+
}
|
|
711
|
+
return `${trimmed.slice(0, maxLength)}...`;
|
|
712
|
+
}
|
|
713
|
+
|
|
632
714
|
main().catch((error) => {
|
|
633
715
|
console.error(`[orchestrator] Failed: ${formatError(error)}`);
|
|
634
716
|
process.exitCode = 1;
|