@harness-engineering/orchestrator 0.2.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/LICENSE +21 -0
- package/dist/index.d.mts +443 -0
- package/dist/index.d.ts +443 -0
- package/dist/index.js +1380 -0
- package/dist/index.mjs +1330 -0
- package/package.json +67 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1330 @@
|
|
|
1
|
+
// src/core/retry.ts
|
|
2
|
+
var CONTINUATION_DELAY_MS = 1e3;
|
|
3
|
+
var BASE_FAILURE_DELAY_MS = 1e4;
|
|
4
|
+
var DEFAULT_MAX_RETRY_BACKOFF_MS = 3e5;
|
|
5
|
+
function calculateRetryDelay(attempt, type, maxRetryBackoffMs = DEFAULT_MAX_RETRY_BACKOFF_MS) {
|
|
6
|
+
if (type === "continuation") {
|
|
7
|
+
return CONTINUATION_DELAY_MS;
|
|
8
|
+
}
|
|
9
|
+
const delay = BASE_FAILURE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
10
|
+
return Math.min(delay, maxRetryBackoffMs);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/core/candidate-selection.ts
|
|
14
|
+
function sortCandidates(issues) {
|
|
15
|
+
return [...issues].sort((a, b) => {
|
|
16
|
+
const pa = a.priority ?? Number.MAX_SAFE_INTEGER;
|
|
17
|
+
const pb = b.priority ?? Number.MAX_SAFE_INTEGER;
|
|
18
|
+
if (pa !== pb) return pa - pb;
|
|
19
|
+
const ca = a.createdAt ?? "\uFFFF";
|
|
20
|
+
const cb = b.createdAt ?? "\uFFFF";
|
|
21
|
+
if (ca !== cb) return ca < cb ? -1 : 1;
|
|
22
|
+
return a.identifier.localeCompare(b.identifier);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function isEligible(issue, state, activeStates, terminalStates) {
|
|
26
|
+
if (!issue.id || !issue.identifier || !issue.title || !issue.state) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const normalizedState = issue.state.toLowerCase();
|
|
30
|
+
const normalizedActive = activeStates.map((s) => s.toLowerCase());
|
|
31
|
+
const normalizedTerminal = terminalStates.map((s) => s.toLowerCase());
|
|
32
|
+
if (!normalizedActive.includes(normalizedState)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (normalizedTerminal.includes(normalizedState)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (state.claimed.has(issue.id)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (state.running.has(issue.id)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (normalizedState === "todo" && issue.blockedBy.length > 0) {
|
|
45
|
+
const hasNonTerminalBlocker = issue.blockedBy.some((blocker) => {
|
|
46
|
+
if (blocker.state === null) return true;
|
|
47
|
+
return !normalizedTerminal.includes(blocker.state.toLowerCase());
|
|
48
|
+
});
|
|
49
|
+
if (hasNonTerminalBlocker) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
function selectCandidates(issues, state, activeStates, terminalStates) {
|
|
56
|
+
const sorted = sortCandidates(issues);
|
|
57
|
+
return sorted.filter((issue) => isEligible(issue, state, activeStates, terminalStates));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/core/concurrency.ts
|
|
61
|
+
function getAvailableSlots(state) {
|
|
62
|
+
return Math.max(state.maxConcurrentAgents - state.running.size, 0);
|
|
63
|
+
}
|
|
64
|
+
function getPerStateCount(running) {
|
|
65
|
+
const counts = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const entry of running.values()) {
|
|
67
|
+
const key = entry.issue.state.toLowerCase();
|
|
68
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
69
|
+
}
|
|
70
|
+
return counts;
|
|
71
|
+
}
|
|
72
|
+
function canDispatch(state, issueState, maxConcurrentAgentsByState) {
|
|
73
|
+
if (getAvailableSlots(state) <= 0) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const normalizedState = issueState.toLowerCase();
|
|
77
|
+
const perStateCap = maxConcurrentAgentsByState[normalizedState];
|
|
78
|
+
if (perStateCap !== void 0) {
|
|
79
|
+
const perStateCounts = getPerStateCount(state.running);
|
|
80
|
+
const currentCount = perStateCounts.get(normalizedState) ?? 0;
|
|
81
|
+
if (currentCount >= perStateCap) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/core/reconciliation.ts
|
|
89
|
+
function reconcile(state, runningStates, activeStates, terminalStates) {
|
|
90
|
+
const effects = [];
|
|
91
|
+
const normalizedActive = activeStates.map((s) => s.toLowerCase());
|
|
92
|
+
const normalizedTerminal = terminalStates.map((s) => s.toLowerCase());
|
|
93
|
+
for (const [issueId, entry] of state.running) {
|
|
94
|
+
const currentIssue = runningStates.get(issueId);
|
|
95
|
+
if (!currentIssue) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const normalizedState = currentIssue.state.toLowerCase();
|
|
99
|
+
if (normalizedTerminal.includes(normalizedState)) {
|
|
100
|
+
effects.push({ type: "stop", issueId, reason: `terminal_state: ${normalizedState}` });
|
|
101
|
+
effects.push({ type: "cleanWorkspace", issueId, identifier: entry.identifier });
|
|
102
|
+
effects.push({ type: "releaseClaim", issueId });
|
|
103
|
+
} else if (!normalizedActive.includes(normalizedState)) {
|
|
104
|
+
effects.push({ type: "stop", issueId, reason: `non_active_state: ${normalizedState}` });
|
|
105
|
+
effects.push({ type: "releaseClaim", issueId });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return effects;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/core/state-machine.ts
|
|
112
|
+
function cloneState(state) {
|
|
113
|
+
return {
|
|
114
|
+
...state,
|
|
115
|
+
running: new Map(state.running),
|
|
116
|
+
claimed: new Set(state.claimed),
|
|
117
|
+
retryAttempts: new Map(state.retryAttempts),
|
|
118
|
+
completed: new Set(state.completed),
|
|
119
|
+
tokenTotals: { ...state.tokenTotals },
|
|
120
|
+
rateLimits: { ...state.rateLimits }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function handleTick(state, candidates, runningStates, config, nowMs) {
|
|
124
|
+
const next = cloneState(state);
|
|
125
|
+
const effects = [];
|
|
126
|
+
const reconcileEffects = reconcile(
|
|
127
|
+
next,
|
|
128
|
+
runningStates,
|
|
129
|
+
config.tracker.activeStates,
|
|
130
|
+
config.tracker.terminalStates
|
|
131
|
+
);
|
|
132
|
+
effects.push(...reconcileEffects);
|
|
133
|
+
for (const effect of reconcileEffects) {
|
|
134
|
+
if (effect.type === "stop") {
|
|
135
|
+
next.running.delete(effect.issueId);
|
|
136
|
+
}
|
|
137
|
+
if (effect.type === "releaseClaim") {
|
|
138
|
+
next.claimed.delete(effect.issueId);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const eligible = selectCandidates(
|
|
142
|
+
candidates,
|
|
143
|
+
next,
|
|
144
|
+
config.tracker.activeStates,
|
|
145
|
+
config.tracker.terminalStates
|
|
146
|
+
);
|
|
147
|
+
for (const issue of eligible) {
|
|
148
|
+
if (!canDispatch(next, issue.state, config.agent.maxConcurrentAgentsByState)) {
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
next.claimed.add(issue.id);
|
|
152
|
+
next.running.set(issue.id, {
|
|
153
|
+
issueId: issue.id,
|
|
154
|
+
identifier: issue.identifier,
|
|
155
|
+
issue,
|
|
156
|
+
attempt: null,
|
|
157
|
+
workspacePath: "",
|
|
158
|
+
startedAt: new Date(nowMs).toISOString(),
|
|
159
|
+
phase: "PreparingWorkspace",
|
|
160
|
+
session: null
|
|
161
|
+
});
|
|
162
|
+
effects.push({
|
|
163
|
+
type: "dispatch",
|
|
164
|
+
issue,
|
|
165
|
+
attempt: null
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return { nextState: next, effects };
|
|
169
|
+
}
|
|
170
|
+
function handleWorkerExit(state, issueId, reason, error, attempt, config) {
|
|
171
|
+
const next = cloneState(state);
|
|
172
|
+
const effects = [];
|
|
173
|
+
const entry = next.running.get(issueId);
|
|
174
|
+
next.running.delete(issueId);
|
|
175
|
+
if (reason === "normal") {
|
|
176
|
+
next.completed.add(issueId);
|
|
177
|
+
const delayMs = calculateRetryDelay(1, "continuation");
|
|
178
|
+
effects.push({
|
|
179
|
+
type: "scheduleRetry",
|
|
180
|
+
issueId,
|
|
181
|
+
identifier: entry?.identifier ?? issueId,
|
|
182
|
+
attempt: 1,
|
|
183
|
+
delayMs,
|
|
184
|
+
error: null
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
const nextAttempt = (attempt ?? 0) + 1;
|
|
188
|
+
const delayMs = calculateRetryDelay(nextAttempt, "failure", config.agent.maxRetryBackoffMs);
|
|
189
|
+
effects.push({
|
|
190
|
+
type: "scheduleRetry",
|
|
191
|
+
issueId,
|
|
192
|
+
identifier: entry?.identifier ?? issueId,
|
|
193
|
+
attempt: nextAttempt,
|
|
194
|
+
delayMs,
|
|
195
|
+
error: error ?? "unknown error"
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return { nextState: next, effects };
|
|
199
|
+
}
|
|
200
|
+
function handleAgentUpdate(state, issueId, event) {
|
|
201
|
+
const next = cloneState(state);
|
|
202
|
+
const effects = [];
|
|
203
|
+
const entry = next.running.get(issueId);
|
|
204
|
+
if (entry && entry.session) {
|
|
205
|
+
const updatedSession = { ...entry.session };
|
|
206
|
+
updatedSession.lastEvent = event.type;
|
|
207
|
+
updatedSession.lastTimestamp = event.timestamp;
|
|
208
|
+
if (event.usage) {
|
|
209
|
+
updatedSession.inputTokens += event.usage.inputTokens;
|
|
210
|
+
updatedSession.outputTokens += event.usage.outputTokens;
|
|
211
|
+
updatedSession.totalTokens += event.usage.totalTokens;
|
|
212
|
+
effects.push({
|
|
213
|
+
type: "updateTokens",
|
|
214
|
+
issueId,
|
|
215
|
+
usage: event.usage
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (event.sessionId) {
|
|
219
|
+
updatedSession.sessionId = event.sessionId;
|
|
220
|
+
}
|
|
221
|
+
next.running.set(issueId, { ...entry, session: updatedSession });
|
|
222
|
+
}
|
|
223
|
+
return { nextState: next, effects };
|
|
224
|
+
}
|
|
225
|
+
function handleRetryFired(state, issueId, candidates, config, nowMs) {
|
|
226
|
+
const next = cloneState(state);
|
|
227
|
+
const effects = [];
|
|
228
|
+
const retryEntry = next.retryAttempts.get(issueId);
|
|
229
|
+
next.retryAttempts.delete(issueId);
|
|
230
|
+
if (!retryEntry) {
|
|
231
|
+
return { nextState: next, effects };
|
|
232
|
+
}
|
|
233
|
+
const issue = candidates.find((c) => c.id === issueId);
|
|
234
|
+
if (!issue) {
|
|
235
|
+
next.claimed.delete(issueId);
|
|
236
|
+
effects.push({ type: "releaseClaim", issueId });
|
|
237
|
+
return { nextState: next, effects };
|
|
238
|
+
}
|
|
239
|
+
const normalizedState = issue.state.toLowerCase();
|
|
240
|
+
const normalizedActive = config.tracker.activeStates.map((s) => s.toLowerCase());
|
|
241
|
+
if (!normalizedActive.includes(normalizedState)) {
|
|
242
|
+
next.claimed.delete(issueId);
|
|
243
|
+
effects.push({ type: "releaseClaim", issueId });
|
|
244
|
+
return { nextState: next, effects };
|
|
245
|
+
}
|
|
246
|
+
if (!canDispatch(next, issue.state, config.agent.maxConcurrentAgentsByState)) {
|
|
247
|
+
const nextAttempt = retryEntry.attempt + 1;
|
|
248
|
+
const delayMs = calculateRetryDelay(nextAttempt, "failure", config.agent.maxRetryBackoffMs);
|
|
249
|
+
next.retryAttempts.set(issueId, {
|
|
250
|
+
issueId,
|
|
251
|
+
identifier: retryEntry.identifier,
|
|
252
|
+
attempt: nextAttempt,
|
|
253
|
+
dueAtMs: nowMs + delayMs,
|
|
254
|
+
error: "no available orchestrator slots"
|
|
255
|
+
});
|
|
256
|
+
effects.push({
|
|
257
|
+
type: "scheduleRetry",
|
|
258
|
+
issueId,
|
|
259
|
+
identifier: retryEntry.identifier,
|
|
260
|
+
attempt: nextAttempt,
|
|
261
|
+
delayMs,
|
|
262
|
+
error: "no available orchestrator slots"
|
|
263
|
+
});
|
|
264
|
+
return { nextState: next, effects };
|
|
265
|
+
}
|
|
266
|
+
effects.push({
|
|
267
|
+
type: "dispatch",
|
|
268
|
+
issue,
|
|
269
|
+
attempt: retryEntry.attempt
|
|
270
|
+
});
|
|
271
|
+
return { nextState: next, effects };
|
|
272
|
+
}
|
|
273
|
+
function handleStallDetected(state, issueId, config) {
|
|
274
|
+
const next = cloneState(state);
|
|
275
|
+
const effects = [];
|
|
276
|
+
const entry = next.running.get(issueId);
|
|
277
|
+
next.running.delete(issueId);
|
|
278
|
+
const attempt = (entry?.attempt ?? 0) + 1;
|
|
279
|
+
const delayMs = calculateRetryDelay(attempt, "failure", config.agent.maxRetryBackoffMs);
|
|
280
|
+
effects.push({
|
|
281
|
+
type: "stop",
|
|
282
|
+
issueId,
|
|
283
|
+
reason: "stall_detected"
|
|
284
|
+
});
|
|
285
|
+
effects.push({
|
|
286
|
+
type: "scheduleRetry",
|
|
287
|
+
issueId,
|
|
288
|
+
identifier: entry?.identifier ?? issueId,
|
|
289
|
+
attempt,
|
|
290
|
+
delayMs,
|
|
291
|
+
error: "stall detected"
|
|
292
|
+
});
|
|
293
|
+
return { nextState: next, effects };
|
|
294
|
+
}
|
|
295
|
+
function applyEvent(state, event, config) {
|
|
296
|
+
switch (event.type) {
|
|
297
|
+
case "tick":
|
|
298
|
+
return handleTick(state, event.candidates, event.runningStates, config, event.nowMs);
|
|
299
|
+
case "worker_exit":
|
|
300
|
+
return handleWorkerExit(
|
|
301
|
+
state,
|
|
302
|
+
event.issueId,
|
|
303
|
+
event.reason,
|
|
304
|
+
event.error,
|
|
305
|
+
event.attempt,
|
|
306
|
+
config
|
|
307
|
+
);
|
|
308
|
+
case "agent_update":
|
|
309
|
+
return handleAgentUpdate(state, event.issueId, event.event);
|
|
310
|
+
case "retry_fired":
|
|
311
|
+
return handleRetryFired(state, event.issueId, event.candidates, config, event.nowMs);
|
|
312
|
+
case "stall_detected":
|
|
313
|
+
return handleStallDetected(state, event.issueId, config);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/core/state-helpers.ts
|
|
318
|
+
function createEmptyState(config) {
|
|
319
|
+
return {
|
|
320
|
+
pollIntervalMs: config.polling.intervalMs,
|
|
321
|
+
maxConcurrentAgents: config.agent.maxConcurrentAgents,
|
|
322
|
+
running: /* @__PURE__ */ new Map(),
|
|
323
|
+
claimed: /* @__PURE__ */ new Set(),
|
|
324
|
+
retryAttempts: /* @__PURE__ */ new Map(),
|
|
325
|
+
completed: /* @__PURE__ */ new Set(),
|
|
326
|
+
tokenTotals: {
|
|
327
|
+
inputTokens: 0,
|
|
328
|
+
outputTokens: 0,
|
|
329
|
+
totalTokens: 0,
|
|
330
|
+
secondsRunning: 0
|
|
331
|
+
},
|
|
332
|
+
rateLimits: {
|
|
333
|
+
requestsRemaining: null,
|
|
334
|
+
requestsLimit: null,
|
|
335
|
+
tokensRemaining: null,
|
|
336
|
+
tokensLimit: null
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/workflow/loader.ts
|
|
342
|
+
import * as fs from "fs/promises";
|
|
343
|
+
import { parse } from "yaml";
|
|
344
|
+
import { Ok as Ok2, Err as Err2 } from "@harness-engineering/types";
|
|
345
|
+
|
|
346
|
+
// src/workflow/config.ts
|
|
347
|
+
import { Ok, Err } from "@harness-engineering/types";
|
|
348
|
+
function validateWorkflowConfig(config) {
|
|
349
|
+
if (!config || typeof config !== "object")
|
|
350
|
+
return Err(new Error("Config is missing or not an object"));
|
|
351
|
+
const c = config;
|
|
352
|
+
if (!c.tracker) return Err(new Error("Config is missing tracker section"));
|
|
353
|
+
if (!c.polling) return Err(new Error("Config is missing polling section"));
|
|
354
|
+
if (!c.workspace) return Err(new Error("Config is missing workspace section"));
|
|
355
|
+
if (!c.hooks) return Err(new Error("Config is missing hooks section"));
|
|
356
|
+
if (!c.agent) return Err(new Error("Config is missing agent section"));
|
|
357
|
+
if (!c.server) return Err(new Error("Config is missing server section"));
|
|
358
|
+
return Ok(config);
|
|
359
|
+
}
|
|
360
|
+
function getDefaultConfig() {
|
|
361
|
+
return {
|
|
362
|
+
tracker: {
|
|
363
|
+
kind: "roadmap",
|
|
364
|
+
filePath: "docs/roadmap.md",
|
|
365
|
+
activeStates: ["planned", "in-progress"],
|
|
366
|
+
terminalStates: ["done"]
|
|
367
|
+
},
|
|
368
|
+
polling: {
|
|
369
|
+
intervalMs: 3e4
|
|
370
|
+
},
|
|
371
|
+
workspace: {
|
|
372
|
+
root: ".harness/workspaces"
|
|
373
|
+
},
|
|
374
|
+
hooks: {
|
|
375
|
+
afterCreate: null,
|
|
376
|
+
beforeRun: null,
|
|
377
|
+
afterRun: null,
|
|
378
|
+
beforeRemove: null,
|
|
379
|
+
timeoutMs: 6e4
|
|
380
|
+
},
|
|
381
|
+
agent: {
|
|
382
|
+
backend: "mock",
|
|
383
|
+
maxConcurrentAgents: 1,
|
|
384
|
+
maxTurns: 10,
|
|
385
|
+
maxRetryBackoffMs: 5e3,
|
|
386
|
+
maxConcurrentAgentsByState: {},
|
|
387
|
+
turnTimeoutMs: 3e5,
|
|
388
|
+
readTimeoutMs: 3e4,
|
|
389
|
+
stallTimeoutMs: 6e4
|
|
390
|
+
},
|
|
391
|
+
server: {
|
|
392
|
+
port: 8080
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/workflow/loader.ts
|
|
398
|
+
var WorkflowLoader = class {
|
|
399
|
+
async loadWorkflow(filePath) {
|
|
400
|
+
try {
|
|
401
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
402
|
+
const parts = content.split("---");
|
|
403
|
+
if (parts.length < 3) {
|
|
404
|
+
return Err2(
|
|
405
|
+
new Error(
|
|
406
|
+
`Invalid WORKFLOW.md format at ${filePath}. Expected frontmatter surrounded by '---'.`
|
|
407
|
+
)
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const yamlContent = parts[1].trim();
|
|
411
|
+
const promptTemplate = parts.slice(2).join("---").trim();
|
|
412
|
+
const configData = parse(yamlContent);
|
|
413
|
+
const configResult = validateWorkflowConfig(configData);
|
|
414
|
+
if (!configResult.ok) {
|
|
415
|
+
return Err2(configResult.error);
|
|
416
|
+
}
|
|
417
|
+
return Ok2({
|
|
418
|
+
config: configResult.value,
|
|
419
|
+
promptTemplate
|
|
420
|
+
});
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return Err2(error instanceof Error ? error : new Error(String(error)));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// src/tracker/adapters/roadmap.ts
|
|
428
|
+
import * as fs2 from "fs/promises";
|
|
429
|
+
import { createHash } from "crypto";
|
|
430
|
+
import { parseRoadmap } from "@harness-engineering/core";
|
|
431
|
+
import {
|
|
432
|
+
Ok as Ok3,
|
|
433
|
+
Err as Err3
|
|
434
|
+
} from "@harness-engineering/types";
|
|
435
|
+
var RoadmapTrackerAdapter = class {
|
|
436
|
+
config;
|
|
437
|
+
/**
|
|
438
|
+
* Creates a new RoadmapTrackerAdapter.
|
|
439
|
+
*
|
|
440
|
+
* @param config - The tracker configuration including the file path
|
|
441
|
+
*/
|
|
442
|
+
constructor(config) {
|
|
443
|
+
this.config = config;
|
|
444
|
+
if (!config.filePath) {
|
|
445
|
+
throw new Error("RoadmapTrackerAdapter requires a filePath in TrackerConfig");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Fetches all issues that are in an "active" state according to the config.
|
|
450
|
+
*/
|
|
451
|
+
async fetchCandidateIssues() {
|
|
452
|
+
return this.fetchIssuesByStates(this.config.activeStates);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Fetches issues that match any of the given state names.
|
|
456
|
+
*
|
|
457
|
+
* @param stateNames - List of statuses to filter by
|
|
458
|
+
*/
|
|
459
|
+
async fetchIssuesByStates(stateNames) {
|
|
460
|
+
try {
|
|
461
|
+
if (!this.config.filePath) return Err3(new Error("Missing filePath"));
|
|
462
|
+
const content = await fs2.readFile(this.config.filePath, "utf-8");
|
|
463
|
+
const roadmapResult = parseRoadmap(content);
|
|
464
|
+
if (!roadmapResult.ok) return roadmapResult;
|
|
465
|
+
const issues = [];
|
|
466
|
+
for (const milestone of roadmapResult.value.milestones) {
|
|
467
|
+
for (const feature of milestone.features) {
|
|
468
|
+
if (stateNames.includes(feature.status)) {
|
|
469
|
+
issues.push(this.mapFeatureToIssue(feature));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return Ok3(issues);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
return Err3(error instanceof Error ? error : new Error(String(error)));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Fetches full issue details for a list of identifiers.
|
|
480
|
+
*
|
|
481
|
+
* @param issueIds - List of issue IDs to fetch
|
|
482
|
+
*/
|
|
483
|
+
async fetchIssueStatesByIds(issueIds) {
|
|
484
|
+
try {
|
|
485
|
+
if (!this.config.filePath) return Err3(new Error("Missing filePath"));
|
|
486
|
+
const content = await fs2.readFile(this.config.filePath, "utf-8");
|
|
487
|
+
const roadmapResult = parseRoadmap(content);
|
|
488
|
+
if (!roadmapResult.ok) return roadmapResult;
|
|
489
|
+
const issueMap = /* @__PURE__ */ new Map();
|
|
490
|
+
for (const milestone of roadmapResult.value.milestones) {
|
|
491
|
+
for (const feature of milestone.features) {
|
|
492
|
+
const issue = this.mapFeatureToIssue(feature);
|
|
493
|
+
if (issueIds.includes(issue.id)) {
|
|
494
|
+
issueMap.set(issue.id, issue);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return Ok3(issueMap);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return Err3(error instanceof Error ? error : new Error(String(error)));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Maps a raw RoadmapFeature from the parser to the unified Issue model.
|
|
505
|
+
*/
|
|
506
|
+
mapFeatureToIssue(feature) {
|
|
507
|
+
const id = this.generateId(feature.name);
|
|
508
|
+
return {
|
|
509
|
+
id,
|
|
510
|
+
identifier: id,
|
|
511
|
+
title: feature.name,
|
|
512
|
+
description: feature.summary,
|
|
513
|
+
priority: null,
|
|
514
|
+
state: feature.status,
|
|
515
|
+
branchName: null,
|
|
516
|
+
url: null,
|
|
517
|
+
labels: [],
|
|
518
|
+
blockedBy: feature.blockedBy.map((b) => ({
|
|
519
|
+
id: this.generateId(b),
|
|
520
|
+
identifier: b,
|
|
521
|
+
state: null
|
|
522
|
+
})),
|
|
523
|
+
createdAt: null,
|
|
524
|
+
updatedAt: null
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Generates a deterministic, URL-safe identifier for a feature name.
|
|
529
|
+
*/
|
|
530
|
+
generateId(name) {
|
|
531
|
+
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8);
|
|
532
|
+
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 20);
|
|
533
|
+
return `${sanitized}-${hash}`;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
// src/tracker/extensions/linear.ts
|
|
538
|
+
import { Ok as Ok4 } from "@harness-engineering/types";
|
|
539
|
+
var LinearGraphQLStub = class {
|
|
540
|
+
async query(query, _variables) {
|
|
541
|
+
console.log("Linear GraphQL query (stub):", query);
|
|
542
|
+
return Ok4({ data: {} });
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// src/workspace/manager.ts
|
|
547
|
+
import * as fs3 from "fs/promises";
|
|
548
|
+
import * as path from "path";
|
|
549
|
+
import { Ok as Ok5, Err as Err4 } from "@harness-engineering/types";
|
|
550
|
+
var WorkspaceManager = class {
|
|
551
|
+
config;
|
|
552
|
+
constructor(config) {
|
|
553
|
+
this.config = config;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Sanitizes an issue identifier to be safe for use as a directory name.
|
|
557
|
+
*/
|
|
558
|
+
sanitizeIdentifier(identifier) {
|
|
559
|
+
return identifier.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Resolves the full path for an issue's workspace.
|
|
563
|
+
*/
|
|
564
|
+
resolvePath(identifier) {
|
|
565
|
+
const sanitized = this.sanitizeIdentifier(identifier);
|
|
566
|
+
return path.join(this.config.root, sanitized);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Ensures the workspace directory exists.
|
|
570
|
+
*/
|
|
571
|
+
async ensureWorkspace(identifier) {
|
|
572
|
+
try {
|
|
573
|
+
const workspacePath = this.resolvePath(identifier);
|
|
574
|
+
await fs3.mkdir(workspacePath, { recursive: true });
|
|
575
|
+
return Ok5(workspacePath);
|
|
576
|
+
} catch (error) {
|
|
577
|
+
return Err4(error instanceof Error ? error : new Error(String(error)));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Checks if a workspace exists.
|
|
582
|
+
*/
|
|
583
|
+
async exists(identifier) {
|
|
584
|
+
try {
|
|
585
|
+
const workspacePath = this.resolvePath(identifier);
|
|
586
|
+
await fs3.access(workspacePath);
|
|
587
|
+
return true;
|
|
588
|
+
} catch {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Removes a workspace directory.
|
|
594
|
+
*/
|
|
595
|
+
async removeWorkspace(identifier) {
|
|
596
|
+
try {
|
|
597
|
+
const workspacePath = this.resolvePath(identifier);
|
|
598
|
+
await fs3.rm(workspacePath, { recursive: true, force: true });
|
|
599
|
+
return Ok5(void 0);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
return Err4(error instanceof Error ? error : new Error(String(error)));
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// src/workspace/hooks.ts
|
|
607
|
+
import { spawn } from "child_process";
|
|
608
|
+
import { Ok as Ok6, Err as Err5 } from "@harness-engineering/types";
|
|
609
|
+
var WorkspaceHooks = class {
|
|
610
|
+
config;
|
|
611
|
+
constructor(config) {
|
|
612
|
+
this.config = config;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Executes a shell hook in the specified workspace directory.
|
|
616
|
+
*/
|
|
617
|
+
async executeHook(hookName, cwd) {
|
|
618
|
+
const command = this.config[hookName];
|
|
619
|
+
if (!command) {
|
|
620
|
+
return Ok6(void 0);
|
|
621
|
+
}
|
|
622
|
+
return new Promise((resolve) => {
|
|
623
|
+
const child = spawn(command, {
|
|
624
|
+
shell: true,
|
|
625
|
+
cwd,
|
|
626
|
+
env: process.env
|
|
627
|
+
});
|
|
628
|
+
const timeout = setTimeout(() => {
|
|
629
|
+
child.kill();
|
|
630
|
+
resolve(Err5(new Error(`Hook ${hookName} timed out after ${this.config.timeoutMs}ms`)));
|
|
631
|
+
}, this.config.timeoutMs);
|
|
632
|
+
child.on("exit", (code) => {
|
|
633
|
+
clearTimeout(timeout);
|
|
634
|
+
if (code === 0) {
|
|
635
|
+
resolve(Ok6(void 0));
|
|
636
|
+
} else {
|
|
637
|
+
resolve(Err5(new Error(`Hook ${hookName} failed with exit code ${code}`)));
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
child.on("error", (error) => {
|
|
641
|
+
clearTimeout(timeout);
|
|
642
|
+
resolve(Err5(error));
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async afterCreate(cwd) {
|
|
647
|
+
return this.executeHook("afterCreate", cwd);
|
|
648
|
+
}
|
|
649
|
+
async beforeRun(cwd) {
|
|
650
|
+
return this.executeHook("beforeRun", cwd);
|
|
651
|
+
}
|
|
652
|
+
async afterRun(cwd) {
|
|
653
|
+
return this.executeHook("afterRun", cwd);
|
|
654
|
+
}
|
|
655
|
+
async beforeRemove(cwd) {
|
|
656
|
+
return this.executeHook("beforeRemove", cwd);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// src/agent/backends/mock.ts
|
|
661
|
+
import {
|
|
662
|
+
Ok as Ok7
|
|
663
|
+
} from "@harness-engineering/types";
|
|
664
|
+
var MockBackend = class {
|
|
665
|
+
name = "mock";
|
|
666
|
+
async startSession(params) {
|
|
667
|
+
const session = {
|
|
668
|
+
sessionId: `mock-session-${Date.now()}`,
|
|
669
|
+
workspacePath: params.workspacePath,
|
|
670
|
+
backendName: this.name,
|
|
671
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
672
|
+
};
|
|
673
|
+
return Ok7(session);
|
|
674
|
+
}
|
|
675
|
+
async *runTurn(session, _params) {
|
|
676
|
+
yield {
|
|
677
|
+
type: "status",
|
|
678
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
679
|
+
content: "Thinking...",
|
|
680
|
+
sessionId: session.sessionId
|
|
681
|
+
};
|
|
682
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
683
|
+
yield {
|
|
684
|
+
type: "thought",
|
|
685
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
686
|
+
content: "I will list the directory.",
|
|
687
|
+
sessionId: session.sessionId
|
|
688
|
+
};
|
|
689
|
+
return {
|
|
690
|
+
success: true,
|
|
691
|
+
sessionId: session.sessionId,
|
|
692
|
+
usage: {
|
|
693
|
+
inputTokens: 100,
|
|
694
|
+
outputTokens: 50,
|
|
695
|
+
totalTokens: 150
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
async stopSession(_session) {
|
|
700
|
+
return Ok7(void 0);
|
|
701
|
+
}
|
|
702
|
+
async healthCheck() {
|
|
703
|
+
return Ok7(void 0);
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// src/agent/backends/claude.ts
|
|
708
|
+
import { spawn as spawn2 } from "child_process";
|
|
709
|
+
import * as readline from "readline";
|
|
710
|
+
import {
|
|
711
|
+
Ok as Ok8,
|
|
712
|
+
Err as Err6
|
|
713
|
+
} from "@harness-engineering/types";
|
|
714
|
+
var ClaudeBackend = class {
|
|
715
|
+
name = "claude";
|
|
716
|
+
command;
|
|
717
|
+
constructor(command = "claude") {
|
|
718
|
+
this.command = command;
|
|
719
|
+
}
|
|
720
|
+
async startSession(params) {
|
|
721
|
+
const session = {
|
|
722
|
+
sessionId: `claude-session-${Date.now()}`,
|
|
723
|
+
workspacePath: params.workspacePath,
|
|
724
|
+
backendName: this.name,
|
|
725
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
726
|
+
};
|
|
727
|
+
return Ok8(session);
|
|
728
|
+
}
|
|
729
|
+
async *runTurn(session, params) {
|
|
730
|
+
const args = ["-p", params.prompt, "--output-format", "json"];
|
|
731
|
+
if (params.isContinuation) {
|
|
732
|
+
args.push("--resume", session.sessionId);
|
|
733
|
+
}
|
|
734
|
+
const child = spawn2(this.command, args, {
|
|
735
|
+
cwd: session.workspacePath,
|
|
736
|
+
env: process.env
|
|
737
|
+
});
|
|
738
|
+
const rl = readline.createInterface({
|
|
739
|
+
input: child.stdout,
|
|
740
|
+
terminal: false
|
|
741
|
+
});
|
|
742
|
+
let lastResult = null;
|
|
743
|
+
try {
|
|
744
|
+
for await (const line of rl) {
|
|
745
|
+
try {
|
|
746
|
+
const event = JSON.parse(line);
|
|
747
|
+
yield event;
|
|
748
|
+
if (event.type === "result") {
|
|
749
|
+
lastResult = event.content;
|
|
750
|
+
}
|
|
751
|
+
} catch {
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
} finally {
|
|
755
|
+
if (child.exitCode === null) {
|
|
756
|
+
child.kill("SIGTERM");
|
|
757
|
+
}
|
|
758
|
+
rl.close();
|
|
759
|
+
}
|
|
760
|
+
return lastResult || {
|
|
761
|
+
success: true,
|
|
762
|
+
sessionId: session.sessionId,
|
|
763
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
async stopSession(_session) {
|
|
767
|
+
return Ok8(void 0);
|
|
768
|
+
}
|
|
769
|
+
async healthCheck() {
|
|
770
|
+
return new Promise((resolve) => {
|
|
771
|
+
const child = spawn2(this.command, ["--version"]);
|
|
772
|
+
child.on("exit", (code) => {
|
|
773
|
+
if (code === 0) {
|
|
774
|
+
resolve(Ok8(void 0));
|
|
775
|
+
} else {
|
|
776
|
+
resolve(
|
|
777
|
+
Err6({
|
|
778
|
+
category: "agent_not_found",
|
|
779
|
+
message: `Claude command '${this.command}' not found or failed`
|
|
780
|
+
})
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
child.on("error", () => {
|
|
785
|
+
resolve(
|
|
786
|
+
Err6({
|
|
787
|
+
category: "agent_not_found",
|
|
788
|
+
message: `Claude command '${this.command}' not found`
|
|
789
|
+
})
|
|
790
|
+
);
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// src/prompt/renderer.ts
|
|
797
|
+
import { Liquid } from "liquidjs";
|
|
798
|
+
var PromptRenderer = class {
|
|
799
|
+
engine;
|
|
800
|
+
constructor() {
|
|
801
|
+
this.engine = new Liquid({
|
|
802
|
+
strictVariables: true,
|
|
803
|
+
strictFilters: true
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
async render(template, context) {
|
|
807
|
+
try {
|
|
808
|
+
return await this.engine.render(this.engine.parse(template), context);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
if (error instanceof Error) {
|
|
811
|
+
throw new Error(`Template rendering failed: ${error.message}`, { cause: error });
|
|
812
|
+
}
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// src/orchestrator.ts
|
|
819
|
+
import { EventEmitter } from "events";
|
|
820
|
+
|
|
821
|
+
// src/agent/runner.ts
|
|
822
|
+
var AgentRunner = class {
|
|
823
|
+
backend;
|
|
824
|
+
options;
|
|
825
|
+
constructor(backend, options) {
|
|
826
|
+
this.backend = backend;
|
|
827
|
+
this.options = options;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Run a multi-turn agent session for an issue.
|
|
831
|
+
*/
|
|
832
|
+
async *runSession(_issue, workspacePath, prompt) {
|
|
833
|
+
const startResult = await this.backend.startSession({
|
|
834
|
+
workspacePath,
|
|
835
|
+
permissionMode: "full"
|
|
836
|
+
// Default for now
|
|
837
|
+
});
|
|
838
|
+
if (!startResult.ok) {
|
|
839
|
+
throw new Error(`Failed to start agent session: ${startResult.error.message}`);
|
|
840
|
+
}
|
|
841
|
+
const session = startResult.value;
|
|
842
|
+
let currentTurn = 0;
|
|
843
|
+
let lastResult = {
|
|
844
|
+
success: false,
|
|
845
|
+
sessionId: session.sessionId,
|
|
846
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
|
|
847
|
+
};
|
|
848
|
+
try {
|
|
849
|
+
while (currentTurn < this.options.maxTurns) {
|
|
850
|
+
currentTurn++;
|
|
851
|
+
const turnParams = {
|
|
852
|
+
sessionId: session.sessionId,
|
|
853
|
+
prompt: currentTurn === 1 ? prompt : "Continue your work.",
|
|
854
|
+
isContinuation: currentTurn > 1
|
|
855
|
+
};
|
|
856
|
+
const turnGen = this.backend.runTurn(session, turnParams);
|
|
857
|
+
let next = await turnGen.next();
|
|
858
|
+
while (!next.done) {
|
|
859
|
+
yield next.value;
|
|
860
|
+
next = await turnGen.next();
|
|
861
|
+
}
|
|
862
|
+
lastResult = next.value;
|
|
863
|
+
if (lastResult.success) {
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
} finally {
|
|
868
|
+
await this.backend.stopSession(session);
|
|
869
|
+
}
|
|
870
|
+
return lastResult;
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// src/server/http.ts
|
|
875
|
+
import * as http from "http";
|
|
876
|
+
var OrchestratorServer = class {
|
|
877
|
+
server;
|
|
878
|
+
orchestrator;
|
|
879
|
+
port;
|
|
880
|
+
constructor(orchestrator, port) {
|
|
881
|
+
this.orchestrator = orchestrator;
|
|
882
|
+
this.port = port;
|
|
883
|
+
this.server = http.createServer(this.handleRequest.bind(this));
|
|
884
|
+
}
|
|
885
|
+
handleRequest(req, res) {
|
|
886
|
+
const { method, url } = req;
|
|
887
|
+
if (method === "GET" && url === "/api/v1/state") {
|
|
888
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
889
|
+
res.end(JSON.stringify(this.orchestrator.getSnapshot()));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
893
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
894
|
+
}
|
|
895
|
+
async start() {
|
|
896
|
+
return new Promise((resolve) => {
|
|
897
|
+
this.server.listen(this.port, "127.0.0.1", () => {
|
|
898
|
+
console.log(`Orchestrator API listening on localhost:${this.port}`);
|
|
899
|
+
resolve();
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
stop() {
|
|
904
|
+
this.server.close();
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
// src/logging/logger.ts
|
|
909
|
+
var StructuredLogger = class {
|
|
910
|
+
info(message, context) {
|
|
911
|
+
this.log("info", message, context);
|
|
912
|
+
}
|
|
913
|
+
warn(message, context) {
|
|
914
|
+
this.log("warn", message, context);
|
|
915
|
+
}
|
|
916
|
+
error(message, context) {
|
|
917
|
+
this.log("error", message, context);
|
|
918
|
+
}
|
|
919
|
+
log(level, message, context) {
|
|
920
|
+
const entry = {
|
|
921
|
+
level,
|
|
922
|
+
message,
|
|
923
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
924
|
+
...context ? { context } : {}
|
|
925
|
+
};
|
|
926
|
+
console.log(this.formatLog(entry));
|
|
927
|
+
}
|
|
928
|
+
formatLog(entry) {
|
|
929
|
+
const timestamp = entry.timestamp || (/* @__PURE__ */ new Date()).toISOString();
|
|
930
|
+
const contextStr = entry.context ? ` ${this.safeStringify(entry.context)}` : "";
|
|
931
|
+
return `${timestamp} ${entry.level.toUpperCase()}: ${entry.message}${contextStr}`;
|
|
932
|
+
}
|
|
933
|
+
safeStringify(obj) {
|
|
934
|
+
try {
|
|
935
|
+
return JSON.stringify(obj);
|
|
936
|
+
} catch {
|
|
937
|
+
return "[Circular or Non-serializable]";
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
// src/orchestrator.ts
|
|
943
|
+
var Orchestrator = class extends EventEmitter {
|
|
944
|
+
state;
|
|
945
|
+
config;
|
|
946
|
+
tracker;
|
|
947
|
+
workspace;
|
|
948
|
+
hooks;
|
|
949
|
+
runner;
|
|
950
|
+
renderer;
|
|
951
|
+
promptTemplate;
|
|
952
|
+
server;
|
|
953
|
+
interval;
|
|
954
|
+
logger;
|
|
955
|
+
/**
|
|
956
|
+
* Creates a new Orchestrator instance.
|
|
957
|
+
*
|
|
958
|
+
* @param config - The workflow configuration
|
|
959
|
+
* @param promptTemplate - The template used to generate agent instructions
|
|
960
|
+
* @param overrides - Optional dependency overrides for testing or custom behavior
|
|
961
|
+
*/
|
|
962
|
+
constructor(config, promptTemplate, overrides) {
|
|
963
|
+
super();
|
|
964
|
+
this.config = config;
|
|
965
|
+
this.promptTemplate = promptTemplate;
|
|
966
|
+
this.state = createEmptyState(config);
|
|
967
|
+
this.logger = new StructuredLogger();
|
|
968
|
+
this.tracker = overrides?.tracker || this.createTracker();
|
|
969
|
+
this.workspace = new WorkspaceManager(config.workspace);
|
|
970
|
+
this.hooks = new WorkspaceHooks(config.hooks);
|
|
971
|
+
this.renderer = new PromptRenderer();
|
|
972
|
+
this.runner = new AgentRunner(overrides?.backend || this.createBackend(), {
|
|
973
|
+
maxTurns: config.agent.maxTurns
|
|
974
|
+
});
|
|
975
|
+
if (config.server?.port) {
|
|
976
|
+
this.server = new OrchestratorServer(this, config.server.port);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
createTracker() {
|
|
980
|
+
if (this.config.tracker.kind === "roadmap") {
|
|
981
|
+
return new RoadmapTrackerAdapter(this.config.tracker);
|
|
982
|
+
}
|
|
983
|
+
throw new Error(`Unsupported tracker kind: ${this.config.tracker.kind}`);
|
|
984
|
+
}
|
|
985
|
+
createBackend() {
|
|
986
|
+
if (this.config.agent.backend === "mock") {
|
|
987
|
+
return new MockBackend();
|
|
988
|
+
} else if (this.config.agent.backend === "claude") {
|
|
989
|
+
return new ClaudeBackend(this.config.agent.command);
|
|
990
|
+
}
|
|
991
|
+
throw new Error(`Unsupported agent backend: ${this.config.agent.backend}`);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Run a single tick of the orchestrator loop.
|
|
995
|
+
*
|
|
996
|
+
* This method fetches the latest issue states, applies them to the state machine,
|
|
997
|
+
* and executes any resulting side effects (dispatching new agents, stopping agents, etc.).
|
|
998
|
+
*/
|
|
999
|
+
async tick() {
|
|
1000
|
+
const candidatesResult = await this.tracker.fetchCandidateIssues();
|
|
1001
|
+
if (!candidatesResult.ok) {
|
|
1002
|
+
this.logger.error("Failed to fetch candidate issues", {
|
|
1003
|
+
error: String(candidatesResult.error)
|
|
1004
|
+
});
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const runningIds = Array.from(this.state.running.keys());
|
|
1008
|
+
const runningStatesResult = await this.tracker.fetchIssueStatesByIds(runningIds);
|
|
1009
|
+
if (!runningStatesResult.ok) {
|
|
1010
|
+
this.logger.error("Failed to fetch running issue states", {
|
|
1011
|
+
error: String(runningStatesResult.error)
|
|
1012
|
+
});
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const tickEvent = {
|
|
1016
|
+
type: "tick",
|
|
1017
|
+
candidates: candidatesResult.value,
|
|
1018
|
+
runningStates: runningStatesResult.value,
|
|
1019
|
+
nowMs: Date.now()
|
|
1020
|
+
};
|
|
1021
|
+
const { nextState, effects } = applyEvent(this.state, tickEvent, this.config);
|
|
1022
|
+
this.state = nextState;
|
|
1023
|
+
for (const effect of effects) {
|
|
1024
|
+
await this.handleEffect(effect);
|
|
1025
|
+
}
|
|
1026
|
+
this.emit("state_change", this.getSnapshot());
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Processes a side effect generated by the state machine.
|
|
1030
|
+
*
|
|
1031
|
+
* @param effect - The effect to handle
|
|
1032
|
+
*/
|
|
1033
|
+
async handleEffect(effect) {
|
|
1034
|
+
switch (effect.type) {
|
|
1035
|
+
case "dispatch":
|
|
1036
|
+
await this.dispatchIssue(effect.issue, effect.attempt);
|
|
1037
|
+
break;
|
|
1038
|
+
case "stop":
|
|
1039
|
+
await this.stopIssue(effect.issueId);
|
|
1040
|
+
break;
|
|
1041
|
+
case "updateTokens":
|
|
1042
|
+
break;
|
|
1043
|
+
case "emitLog":
|
|
1044
|
+
this.logger.log(effect.level, effect.message, effect.context);
|
|
1045
|
+
break;
|
|
1046
|
+
case "releaseClaim":
|
|
1047
|
+
break;
|
|
1048
|
+
case "cleanWorkspace":
|
|
1049
|
+
await this.workspace.removeWorkspace(effect.identifier);
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Dispatches a new agent to work on an issue.
|
|
1055
|
+
*
|
|
1056
|
+
* @param issue - The issue to resolve
|
|
1057
|
+
* @param attempt - The retry attempt number
|
|
1058
|
+
*/
|
|
1059
|
+
async dispatchIssue(issue, attempt) {
|
|
1060
|
+
this.logger.info(`Dispatching issue: ${issue.identifier} (attempt ${attempt})`, {
|
|
1061
|
+
issueId: issue.id
|
|
1062
|
+
});
|
|
1063
|
+
try {
|
|
1064
|
+
const workspaceResult = await this.workspace.ensureWorkspace(issue.identifier);
|
|
1065
|
+
if (!workspaceResult.ok) throw workspaceResult.error;
|
|
1066
|
+
const workspacePath = workspaceResult.value;
|
|
1067
|
+
const hookResult = await this.hooks.beforeRun(workspacePath);
|
|
1068
|
+
if (!hookResult.ok) throw hookResult.error;
|
|
1069
|
+
const prompt = await this.renderer.render(this.promptTemplate, {
|
|
1070
|
+
issue,
|
|
1071
|
+
attempt: attempt || 1
|
|
1072
|
+
});
|
|
1073
|
+
this.runAgentInBackgroundTask(issue, workspacePath, prompt, attempt);
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
this.logger.error(`Dispatch failed for ${issue.identifier}`, { error: String(error) });
|
|
1076
|
+
await this.emitWorkerExit(issue.id, "error", attempt, String(error));
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Runs an agent session in a background task to avoid blocking the main loop.
|
|
1081
|
+
*/
|
|
1082
|
+
runAgentInBackgroundTask(issue, workspacePath, prompt, attempt) {
|
|
1083
|
+
(async () => {
|
|
1084
|
+
try {
|
|
1085
|
+
const sessionGen = this.runner.runSession(issue, workspacePath, prompt);
|
|
1086
|
+
for await (const event of sessionGen) {
|
|
1087
|
+
this.emit("agent_event", { issueId: issue.id, event });
|
|
1088
|
+
}
|
|
1089
|
+
await this.emitWorkerExit(issue.id, "normal", attempt);
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
this.logger.error(`Agent runner failed for ${issue.identifier}`, { error: String(error) });
|
|
1092
|
+
await this.emitWorkerExit(issue.id, "error", attempt, String(error));
|
|
1093
|
+
}
|
|
1094
|
+
})().catch((err) => {
|
|
1095
|
+
this.logger.error("Fatal error in background task", { error: String(err) });
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Informs the state machine that an agent worker has exited.
|
|
1100
|
+
*/
|
|
1101
|
+
async emitWorkerExit(issueId, reason, attempt, error) {
|
|
1102
|
+
const event = {
|
|
1103
|
+
type: "worker_exit",
|
|
1104
|
+
issueId,
|
|
1105
|
+
reason,
|
|
1106
|
+
error,
|
|
1107
|
+
attempt
|
|
1108
|
+
};
|
|
1109
|
+
const { nextState, effects } = applyEvent(this.state, event, this.config);
|
|
1110
|
+
this.state = nextState;
|
|
1111
|
+
for (const effect of effects) {
|
|
1112
|
+
await this.handleEffect(effect);
|
|
1113
|
+
}
|
|
1114
|
+
this.emit("state_change", this.getSnapshot());
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Stops execution for a specific issue.
|
|
1118
|
+
*
|
|
1119
|
+
* @param issueId - The ID of the issue to stop
|
|
1120
|
+
*/
|
|
1121
|
+
async stopIssue(issueId) {
|
|
1122
|
+
this.logger.info(`Stopping issue: ${issueId}`);
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Starts the polling loop and the internal HTTP server.
|
|
1126
|
+
*/
|
|
1127
|
+
start() {
|
|
1128
|
+
if (this.server) {
|
|
1129
|
+
void this.server.start();
|
|
1130
|
+
}
|
|
1131
|
+
const intervalMs = this.config.polling.intervalMs || 3e4;
|
|
1132
|
+
this.interval = setInterval(() => {
|
|
1133
|
+
void this.tick();
|
|
1134
|
+
}, intervalMs);
|
|
1135
|
+
void this.tick();
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Stops the orchestrator, clearing the polling interval and stopping the server.
|
|
1139
|
+
*/
|
|
1140
|
+
async stop() {
|
|
1141
|
+
if (this.interval) {
|
|
1142
|
+
clearInterval(this.interval);
|
|
1143
|
+
this.interval = void 0;
|
|
1144
|
+
}
|
|
1145
|
+
if (this.server) {
|
|
1146
|
+
this.server.stop();
|
|
1147
|
+
}
|
|
1148
|
+
this.logger.info("Orchestrator stopped.");
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Returns a point-in-time snapshot of the orchestrator's internal state.
|
|
1152
|
+
*/
|
|
1153
|
+
getSnapshot() {
|
|
1154
|
+
return {
|
|
1155
|
+
running: Array.from(this.state.running.entries()),
|
|
1156
|
+
retryAttempts: Array.from(this.state.retryAttempts.entries()),
|
|
1157
|
+
claimed: Array.from(this.state.claimed),
|
|
1158
|
+
tokenTotals: this.state.tokenTotals,
|
|
1159
|
+
maxConcurrentAgents: this.state.maxConcurrentAgents
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
// src/tui/launcher.tsx
|
|
1165
|
+
import { render } from "ink";
|
|
1166
|
+
|
|
1167
|
+
// src/tui/app.tsx
|
|
1168
|
+
import { useState as useState2, useEffect as useEffect2 } from "react";
|
|
1169
|
+
import { Box as Box4, Text as Text4, useApp, useInput } from "ink";
|
|
1170
|
+
|
|
1171
|
+
// src/tui/components/Header.tsx
|
|
1172
|
+
import { useState, useEffect } from "react";
|
|
1173
|
+
import { Box, Text } from "ink";
|
|
1174
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1175
|
+
var Header = () => {
|
|
1176
|
+
const [uptime, setUptime] = useState(0);
|
|
1177
|
+
useEffect(() => {
|
|
1178
|
+
const timer = setInterval(() => {
|
|
1179
|
+
setUptime((prev) => prev + 1);
|
|
1180
|
+
}, 1e3);
|
|
1181
|
+
return () => clearInterval(timer);
|
|
1182
|
+
}, []);
|
|
1183
|
+
const formatUptime = (seconds) => {
|
|
1184
|
+
const h = Math.floor(seconds / 3600);
|
|
1185
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
1186
|
+
const s = seconds % 60;
|
|
1187
|
+
return `${h}h ${m}m ${s}s`;
|
|
1188
|
+
};
|
|
1189
|
+
return /* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, justifyContent: "space-between", children: [
|
|
1190
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "Harness Orchestrator" }),
|
|
1191
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1192
|
+
"Uptime: ",
|
|
1193
|
+
formatUptime(uptime)
|
|
1194
|
+
] })
|
|
1195
|
+
] });
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
// src/tui/components/Stats.tsx
|
|
1199
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
1200
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
1201
|
+
var Stats = ({ tokenTotals, runningCount, maxConcurrency }) => {
|
|
1202
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "row", paddingX: 1, gap: 4, children: [
|
|
1203
|
+
/* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
1204
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "Concurrency" }),
|
|
1205
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1206
|
+
"Active: ",
|
|
1207
|
+
/* @__PURE__ */ jsx2(Text2, { color: runningCount > 0 ? "green" : "gray", children: runningCount }),
|
|
1208
|
+
" /",
|
|
1209
|
+
" ",
|
|
1210
|
+
maxConcurrency
|
|
1211
|
+
] })
|
|
1212
|
+
] }),
|
|
1213
|
+
/* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
1214
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "Token Usage" }),
|
|
1215
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1216
|
+
"Input: ",
|
|
1217
|
+
/* @__PURE__ */ jsx2(Text2, { color: "yellow", children: tokenTotals.inputTokens.toLocaleString() })
|
|
1218
|
+
] }),
|
|
1219
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1220
|
+
"Output: ",
|
|
1221
|
+
/* @__PURE__ */ jsx2(Text2, { color: "yellow", children: tokenTotals.outputTokens.toLocaleString() })
|
|
1222
|
+
] }),
|
|
1223
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1224
|
+
"Total: ",
|
|
1225
|
+
/* @__PURE__ */ jsx2(Text2, { color: "yellow", children: tokenTotals.totalTokens.toLocaleString() })
|
|
1226
|
+
] })
|
|
1227
|
+
] }),
|
|
1228
|
+
/* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
1229
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "Efficiency" }),
|
|
1230
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1231
|
+
"Time Running: ",
|
|
1232
|
+
/* @__PURE__ */ jsxs2(Text2, { color: "blue", children: [
|
|
1233
|
+
Math.round(tokenTotals.secondsRunning),
|
|
1234
|
+
"s"
|
|
1235
|
+
] })
|
|
1236
|
+
] })
|
|
1237
|
+
] })
|
|
1238
|
+
] });
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
// src/tui/components/AgentsTable.tsx
|
|
1242
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
1243
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
1244
|
+
var AgentsTable = ({ agents }) => {
|
|
1245
|
+
if (agents.length === 0) {
|
|
1246
|
+
return /* @__PURE__ */ jsx3(Box3, { paddingX: 1, marginY: 1, children: /* @__PURE__ */ jsx3(Text3, { color: "gray", italic: true, children: "No active agents." }) });
|
|
1247
|
+
}
|
|
1248
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginY: 1, children: [
|
|
1249
|
+
/* @__PURE__ */ jsx3(Text3, { bold: true, underline: true, children: "Active Agents" }),
|
|
1250
|
+
/* @__PURE__ */ jsxs3(Box3, { flexDirection: "row", borderStyle: "single", borderColor: "gray", children: [
|
|
1251
|
+
/* @__PURE__ */ jsx3(Box3, { width: 20, children: /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Identifier" }) }),
|
|
1252
|
+
/* @__PURE__ */ jsx3(Box3, { width: 20, children: /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Phase" }) }),
|
|
1253
|
+
/* @__PURE__ */ jsx3(Box3, { width: 10, children: /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Tokens" }) }),
|
|
1254
|
+
/* @__PURE__ */ jsx3(Box3, { flexGrow: 1, children: /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Message" }) })
|
|
1255
|
+
] }),
|
|
1256
|
+
agents.map((agent) => /* @__PURE__ */ jsxs3(Box3, { flexDirection: "row", children: [
|
|
1257
|
+
/* @__PURE__ */ jsx3(Box3, { width: 20, children: /* @__PURE__ */ jsx3(Text3, { children: agent.identifier }) }),
|
|
1258
|
+
/* @__PURE__ */ jsx3(Box3, { width: 20, children: /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: agent.phase }) }),
|
|
1259
|
+
/* @__PURE__ */ jsx3(Box3, { width: 10, children: /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: agent.session?.totalTokens || 0 }) }),
|
|
1260
|
+
/* @__PURE__ */ jsx3(Box3, { flexGrow: 1, children: /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: agent.session?.lastMessage || "-" }) })
|
|
1261
|
+
] }, agent.issueId))
|
|
1262
|
+
] });
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
// src/tui/app.tsx
|
|
1266
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1267
|
+
var Dashboard = ({ orchestrator }) => {
|
|
1268
|
+
const [state, setState] = useState2(orchestrator.getSnapshot());
|
|
1269
|
+
const { exit } = useApp();
|
|
1270
|
+
useEffect2(() => {
|
|
1271
|
+
const handleStateChange = (newState) => {
|
|
1272
|
+
setState(newState);
|
|
1273
|
+
};
|
|
1274
|
+
orchestrator.on("state_change", handleStateChange);
|
|
1275
|
+
return () => {
|
|
1276
|
+
orchestrator.off("state_change", handleStateChange);
|
|
1277
|
+
};
|
|
1278
|
+
}, [orchestrator]);
|
|
1279
|
+
useInput((input, key) => {
|
|
1280
|
+
if (input === "q" || key.ctrl && input === "c") {
|
|
1281
|
+
orchestrator.stop();
|
|
1282
|
+
exit();
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
const runningAgents = state.running.map(([_, entry]) => entry);
|
|
1286
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", padding: 1, children: [
|
|
1287
|
+
/* @__PURE__ */ jsx4(Header, {}),
|
|
1288
|
+
/* @__PURE__ */ jsx4(
|
|
1289
|
+
Stats,
|
|
1290
|
+
{
|
|
1291
|
+
tokenTotals: state.tokenTotals,
|
|
1292
|
+
runningCount: state.running.length,
|
|
1293
|
+
maxConcurrency: state.maxConcurrentAgents
|
|
1294
|
+
}
|
|
1295
|
+
),
|
|
1296
|
+
/* @__PURE__ */ jsx4(AgentsTable, { agents: runningAgents }),
|
|
1297
|
+
/* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "Press 'q' to quit" }) })
|
|
1298
|
+
] });
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
// src/tui/launcher.tsx
|
|
1302
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
1303
|
+
function launchTUI(orchestrator) {
|
|
1304
|
+
const { waitUntilExit } = render(/* @__PURE__ */ jsx5(Dashboard, { orchestrator }));
|
|
1305
|
+
return { waitUntilExit };
|
|
1306
|
+
}
|
|
1307
|
+
export {
|
|
1308
|
+
ClaudeBackend,
|
|
1309
|
+
LinearGraphQLStub,
|
|
1310
|
+
MockBackend,
|
|
1311
|
+
Orchestrator,
|
|
1312
|
+
PromptRenderer,
|
|
1313
|
+
RoadmapTrackerAdapter,
|
|
1314
|
+
WorkflowLoader,
|
|
1315
|
+
WorkspaceHooks,
|
|
1316
|
+
WorkspaceManager,
|
|
1317
|
+
applyEvent,
|
|
1318
|
+
calculateRetryDelay,
|
|
1319
|
+
canDispatch,
|
|
1320
|
+
createEmptyState,
|
|
1321
|
+
getAvailableSlots,
|
|
1322
|
+
getDefaultConfig,
|
|
1323
|
+
getPerStateCount,
|
|
1324
|
+
isEligible,
|
|
1325
|
+
launchTUI,
|
|
1326
|
+
reconcile,
|
|
1327
|
+
selectCandidates,
|
|
1328
|
+
sortCandidates,
|
|
1329
|
+
validateWorkflowConfig
|
|
1330
|
+
};
|