better-symphony 1.0.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/CLAUDE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/web/app.css +2 -0
- package/dist/web/index.html +13 -0
- package/dist/web/main.js +235 -0
- package/package.json +62 -0
- package/src/agent/claude-runner.ts +576 -0
- package/src/agent/protocol.ts +2 -0
- package/src/agent/runner.ts +2 -0
- package/src/agent/session.ts +113 -0
- package/src/cli.ts +354 -0
- package/src/config/loader.ts +379 -0
- package/src/config/types.ts +382 -0
- package/src/index.ts +53 -0
- package/src/linear-cli.ts +414 -0
- package/src/logging/logger.ts +143 -0
- package/src/orchestrator/multi-orchestrator.ts +266 -0
- package/src/orchestrator/orchestrator.ts +1357 -0
- package/src/orchestrator/scheduler.ts +195 -0
- package/src/orchestrator/state.ts +201 -0
- package/src/prompts/github-system-prompt.md +51 -0
- package/src/prompts/linear-system-prompt.md +44 -0
- package/src/tracker/client.ts +577 -0
- package/src/tracker/github-issues-tracker.ts +280 -0
- package/src/tracker/github-pr-tracker.ts +298 -0
- package/src/tracker/index.ts +9 -0
- package/src/tracker/interface.ts +76 -0
- package/src/tracker/linear-tracker.ts +147 -0
- package/src/tracker/queries.ts +281 -0
- package/src/tracker/types.ts +125 -0
- package/src/tui/App.tsx +157 -0
- package/src/tui/LogView.tsx +120 -0
- package/src/tui/StatusBar.tsx +72 -0
- package/src/tui/TabBar.tsx +55 -0
- package/src/tui/sink.ts +47 -0
- package/src/tui/types.ts +6 -0
- package/src/tui/useOrchestrator.ts +244 -0
- package/src/web/server.ts +182 -0
- package/src/web/sink.ts +67 -0
- package/src/web-ui/App.tsx +60 -0
- package/src/web-ui/components/agent-table.tsx +57 -0
- package/src/web-ui/components/header.tsx +72 -0
- package/src/web-ui/components/log-stream.tsx +111 -0
- package/src/web-ui/components/retry-table.tsx +58 -0
- package/src/web-ui/components/stats-cards.tsx +142 -0
- package/src/web-ui/components/ui/badge.tsx +30 -0
- package/src/web-ui/components/ui/button.tsx +39 -0
- package/src/web-ui/components/ui/card.tsx +32 -0
- package/src/web-ui/globals.css +27 -0
- package/src/web-ui/index.html +13 -0
- package/src/web-ui/lib/use-sse.ts +98 -0
- package/src/web-ui/lib/utils.ts +25 -0
- package/src/web-ui/main.tsx +4 -0
- package/src/workspace/hooks.ts +97 -0
- package/src/workspace/manager.ts +211 -0
- package/src/workspace/render-hook.ts +13 -0
- package/workflows/dev.md +127 -0
- package/workflows/github-issues.md +107 -0
- package/workflows/pr-review.md +89 -0
- package/workflows/prd.md +170 -0
- package/workflows/ralph.md +95 -0
- package/workflows/smoke.md +66 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Workflow Orchestrator
|
|
3
|
+
* Shares a single LinearClient and poll loop across multiple workflows
|
|
4
|
+
* to avoid hammering the Linear API with N independent pollers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Issue, ServiceConfig } from "../config/types.js";
|
|
8
|
+
import { loadWorkflow, buildServiceConfig, validateServiceConfig } from "../config/loader.js";
|
|
9
|
+
import { LinearClient } from "../tracker/client.js";
|
|
10
|
+
import { logger } from "../logging/logger.js";
|
|
11
|
+
import { Orchestrator } from "./orchestrator.js";
|
|
12
|
+
import type { RuntimeSnapshot } from "./state.js";
|
|
13
|
+
|
|
14
|
+
export interface MultiOrchestratorOptions {
|
|
15
|
+
workflowPaths: string[];
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface WorkflowEntry {
|
|
20
|
+
path: string;
|
|
21
|
+
orchestrator: Orchestrator;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class MultiOrchestrator {
|
|
25
|
+
private entries: WorkflowEntry[] = [];
|
|
26
|
+
private linearClient: LinearClient | null = null;
|
|
27
|
+
private pollTimer: Timer | null = null;
|
|
28
|
+
private running = false;
|
|
29
|
+
private workflowPaths: string[];
|
|
30
|
+
private debug: boolean;
|
|
31
|
+
|
|
32
|
+
constructor(options: MultiOrchestratorOptions) {
|
|
33
|
+
this.workflowPaths = options.workflowPaths;
|
|
34
|
+
this.debug = options.debug ?? false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Lifecycle ─────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
async start(): Promise<void> {
|
|
40
|
+
logger.info("Starting multi-workflow orchestrator", {
|
|
41
|
+
workflows: this.workflowPaths.length,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Load first workflow to bootstrap the shared LinearClient
|
|
45
|
+
const firstWorkflow = loadWorkflow(this.workflowPaths[0]);
|
|
46
|
+
const firstConfig = buildServiceConfig(firstWorkflow);
|
|
47
|
+
const firstValidation = validateServiceConfig(firstConfig);
|
|
48
|
+
|
|
49
|
+
if (!firstValidation.valid) {
|
|
50
|
+
throw new Error(`First workflow validation failed: ${firstValidation.errors.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create shared LinearClient
|
|
54
|
+
this.linearClient = new LinearClient(
|
|
55
|
+
firstConfig.tracker.endpoint,
|
|
56
|
+
firstConfig.tracker.api_key
|
|
57
|
+
);
|
|
58
|
+
this.linearClient.onRateLimit = (attempt, waitSecs) => {
|
|
59
|
+
logger.warn(`Linear rate limit hit, retrying in ${waitSecs}s`, { attempt });
|
|
60
|
+
};
|
|
61
|
+
this.linearClient.onThrottle = (remaining, limit) => {
|
|
62
|
+
logger.debug(`Throttling Linear requests`, { remaining, limit });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Start each orchestrator in managed mode
|
|
66
|
+
for (const path of this.workflowPaths) {
|
|
67
|
+
const orchestrator = new Orchestrator({
|
|
68
|
+
workflowPath: path,
|
|
69
|
+
linearClient: this.linearClient,
|
|
70
|
+
managedPolling: true,
|
|
71
|
+
debug: this.debug,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await orchestrator.start();
|
|
75
|
+
this.entries.push({ path, orchestrator });
|
|
76
|
+
|
|
77
|
+
logger.info(`Loaded workflow: ${path}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Start shared poll loop
|
|
81
|
+
this.running = true;
|
|
82
|
+
this.schedulePoll(0);
|
|
83
|
+
|
|
84
|
+
logger.info("Multi-workflow orchestrator started", {
|
|
85
|
+
workflows: this.entries.length,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async stop(): Promise<void> {
|
|
90
|
+
logger.info("Stopping multi-workflow orchestrator");
|
|
91
|
+
this.running = false;
|
|
92
|
+
|
|
93
|
+
if (this.pollTimer) {
|
|
94
|
+
clearTimeout(this.pollTimer);
|
|
95
|
+
this.pollTimer = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Stop all child orchestrators
|
|
99
|
+
await Promise.all(this.entries.map((e) => e.orchestrator.stop()));
|
|
100
|
+
|
|
101
|
+
logger.info("Multi-workflow orchestrator stopped");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Force an immediate poll tick, resetting the poll timer */
|
|
105
|
+
async forcePoll(): Promise<void> {
|
|
106
|
+
if (!this.running) return;
|
|
107
|
+
if (this.pollTimer) {
|
|
108
|
+
clearTimeout(this.pollTimer);
|
|
109
|
+
this.pollTimer = null;
|
|
110
|
+
}
|
|
111
|
+
logger.info("Force refresh triggered");
|
|
112
|
+
await this.pollTick();
|
|
113
|
+
this.schedulePoll(this.getPollInterval());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Shared Poll Loop ──────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
private schedulePoll(delayMs: number): void {
|
|
119
|
+
if (!this.running) return;
|
|
120
|
+
|
|
121
|
+
this.pollTimer = setTimeout(async () => {
|
|
122
|
+
await this.pollTick();
|
|
123
|
+
this.schedulePoll(this.getPollInterval());
|
|
124
|
+
}, delayMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async pollTick(): Promise<void> {
|
|
128
|
+
if (!this.linearClient) return;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Step 1: Stall detection on all orchestrators
|
|
132
|
+
for (const entry of this.entries) {
|
|
133
|
+
entry.orchestrator.runStallDetection();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Step 2: Batched reconciliation — collect all running IDs
|
|
137
|
+
const allRunningIds: string[] = [];
|
|
138
|
+
for (const entry of this.entries) {
|
|
139
|
+
allRunningIds.push(...entry.orchestrator.getRunningIssueIds());
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (allRunningIds.length > 0) {
|
|
143
|
+
try {
|
|
144
|
+
const stateMap = await this.linearClient.fetchIssueStatesByIds(allRunningIds);
|
|
145
|
+
for (const entry of this.entries) {
|
|
146
|
+
await entry.orchestrator.applyReconcileStates(stateMap);
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.warn(`Shared state refresh failed: ${(err as Error).message}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Step 3: Refresh configs on all orchestrators
|
|
154
|
+
for (const entry of this.entries) {
|
|
155
|
+
entry.orchestrator.refreshConfig();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Step 4: Fetch candidates — group by project_slug to minimize API calls
|
|
159
|
+
const slugGroups = this.groupByProjectSlug();
|
|
160
|
+
|
|
161
|
+
for (const [slug, group] of slugGroups) {
|
|
162
|
+
// Union all active_states across workflows targeting this slug
|
|
163
|
+
const unionStates = new Set<string>();
|
|
164
|
+
for (const { config } of group) {
|
|
165
|
+
for (const s of config.tracker.active_states) {
|
|
166
|
+
unionStates.add(s);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// One fetch per unique project_slug
|
|
171
|
+
const issues = await this.linearClient.fetchCandidateIssues(
|
|
172
|
+
slug,
|
|
173
|
+
Array.from(unionStates)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
logger.debug(`Fetched ${issues.length} issues for project ${slug}`, {
|
|
177
|
+
workflows: group.length,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Step 5: Distribute to each workflow's scheduler
|
|
181
|
+
let totalDispatched = 0;
|
|
182
|
+
for (const { entry } of group) {
|
|
183
|
+
const dispatched = entry.orchestrator.dispatchFromIssues(issues);
|
|
184
|
+
totalDispatched += dispatched;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (totalDispatched > 0) {
|
|
188
|
+
logger.info(`Dispatched ${totalDispatched} issues across workflows for ${slug}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
logger.error(`Multi-orchestrator poll tick failed: ${(err as Error).message}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
private groupByProjectSlug(): Map<string, Array<{ entry: WorkflowEntry; config: ServiceConfig }>> {
|
|
199
|
+
const groups = new Map<string, Array<{ entry: WorkflowEntry; config: ServiceConfig }>>();
|
|
200
|
+
|
|
201
|
+
for (const entry of this.entries) {
|
|
202
|
+
const config = entry.orchestrator.getServiceConfig();
|
|
203
|
+
if (!config) continue;
|
|
204
|
+
|
|
205
|
+
const slug = config.tracker.project_slug;
|
|
206
|
+
if (!groups.has(slug)) {
|
|
207
|
+
groups.set(slug, []);
|
|
208
|
+
}
|
|
209
|
+
groups.get(slug)!.push({ entry, config });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return groups;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Use the minimum poll interval across all workflows */
|
|
216
|
+
private getPollInterval(): number {
|
|
217
|
+
let min = 30000;
|
|
218
|
+
for (const entry of this.entries) {
|
|
219
|
+
const config = entry.orchestrator.getServiceConfig();
|
|
220
|
+
if (config && config.polling.interval_ms < min) {
|
|
221
|
+
min = config.polling.interval_ms;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return min;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Observability ─────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/** Aggregate snapshot across all workflows */
|
|
230
|
+
getSnapshot(): RuntimeSnapshot | null {
|
|
231
|
+
const snapshots: RuntimeSnapshot[] = [];
|
|
232
|
+
|
|
233
|
+
for (const entry of this.entries) {
|
|
234
|
+
const snap = entry.orchestrator.getSnapshot();
|
|
235
|
+
if (snap) snapshots.push(snap);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (snapshots.length === 0) return null;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
running: snapshots.flatMap((s) => s.running),
|
|
242
|
+
retrying: snapshots.flatMap((s) => s.retrying),
|
|
243
|
+
workflows: snapshots.flatMap((s) => s.workflows),
|
|
244
|
+
token_totals: {
|
|
245
|
+
input_tokens: snapshots.reduce((sum, s) => sum + s.token_totals.input_tokens, 0),
|
|
246
|
+
output_tokens: snapshots.reduce((sum, s) => sum + s.token_totals.output_tokens, 0),
|
|
247
|
+
total_tokens: snapshots.reduce((sum, s) => sum + s.token_totals.total_tokens, 0),
|
|
248
|
+
seconds_running: snapshots.reduce((sum, s) => sum + s.token_totals.seconds_running, 0),
|
|
249
|
+
},
|
|
250
|
+
rate_limits: (() => {
|
|
251
|
+
const rl = this.linearClient?.getRateLimitState();
|
|
252
|
+
return rl
|
|
253
|
+
? {
|
|
254
|
+
requests_limit: rl.requestsLimit,
|
|
255
|
+
requests_remaining: rl.requestsRemaining,
|
|
256
|
+
requests_reset: rl.requestsReset,
|
|
257
|
+
}
|
|
258
|
+
: snapshots[0].rate_limits;
|
|
259
|
+
})(),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
isRunning(): boolean {
|
|
264
|
+
return this.running;
|
|
265
|
+
}
|
|
266
|
+
}
|