orchestrar 0.3.4 → 0.3.6
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 +144 -33
- package/package.json +1 -1
package/orchestrator.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("node:fs/promises");
|
|
4
4
|
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
5
6
|
const DEFAULT_MODEL = "github-copilot/gpt-5.2-codex";
|
|
6
7
|
const COMMIT_MODEL = "github-copilot/gpt-5-mini";
|
|
7
8
|
const DEFAULT_AGENT = "build-gpt-5.2-codex";
|
|
@@ -12,10 +13,19 @@ const DEFAULT_REVIEW_TIMEOUT_MS = 60 * 60 * 1000;
|
|
|
12
13
|
const DEFAULT_SESSION_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
13
14
|
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
|
|
14
15
|
const DEFAULT_MAX_REVIEW_ITERATIONS = 20;
|
|
16
|
+
const DEFAULT_DEBUG_ENABLED = true;
|
|
17
|
+
const DEBUG_ENABLED = parseBoolean(
|
|
18
|
+
process.env.ORCHESTRATOR_DEBUG,
|
|
19
|
+
DEFAULT_DEBUG_ENABLED
|
|
20
|
+
);
|
|
15
21
|
|
|
16
22
|
async function main() {
|
|
17
23
|
const root = process.cwd();
|
|
24
|
+
logDebug(`Starting orchestrator in ${root}`);
|
|
18
25
|
const docs = await resolveDocs(root);
|
|
26
|
+
logDebug(
|
|
27
|
+
`Docs resolved: prd=${docs.prd}, spec=${docs.spec}, plan=${docs.plan}`
|
|
28
|
+
);
|
|
19
29
|
const planPath = docs.plan;
|
|
20
30
|
const { createOpencode } = await import("@opencode-ai/sdk");
|
|
21
31
|
|
|
@@ -38,13 +48,15 @@ async function runWorkInstance(createOpencode, docs, root) {
|
|
|
38
48
|
const { client, server } = await createOpencode({
|
|
39
49
|
config: buildConfig(DEFAULT_MODEL),
|
|
40
50
|
});
|
|
51
|
+
logDebug(`OpenCode server started at ${server.url}`);
|
|
41
52
|
try {
|
|
42
|
-
|
|
53
|
+
logDebug("Creating milestone session");
|
|
54
|
+
const session = await callClient(
|
|
55
|
+
"session.create",
|
|
43
56
|
client.session.create({
|
|
44
57
|
query: { directory: root },
|
|
45
58
|
body: { title: "Milestone Orchestrator" },
|
|
46
|
-
})
|
|
47
|
-
"session.create"
|
|
59
|
+
})
|
|
48
60
|
);
|
|
49
61
|
|
|
50
62
|
const sessionID = extractSessionID(session, "session.create (work instance)");
|
|
@@ -76,13 +88,15 @@ async function runCommitInstance(createOpencode, root) {
|
|
|
76
88
|
const { client, server } = await createOpencode({
|
|
77
89
|
config: buildConfig(COMMIT_MODEL),
|
|
78
90
|
});
|
|
91
|
+
logDebug(`OpenCode server started at ${server.url}`);
|
|
79
92
|
try {
|
|
80
|
-
|
|
93
|
+
logDebug("Creating commit session");
|
|
94
|
+
const session = await callClient(
|
|
95
|
+
"session.create",
|
|
81
96
|
client.session.create({
|
|
82
97
|
query: { directory: root },
|
|
83
98
|
body: { title: "Commit & Push" },
|
|
84
|
-
})
|
|
85
|
-
"session.create"
|
|
99
|
+
})
|
|
86
100
|
);
|
|
87
101
|
|
|
88
102
|
const sessionID = extractSessionID(session, "session.create (commit instance)");
|
|
@@ -105,6 +119,11 @@ async function runReviewLoop(client, sessionID, root) {
|
|
|
105
119
|
process.env.ORCHESTRATOR_MAX_REVIEW_ITERATIONS,
|
|
106
120
|
DEFAULT_MAX_REVIEW_ITERATIONS
|
|
107
121
|
);
|
|
122
|
+
logDebug(
|
|
123
|
+
`Review loop configured: maxIterations=${maxIterations}, command=${
|
|
124
|
+
process.env.ORCHESTRATOR_REVIEW_COMMAND || DEFAULT_REVIEW_COMMAND_NAME
|
|
125
|
+
}`
|
|
126
|
+
);
|
|
108
127
|
|
|
109
128
|
for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
|
|
110
129
|
logStep(`Running review (${iteration}/${maxIterations})`);
|
|
@@ -142,16 +161,21 @@ async function runReviewCommand(client, root) {
|
|
|
142
161
|
process.env.ORCHESTRATOR_REVIEW_TIMEOUT_MS,
|
|
143
162
|
DEFAULT_REVIEW_TIMEOUT_MS
|
|
144
163
|
);
|
|
145
|
-
|
|
164
|
+
logDebug(
|
|
165
|
+
`Starting review command: name=${commandName}, args=${commandArguments ||
|
|
166
|
+
"<none>"}, timeoutMs=${timeoutMs}`
|
|
167
|
+
);
|
|
168
|
+
const session = await callClient(
|
|
169
|
+
"session.create",
|
|
146
170
|
client.session.create({
|
|
147
171
|
query: { directory: root },
|
|
148
172
|
body: { title: "Review" },
|
|
149
|
-
})
|
|
150
|
-
"session.create"
|
|
173
|
+
})
|
|
151
174
|
);
|
|
152
175
|
|
|
153
176
|
const sessionID = extractSessionID(session, "session.create (review instance)");
|
|
154
|
-
const commandResult = await
|
|
177
|
+
const commandResult = await callClient(
|
|
178
|
+
"session.command",
|
|
155
179
|
client.session.command({
|
|
156
180
|
path: { id: sessionID },
|
|
157
181
|
query: { directory: root },
|
|
@@ -161,8 +185,7 @@ async function runReviewCommand(client, root) {
|
|
|
161
185
|
agent: DEFAULT_AGENT,
|
|
162
186
|
model: DEFAULT_MODEL,
|
|
163
187
|
},
|
|
164
|
-
})
|
|
165
|
-
"session.command"
|
|
188
|
+
})
|
|
166
189
|
);
|
|
167
190
|
|
|
168
191
|
const commandMessageID = extractMessageID(commandResult);
|
|
@@ -177,17 +200,18 @@ async function runReviewCommand(client, root) {
|
|
|
177
200
|
let parts = commandResult?.parts ?? [];
|
|
178
201
|
const messageID = extractMessageID(commandResult);
|
|
179
202
|
if (messageID) {
|
|
180
|
-
const message = await
|
|
203
|
+
const message = await callClient(
|
|
204
|
+
"session.message",
|
|
181
205
|
client.session.message({
|
|
182
206
|
path: { id: sessionID, messageID },
|
|
183
207
|
query: { directory: root },
|
|
184
|
-
})
|
|
185
|
-
"session.message"
|
|
208
|
+
})
|
|
186
209
|
);
|
|
187
210
|
parts = message?.parts ?? parts;
|
|
188
211
|
}
|
|
189
212
|
|
|
190
213
|
const output = collectCommandOutput(parts);
|
|
214
|
+
logDebug(`Review command output length: ${output.length}`);
|
|
191
215
|
if (!output.trim()) {
|
|
192
216
|
throw new Error("Review command produced no output.");
|
|
193
217
|
}
|
|
@@ -203,20 +227,25 @@ async function sendPrompt(
|
|
|
203
227
|
agentSpec = DEFAULT_AGENT
|
|
204
228
|
) {
|
|
205
229
|
const model = parseModelSpec(modelSpec);
|
|
206
|
-
|
|
207
|
-
|
|
230
|
+
logDebug(
|
|
231
|
+
`Sending prompt to session ${sessionID}: ${summarizeText(text, 160)}`
|
|
232
|
+
);
|
|
233
|
+
const messageID = generateMessageID();
|
|
234
|
+
await callClient(
|
|
235
|
+
"session.promptAsync",
|
|
236
|
+
client.session.promptAsync({
|
|
208
237
|
path: { id: sessionID },
|
|
209
238
|
query: { directory: root },
|
|
210
239
|
body: {
|
|
240
|
+
messageID,
|
|
211
241
|
agent: agentSpec,
|
|
212
242
|
model,
|
|
213
243
|
parts: [{ type: "text", text }],
|
|
214
244
|
},
|
|
215
|
-
})
|
|
216
|
-
"session.prompt"
|
|
245
|
+
})
|
|
217
246
|
);
|
|
218
247
|
|
|
219
|
-
return
|
|
248
|
+
return messageID;
|
|
220
249
|
}
|
|
221
250
|
|
|
222
251
|
async function waitForSessionIdle(client, sessionID, root, timeoutOverrideMs) {
|
|
@@ -230,18 +259,28 @@ async function waitForSessionIdle(client, sessionID, root, timeoutOverrideMs) {
|
|
|
230
259
|
process.env.ORCHESTRATOR_STATUS_POLL_INTERVAL_MS,
|
|
231
260
|
DEFAULT_STATUS_POLL_INTERVAL_MS
|
|
232
261
|
);
|
|
262
|
+
logDebug(
|
|
263
|
+
`Waiting for session ${sessionID} idle (timeoutMs=${timeoutMs}, pollMs=${pollIntervalMs})`
|
|
264
|
+
);
|
|
233
265
|
|
|
234
266
|
const start = Date.now();
|
|
235
267
|
let lastKnownSessions = [];
|
|
268
|
+
let attempt = 0;
|
|
236
269
|
while (Date.now() - start < timeoutMs) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
"session.status"
|
|
270
|
+
attempt += 1;
|
|
271
|
+
const statusMap = await callClient(
|
|
272
|
+
"session.status",
|
|
273
|
+
client.session.status({ query: { directory: root } })
|
|
240
274
|
);
|
|
241
275
|
if (statusMap && typeof statusMap === "object") {
|
|
242
276
|
lastKnownSessions = Object.keys(statusMap);
|
|
243
277
|
}
|
|
244
278
|
const status = statusMap?.[sessionID];
|
|
279
|
+
logDebug(
|
|
280
|
+
`Session status poll ${attempt}: session=${sessionID}, status=${
|
|
281
|
+
status?.type || "<missing>"
|
|
282
|
+
}, known=${lastKnownSessions.length}`
|
|
283
|
+
);
|
|
245
284
|
if (!status) {
|
|
246
285
|
await delay(pollIntervalMs);
|
|
247
286
|
continue;
|
|
@@ -282,17 +321,40 @@ async function waitForMessageComplete(
|
|
|
282
321
|
process.env.ORCHESTRATOR_STATUS_POLL_INTERVAL_MS,
|
|
283
322
|
DEFAULT_STATUS_POLL_INTERVAL_MS
|
|
284
323
|
);
|
|
324
|
+
logDebug(
|
|
325
|
+
`Waiting for message ${messageID} (timeoutMs=${timeoutMs}, pollMs=${pollIntervalMs})`
|
|
326
|
+
);
|
|
285
327
|
|
|
286
328
|
const start = Date.now();
|
|
329
|
+
let attempt = 0;
|
|
287
330
|
while (Date.now() - start < timeoutMs) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
331
|
+
attempt += 1;
|
|
332
|
+
let message;
|
|
333
|
+
try {
|
|
334
|
+
message = await callClient(
|
|
335
|
+
"session.message",
|
|
336
|
+
client.session.message({
|
|
337
|
+
path: { id: sessionID, messageID },
|
|
338
|
+
query: { directory: root },
|
|
339
|
+
})
|
|
340
|
+
);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
const formatted = formatError(error).toLowerCase();
|
|
343
|
+
if (formatted.includes("not found") || formatted.includes("404")) {
|
|
344
|
+
logDebug(
|
|
345
|
+
`Message poll ${attempt}: message=${messageID}, not found yet`
|
|
346
|
+
);
|
|
347
|
+
await delay(pollIntervalMs);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
295
352
|
const info = message?.info ?? message;
|
|
353
|
+
logDebug(
|
|
354
|
+
`Message poll ${attempt}: message=${messageID}, completed=${
|
|
355
|
+
info?.time?.completed ? "yes" : "no"
|
|
356
|
+
}, finish=${info?.finish || "<none>"}`
|
|
357
|
+
);
|
|
296
358
|
if (info?.error) {
|
|
297
359
|
throw new Error(
|
|
298
360
|
`Session message ${messageID} failed: ${formatError(info.error)}`
|
|
@@ -454,6 +516,20 @@ function parseNumber(value, fallback) {
|
|
|
454
516
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
455
517
|
}
|
|
456
518
|
|
|
519
|
+
function parseBoolean(value, fallback) {
|
|
520
|
+
if (value === undefined || value === null || value === "") {
|
|
521
|
+
return fallback;
|
|
522
|
+
}
|
|
523
|
+
const normalized = String(value).trim().toLowerCase();
|
|
524
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
return fallback;
|
|
531
|
+
}
|
|
532
|
+
|
|
457
533
|
function buildConfig(model) {
|
|
458
534
|
return {
|
|
459
535
|
model,
|
|
@@ -480,9 +556,9 @@ function parseModelSpec(spec) {
|
|
|
480
556
|
|
|
481
557
|
async function disposeInstance(client, server, root) {
|
|
482
558
|
try {
|
|
483
|
-
await
|
|
484
|
-
|
|
485
|
-
|
|
559
|
+
await callClient(
|
|
560
|
+
"instance.dispose",
|
|
561
|
+
client.instance.dispose({ query: { directory: root } })
|
|
486
562
|
);
|
|
487
563
|
} catch (error) {
|
|
488
564
|
logStep(`Instance dispose failed: ${formatError(error)}`);
|
|
@@ -527,6 +603,19 @@ async function unwrap(result, label) {
|
|
|
527
603
|
return result;
|
|
528
604
|
}
|
|
529
605
|
|
|
606
|
+
async function callClient(label, promise) {
|
|
607
|
+
const start = Date.now();
|
|
608
|
+
logDebug(`${label} -> start`);
|
|
609
|
+
try {
|
|
610
|
+
const data = await unwrap(promise, label);
|
|
611
|
+
logDebug(`${label} -> ok (${Date.now() - start}ms)`);
|
|
612
|
+
return data;
|
|
613
|
+
} catch (error) {
|
|
614
|
+
logDebug(`${label} -> error (${Date.now() - start}ms): ${formatError(error)}`);
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
530
619
|
function extractSessionID(session, context) {
|
|
531
620
|
if (!session || typeof session !== "object") {
|
|
532
621
|
throw new Error(`${context} did not return a session object.`);
|
|
@@ -620,6 +709,28 @@ function logStep(message) {
|
|
|
620
709
|
console.log(`[orchestrator] ${message}`);
|
|
621
710
|
}
|
|
622
711
|
|
|
712
|
+
function logDebug(message) {
|
|
713
|
+
if (!DEBUG_ENABLED) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
console.log(`[orchestrator][debug] ${message}`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function summarizeText(text, maxLength) {
|
|
720
|
+
if (typeof text !== "string") {
|
|
721
|
+
return "<non-text>";
|
|
722
|
+
}
|
|
723
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
724
|
+
if (trimmed.length <= maxLength) {
|
|
725
|
+
return trimmed;
|
|
726
|
+
}
|
|
727
|
+
return `${trimmed.slice(0, maxLength)}...`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function generateMessageID() {
|
|
731
|
+
return `msg_${crypto.randomBytes(12).toString("hex")}`;
|
|
732
|
+
}
|
|
733
|
+
|
|
623
734
|
main().catch((error) => {
|
|
624
735
|
console.error(`[orchestrator] Failed: ${formatError(error)}`);
|
|
625
736
|
process.exitCode = 1;
|