symphifo 0.1.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 +201 -0
- package/NOTICE +13 -0
- package/README.md +394 -0
- package/SYMPHIFO.md +171 -0
- package/WORKFLOW.md +39 -0
- package/bin/symphifo.js +37 -0
- package/package.json +46 -0
- package/src/cli.ts +213 -0
- package/src/dashboard/app.js +1390 -0
- package/src/dashboard/index.html +139 -0
- package/src/dashboard/styles.css +1528 -0
- package/src/fixtures/local-issues.json +13 -0
- package/src/integrations/catalog.ts +151 -0
- package/src/mcp/server.ts +1237 -0
- package/src/routing/capability-resolver.ts +390 -0
- package/src/runtime/agent.ts +1050 -0
- package/src/runtime/api-server.ts +306 -0
- package/src/runtime/constants.ts +102 -0
- package/src/runtime/helpers.ts +134 -0
- package/src/runtime/issues.ts +456 -0
- package/src/runtime/logger.ts +59 -0
- package/src/runtime/providers.ts +310 -0
- package/src/runtime/run-local.ts +146 -0
- package/src/runtime/scheduler.ts +214 -0
- package/src/runtime/skills.ts +55 -0
- package/src/runtime/store.ts +313 -0
- package/src/runtime/types.ts +274 -0
- package/src/runtime/workflow.ts +185 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { env } from "node:process";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import type {
|
|
15
|
+
AgentDirective,
|
|
16
|
+
AgentDirectiveStatus,
|
|
17
|
+
AgentPipelineRecord,
|
|
18
|
+
AgentPipelineState,
|
|
19
|
+
AgentProviderDefinition,
|
|
20
|
+
AgentSessionRecord,
|
|
21
|
+
AgentSessionResult,
|
|
22
|
+
AgentSessionState,
|
|
23
|
+
AgentSessionTurn,
|
|
24
|
+
IssueEntry,
|
|
25
|
+
JsonRecord,
|
|
26
|
+
RuntimeConfig,
|
|
27
|
+
RuntimeState,
|
|
28
|
+
WorkflowDefinition,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
import {
|
|
31
|
+
SOURCE_ROOT,
|
|
32
|
+
WORKSPACE_ROOT,
|
|
33
|
+
} from "./constants.ts";
|
|
34
|
+
import {
|
|
35
|
+
now,
|
|
36
|
+
sleep,
|
|
37
|
+
toStringValue,
|
|
38
|
+
toNumberValue,
|
|
39
|
+
clamp,
|
|
40
|
+
idToSafePath,
|
|
41
|
+
appendFileTail,
|
|
42
|
+
getNestedRecord,
|
|
43
|
+
getNestedNumber,
|
|
44
|
+
} from "./helpers.ts";
|
|
45
|
+
import { logger } from "./logger.ts";
|
|
46
|
+
import {
|
|
47
|
+
getAgentSessionResource,
|
|
48
|
+
getAgentPipelineResource,
|
|
49
|
+
isStateNotFoundError,
|
|
50
|
+
persistState,
|
|
51
|
+
} from "./store.ts";
|
|
52
|
+
import {
|
|
53
|
+
normalizeAgentProvider,
|
|
54
|
+
getEffectiveAgentProviders,
|
|
55
|
+
applyCapabilityMetadata,
|
|
56
|
+
} from "./providers.ts";
|
|
57
|
+
import {
|
|
58
|
+
addEvent,
|
|
59
|
+
transition,
|
|
60
|
+
computeMetrics,
|
|
61
|
+
getNextRetryAt,
|
|
62
|
+
} from "./issues.ts";
|
|
63
|
+
import {
|
|
64
|
+
inferCapabilityPaths,
|
|
65
|
+
resolveTaskCapabilities,
|
|
66
|
+
} from "../routing/capability-resolver.ts";
|
|
67
|
+
import { discoverSkills, buildSkillContext } from "./skills.ts";
|
|
68
|
+
|
|
69
|
+
function normalizeAgentDirectiveStatus(value: unknown, fallback: AgentDirectiveStatus): AgentDirectiveStatus {
|
|
70
|
+
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
71
|
+
if (normalized === "done" || normalized === "continue" || normalized === "blocked" || normalized === "failed") {
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractOutputMarker(output: string, name: string): string {
|
|
78
|
+
const match = output.match(new RegExp(`^${name}=(.+)$`, "im"));
|
|
79
|
+
return match?.[1]?.trim() ?? "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readAgentDirective(workspacePath: string, output: string, success: boolean): AgentDirective {
|
|
83
|
+
const fallbackStatus: AgentDirectiveStatus = success ? "done" : "failed";
|
|
84
|
+
const resultFile = join(workspacePath, "symphifo-result.json");
|
|
85
|
+
let resultPayload: JsonRecord = {};
|
|
86
|
+
|
|
87
|
+
if (existsSync(resultFile)) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(readFileSync(resultFile, "utf8")) as unknown;
|
|
90
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
91
|
+
resultPayload = parsed as JsonRecord;
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logger.warn(`Invalid symphifo-result.json in ${workspacePath}: ${String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const status = normalizeAgentDirectiveStatus(
|
|
99
|
+
resultPayload.status ?? extractOutputMarker(output, "SYMPHIFO_STATUS"),
|
|
100
|
+
fallbackStatus,
|
|
101
|
+
);
|
|
102
|
+
const summary =
|
|
103
|
+
toStringValue(resultPayload.summary)
|
|
104
|
+
|| toStringValue(resultPayload.message)
|
|
105
|
+
|| extractOutputMarker(output, "SYMPHIFO_SUMMARY");
|
|
106
|
+
const nextPrompt =
|
|
107
|
+
toStringValue(resultPayload.nextPrompt)
|
|
108
|
+
|| toStringValue(resultPayload.next_prompt)
|
|
109
|
+
|| "";
|
|
110
|
+
|
|
111
|
+
return { status, summary, nextPrompt };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function canRunIssue(issue: IssueEntry, running: Set<string>, state: RuntimeState): boolean {
|
|
115
|
+
const TERMINAL = new Set(["Done", "Cancelled"]);
|
|
116
|
+
if (!issue.assignedToWorker) return false;
|
|
117
|
+
if (running.has(issue.id)) return false;
|
|
118
|
+
if (TERMINAL.has(issue.state)) return false;
|
|
119
|
+
|
|
120
|
+
if (issue.state === "Blocked") {
|
|
121
|
+
if (!issue.nextRetryAt) return false;
|
|
122
|
+
if (issue.attempts >= issue.maxAttempts) return false;
|
|
123
|
+
if (Date.parse(issue.nextRetryAt) > Date.now()) return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!issueDepsResolved(issue, state.issues)) return false;
|
|
127
|
+
|
|
128
|
+
if (issue.state === "Todo" || issue.state === "Blocked") return true;
|
|
129
|
+
if (issue.state === "In Progress" && issueHasResumableSession(issue)) return true;
|
|
130
|
+
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function issueDepsResolved(issue: IssueEntry, allIssues: IssueEntry[]): boolean {
|
|
135
|
+
if (issue.blockedBy.length === 0) return true;
|
|
136
|
+
const map = new Map(allIssues.map((entry) => [entry.id, entry]));
|
|
137
|
+
return issue.blockedBy.every((depId) => {
|
|
138
|
+
const dep = map.get(depId);
|
|
139
|
+
return dep?.state === "Done";
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shouldSkipRoutingPath(relativePath: string): boolean {
|
|
144
|
+
const parts = relativePath.split("/");
|
|
145
|
+
if (parts.some((segment) => segment === ".git" || segment === "node_modules" || segment === ".symphifo")) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
const base = parts.at(-1) ?? "";
|
|
149
|
+
return base === "symphifo-issue.json"
|
|
150
|
+
|| base === "symphifo-prompt.md"
|
|
151
|
+
|| base === "symphifo-result.json"
|
|
152
|
+
|| base === "WORKFLOW.local.md"
|
|
153
|
+
|| base.startsWith("symphifo-result-")
|
|
154
|
+
|| base.startsWith("symphifo-turn-")
|
|
155
|
+
|| base.startsWith("symphifo-session")
|
|
156
|
+
|| base.startsWith("symphifo-pipeline");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function inferChangedWorkspacePaths(workspacePath: string, limit = 32): string[] {
|
|
160
|
+
if (!workspacePath || !existsSync(workspacePath) || !existsSync(SOURCE_ROOT)) return [];
|
|
161
|
+
|
|
162
|
+
const changed = new Set<string>();
|
|
163
|
+
|
|
164
|
+
const walk = (currentRoot: string, relativeRoot = ""): void => {
|
|
165
|
+
if (changed.size >= limit) return;
|
|
166
|
+
for (const item of readdirSync(currentRoot, { withFileTypes: true })) {
|
|
167
|
+
if (changed.size >= limit) return;
|
|
168
|
+
const nextRelative = relativeRoot ? `${relativeRoot}/${item.name}` : item.name;
|
|
169
|
+
if (shouldSkipRoutingPath(nextRelative)) continue;
|
|
170
|
+
const currentPath = join(currentRoot, item.name);
|
|
171
|
+
if (item.isDirectory()) { walk(currentPath, nextRelative); continue; }
|
|
172
|
+
if (!item.isFile()) continue;
|
|
173
|
+
const sourcePath = join(SOURCE_ROOT, nextRelative);
|
|
174
|
+
if (!existsSync(sourcePath)) { changed.add(nextRelative); continue; }
|
|
175
|
+
const currentStat = statSync(currentPath);
|
|
176
|
+
const sourceStat = statSync(sourcePath);
|
|
177
|
+
if (currentStat.size !== sourceStat.size) { changed.add(nextRelative); continue; }
|
|
178
|
+
const currentFile = readFileSync(currentPath);
|
|
179
|
+
const sourceFile = readFileSync(sourcePath);
|
|
180
|
+
if (!currentFile.equals(sourceFile)) changed.add(nextRelative);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
walk(workspacePath);
|
|
185
|
+
return [...changed];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function hydrateIssuePathsFromWorkspace(issue: IssueEntry): string[] {
|
|
189
|
+
const inferredPaths = inferChangedWorkspacePaths(issue.workspacePath ?? "");
|
|
190
|
+
if (inferredPaths.length === 0) return [];
|
|
191
|
+
issue.paths = [...new Set([...(issue.paths ?? []), ...inferredPaths])];
|
|
192
|
+
issue.inferredPaths = [...new Set([...(issue.inferredPaths ?? []), ...inferredPaths])];
|
|
193
|
+
return inferredPaths;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function describeRoutingSignals(issue: IssueEntry, workspaceDerivedPaths: string[]): string {
|
|
197
|
+
const explicitPaths = issue.paths ?? [];
|
|
198
|
+
const textDerivedPaths = inferCapabilityPaths({
|
|
199
|
+
id: issue.id,
|
|
200
|
+
identifier: issue.identifier,
|
|
201
|
+
title: issue.title,
|
|
202
|
+
description: issue.description,
|
|
203
|
+
labels: issue.labels,
|
|
204
|
+
}).filter((path) => !explicitPaths.includes(path));
|
|
205
|
+
|
|
206
|
+
const parts: string[] = [];
|
|
207
|
+
if (explicitPaths.length > 0) parts.push(`payload paths=${explicitPaths.join(", ")}`);
|
|
208
|
+
if (textDerivedPaths.length > 0) parts.push(`text hints=${textDerivedPaths.join(", ")}`);
|
|
209
|
+
if (workspaceDerivedPaths.length > 0) parts.push(`workspace diff=${workspaceDerivedPaths.join(", ")}`);
|
|
210
|
+
return parts.join(" | ");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildAgentSessionState(
|
|
214
|
+
issue: IssueEntry,
|
|
215
|
+
attempt: number,
|
|
216
|
+
maxTurns: number,
|
|
217
|
+
): AgentSessionState {
|
|
218
|
+
const createdAt = now();
|
|
219
|
+
return {
|
|
220
|
+
issueId: issue.id,
|
|
221
|
+
issueIdentifier: issue.identifier,
|
|
222
|
+
attempt,
|
|
223
|
+
status: "running",
|
|
224
|
+
startedAt: createdAt,
|
|
225
|
+
updatedAt: createdAt,
|
|
226
|
+
maxTurns,
|
|
227
|
+
turns: [],
|
|
228
|
+
lastPrompt: "",
|
|
229
|
+
lastPromptFile: "",
|
|
230
|
+
lastOutput: "",
|
|
231
|
+
lastCode: null,
|
|
232
|
+
lastDirectiveStatus: "continue",
|
|
233
|
+
lastDirectiveSummary: "",
|
|
234
|
+
nextPrompt: "",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function loadAgentSessionState(
|
|
239
|
+
sessionKey: string,
|
|
240
|
+
issue: IssueEntry,
|
|
241
|
+
attempt: number,
|
|
242
|
+
maxTurns: number,
|
|
243
|
+
): Promise<{ session: AgentSessionState; key: string }> {
|
|
244
|
+
const agentSessionResource = getAgentSessionResource();
|
|
245
|
+
if (agentSessionResource) {
|
|
246
|
+
try {
|
|
247
|
+
const record = await agentSessionResource.get(sessionKey) as AgentSessionRecord;
|
|
248
|
+
if (
|
|
249
|
+
record?.session
|
|
250
|
+
&& record.issueId === issue.id
|
|
251
|
+
&& record.attempt === attempt
|
|
252
|
+
&& Array.isArray(record.session.turns)
|
|
253
|
+
) {
|
|
254
|
+
return {
|
|
255
|
+
session: {
|
|
256
|
+
...buildAgentSessionState(issue, attempt, maxTurns),
|
|
257
|
+
...record.session,
|
|
258
|
+
maxTurns,
|
|
259
|
+
turns: record.session.turns as AgentSessionTurn[],
|
|
260
|
+
updatedAt: now(),
|
|
261
|
+
},
|
|
262
|
+
key: sessionKey,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (!isStateNotFoundError(error)) {
|
|
267
|
+
logger.warn(`Failed to load session state for ${issue.id}: ${String(error)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { session: buildAgentSessionState(issue, attempt, maxTurns), key: sessionKey };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function persistAgentSessionState(
|
|
276
|
+
key: string,
|
|
277
|
+
issue: IssueEntry,
|
|
278
|
+
provider: AgentProviderDefinition,
|
|
279
|
+
cycle: number,
|
|
280
|
+
session: AgentSessionState,
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
session.updatedAt = now();
|
|
283
|
+
const agentSessionResource = getAgentSessionResource();
|
|
284
|
+
if (!agentSessionResource) return;
|
|
285
|
+
|
|
286
|
+
await agentSessionResource.replace(key, {
|
|
287
|
+
id: key,
|
|
288
|
+
issueId: issue.id,
|
|
289
|
+
issueIdentifier: issue.identifier,
|
|
290
|
+
attempt: session.attempt,
|
|
291
|
+
cycle,
|
|
292
|
+
provider: provider.provider,
|
|
293
|
+
role: provider.role,
|
|
294
|
+
updatedAt: session.updatedAt,
|
|
295
|
+
session,
|
|
296
|
+
} satisfies AgentSessionRecord);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function issueHasResumableSession(issue: IssueEntry): boolean {
|
|
300
|
+
return Boolean(issue.workspacePath) && issue.state === "In Progress";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildProviderSessionKey(issue: IssueEntry, attempt: number, provider: AgentProviderDefinition, cycle: number): string {
|
|
304
|
+
return `${idToSafePath(issue.id)}-a${attempt}-${provider.role}-${provider.provider}-c${cycle}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildPipelineKey(issue: IssueEntry, attempt: number): string {
|
|
308
|
+
return `${idToSafePath(issue.id)}-a${attempt}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function getLatestPipelineAttempt(issue: IssueEntry): number {
|
|
312
|
+
if (issue.state === "Blocked" || issue.state === "Cancelled") {
|
|
313
|
+
return Math.max(1, issue.attempts);
|
|
314
|
+
}
|
|
315
|
+
return Math.max(1, issue.attempts + 1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function stateConfigMaxTurnsFallback(workflowDefinition: WorkflowDefinition | null): number {
|
|
319
|
+
if (!workflowDefinition) return 4;
|
|
320
|
+
return clamp(getNestedNumber(getNestedRecord(workflowDefinition.config, "agent"), "max_turns", 4), 1, 16);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function loadAgentPipelineState(
|
|
324
|
+
issue: IssueEntry,
|
|
325
|
+
attempt: number,
|
|
326
|
+
providers: AgentProviderDefinition[],
|
|
327
|
+
): Promise<{ pipeline: AgentPipelineState; key: string }> {
|
|
328
|
+
const pipelineKey = buildPipelineKey(issue, attempt);
|
|
329
|
+
const agentPipelineResource = getAgentPipelineResource();
|
|
330
|
+
|
|
331
|
+
if (agentPipelineResource) {
|
|
332
|
+
try {
|
|
333
|
+
const record = await agentPipelineResource.get(pipelineKey) as AgentPipelineRecord;
|
|
334
|
+
if (record?.pipeline && record.issueId === issue.id && record.attempt === attempt) {
|
|
335
|
+
return {
|
|
336
|
+
pipeline: {
|
|
337
|
+
issueId: issue.id,
|
|
338
|
+
issueIdentifier: issue.identifier,
|
|
339
|
+
attempt,
|
|
340
|
+
cycle: Math.max(1, toNumberValue(record.pipeline.cycle, 1)),
|
|
341
|
+
activeIndex: clamp(toNumberValue(record.pipeline.activeIndex, 0), 0, Math.max(0, providers.length - 1)),
|
|
342
|
+
updatedAt: now(),
|
|
343
|
+
history: Array.isArray(record.pipeline.history)
|
|
344
|
+
? record.pipeline.history.filter((entry): entry is string => typeof entry === "string")
|
|
345
|
+
: [],
|
|
346
|
+
},
|
|
347
|
+
key: pipelineKey,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (!isStateNotFoundError(error)) {
|
|
352
|
+
logger.warn(`Failed to load pipeline state for ${issue.id}: ${String(error)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
pipeline: {
|
|
359
|
+
issueId: issue.id,
|
|
360
|
+
issueIdentifier: issue.identifier,
|
|
361
|
+
attempt,
|
|
362
|
+
cycle: 1,
|
|
363
|
+
activeIndex: 0,
|
|
364
|
+
updatedAt: now(),
|
|
365
|
+
history: [],
|
|
366
|
+
},
|
|
367
|
+
key: pipelineKey,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function persistAgentPipelineState(key: string, pipeline: AgentPipelineState): Promise<void> {
|
|
372
|
+
pipeline.updatedAt = now();
|
|
373
|
+
const agentPipelineResource = getAgentPipelineResource();
|
|
374
|
+
if (!agentPipelineResource) return;
|
|
375
|
+
|
|
376
|
+
await agentPipelineResource.replace(key, {
|
|
377
|
+
id: key,
|
|
378
|
+
issueId: pipeline.issueId,
|
|
379
|
+
issueIdentifier: pipeline.issueIdentifier,
|
|
380
|
+
attempt: pipeline.attempt,
|
|
381
|
+
updatedAt: pipeline.updatedAt,
|
|
382
|
+
pipeline,
|
|
383
|
+
} satisfies AgentPipelineRecord);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function loadAgentPipelineSnapshotForIssue(
|
|
387
|
+
issue: IssueEntry,
|
|
388
|
+
providers: AgentProviderDefinition[],
|
|
389
|
+
): Promise<AgentPipelineState | null> {
|
|
390
|
+
const attempt = getLatestPipelineAttempt(issue);
|
|
391
|
+
const agentPipelineResource = getAgentPipelineResource();
|
|
392
|
+
|
|
393
|
+
if (agentPipelineResource?.list) {
|
|
394
|
+
try {
|
|
395
|
+
const records = await agentPipelineResource.list({
|
|
396
|
+
partition: "byIssueAttempt",
|
|
397
|
+
partitionValues: { issueId: issue.id, attempt },
|
|
398
|
+
limit: 10,
|
|
399
|
+
});
|
|
400
|
+
const record = records
|
|
401
|
+
.map((entry) => entry as AgentPipelineRecord)
|
|
402
|
+
.find((entry) => entry.issueId === issue.id && entry.attempt === attempt && entry.pipeline);
|
|
403
|
+
if (record?.pipeline) {
|
|
404
|
+
return {
|
|
405
|
+
issueId: issue.id,
|
|
406
|
+
issueIdentifier: issue.identifier,
|
|
407
|
+
attempt,
|
|
408
|
+
cycle: Math.max(1, toNumberValue(record.pipeline.cycle, 1)),
|
|
409
|
+
activeIndex: clamp(toNumberValue(record.pipeline.activeIndex, 0), 0, Math.max(0, providers.length - 1)),
|
|
410
|
+
updatedAt: now(),
|
|
411
|
+
history: Array.isArray(record.pipeline.history)
|
|
412
|
+
? record.pipeline.history.filter((entry): entry is string => typeof entry === "string")
|
|
413
|
+
: [],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
logger.warn(`Failed to load partitioned pipeline snapshot for ${issue.id}: ${String(error)}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const loaded = await loadAgentPipelineState(issue, attempt, providers);
|
|
422
|
+
return loaded.pipeline.history.length > 0 ? loaded.pipeline : null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function loadAgentSessionSnapshotsForIssue(
|
|
426
|
+
issue: IssueEntry,
|
|
427
|
+
providers: AgentProviderDefinition[],
|
|
428
|
+
pipeline: AgentPipelineState | null,
|
|
429
|
+
workflowDefinition: WorkflowDefinition | null,
|
|
430
|
+
): Promise<Array<{ key: string; session: AgentSessionState; provider: string; role: string; cycle: number }>> {
|
|
431
|
+
if (!pipeline) return [];
|
|
432
|
+
|
|
433
|
+
const sessions: Array<{ key: string; session: AgentSessionState; provider: string; role: string; cycle: number }> = [];
|
|
434
|
+
const attempt = pipeline.attempt;
|
|
435
|
+
const agentSessionResource = getAgentSessionResource();
|
|
436
|
+
const maxTurns = stateConfigMaxTurnsFallback(workflowDefinition);
|
|
437
|
+
|
|
438
|
+
if (agentSessionResource?.list) {
|
|
439
|
+
try {
|
|
440
|
+
const records = await agentSessionResource.list({
|
|
441
|
+
partition: "byIssueAttempt",
|
|
442
|
+
partitionValues: { issueId: issue.id, attempt },
|
|
443
|
+
limit: Math.max(12, providers.length * Math.max(1, pipeline.cycle) * 2),
|
|
444
|
+
});
|
|
445
|
+
const loadedSessions = records
|
|
446
|
+
.map((entry) => entry as AgentSessionRecord)
|
|
447
|
+
.filter((entry) => entry.issueId === issue.id && entry.attempt === attempt && entry.session && Array.isArray(entry.session.turns));
|
|
448
|
+
|
|
449
|
+
for (const record of loadedSessions) {
|
|
450
|
+
if (!record.session.turns.length) continue;
|
|
451
|
+
sessions.push({
|
|
452
|
+
key: record.id,
|
|
453
|
+
session: {
|
|
454
|
+
...buildAgentSessionState(issue, attempt, maxTurns),
|
|
455
|
+
...record.session,
|
|
456
|
+
maxTurns,
|
|
457
|
+
turns: record.session.turns as AgentSessionTurn[],
|
|
458
|
+
updatedAt: now(),
|
|
459
|
+
},
|
|
460
|
+
provider: record.provider,
|
|
461
|
+
role: record.role,
|
|
462
|
+
cycle: record.cycle,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
sessions.sort((a, b) => a.cycle !== b.cycle ? a.cycle - b.cycle : a.key.localeCompare(b.key));
|
|
467
|
+
if (sessions.length > 0) return sessions;
|
|
468
|
+
} catch (error) {
|
|
469
|
+
logger.warn(`Failed to load partitioned session snapshots for ${issue.id}: ${String(error)}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
for (let cycle = 1; cycle <= pipeline.cycle; cycle += 1) {
|
|
474
|
+
for (const provider of providers) {
|
|
475
|
+
const key = buildProviderSessionKey(issue, attempt, provider, cycle);
|
|
476
|
+
const loaded = await loadAgentSessionState(key, issue, attempt, maxTurns);
|
|
477
|
+
if (loaded.session.turns.length === 0) continue;
|
|
478
|
+
sessions.push({
|
|
479
|
+
key,
|
|
480
|
+
session: loaded.session,
|
|
481
|
+
provider: provider.provider,
|
|
482
|
+
role: provider.role,
|
|
483
|
+
cycle,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return sessions;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function buildPrompt(issue: IssueEntry, workflowDefinition: WorkflowDefinition | null): string {
|
|
492
|
+
const template = workflowDefinition?.promptTemplate.trim() || [
|
|
493
|
+
"You are working on {{ issue.identifier }}.",
|
|
494
|
+
"",
|
|
495
|
+
"Title: {{ issue.title }}",
|
|
496
|
+
"Description:",
|
|
497
|
+
"{{ issue.description }}",
|
|
498
|
+
].join("\n");
|
|
499
|
+
|
|
500
|
+
return template.replace(/{{\s*issue\.([a-zA-Z0-9_]+)\s*}}/g, (_match, key: string) => {
|
|
501
|
+
const value = issue[key as keyof IssueEntry];
|
|
502
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
503
|
+
return value == null ? "" : String(value);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function buildTurnPrompt(
|
|
508
|
+
issue: IssueEntry,
|
|
509
|
+
basePrompt: string,
|
|
510
|
+
previousOutput: string,
|
|
511
|
+
turnIndex: number,
|
|
512
|
+
maxTurns: number,
|
|
513
|
+
nextPrompt: string,
|
|
514
|
+
): string {
|
|
515
|
+
if (turnIndex === 1) return basePrompt;
|
|
516
|
+
|
|
517
|
+
const outputTail = previousOutput.trim() || "No previous output captured.";
|
|
518
|
+
const continuation = nextPrompt.trim() || "Continue the work, inspect the workspace, and move the issue toward completion.";
|
|
519
|
+
|
|
520
|
+
return [
|
|
521
|
+
`Continue working on ${issue.identifier}.`,
|
|
522
|
+
`Turn ${turnIndex} of ${maxTurns}.`,
|
|
523
|
+
"",
|
|
524
|
+
"Base objective:",
|
|
525
|
+
basePrompt,
|
|
526
|
+
"",
|
|
527
|
+
"Continuation guidance:",
|
|
528
|
+
continuation,
|
|
529
|
+
"",
|
|
530
|
+
"Previous command output tail:",
|
|
531
|
+
"```text",
|
|
532
|
+
outputTail,
|
|
533
|
+
"```",
|
|
534
|
+
"",
|
|
535
|
+
"Before exiting successfully, emit one of the following control markers:",
|
|
536
|
+
"- `SYMPHIFO_STATUS=continue` if more turns are required.",
|
|
537
|
+
"- `SYMPHIFO_STATUS=done` if the issue is complete.",
|
|
538
|
+
"- `SYMPHIFO_STATUS=blocked` if manual intervention is required.",
|
|
539
|
+
'You may also write `symphifo-result.json` with `{ "status": "...", "summary": "...", "nextPrompt": "..." }`.',
|
|
540
|
+
].join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function buildProviderBasePrompt(
|
|
544
|
+
provider: AgentProviderDefinition,
|
|
545
|
+
issue: IssueEntry,
|
|
546
|
+
basePrompt: string,
|
|
547
|
+
workspacePath: string,
|
|
548
|
+
skillContext: string,
|
|
549
|
+
): string {
|
|
550
|
+
const roleInstructions = provider.role === "planner"
|
|
551
|
+
? [
|
|
552
|
+
"Role: planner.",
|
|
553
|
+
"Analyze the issue and prepare an execution plan for the implementation agents.",
|
|
554
|
+
"Do not claim the issue is complete unless the plan itself is the deliverable.",
|
|
555
|
+
]
|
|
556
|
+
: provider.role === "reviewer"
|
|
557
|
+
? [
|
|
558
|
+
"Role: reviewer.",
|
|
559
|
+
"Inspect the workspace and review the current implementation critically.",
|
|
560
|
+
"If rework is required, emit `SYMPHIFO_STATUS=continue` and provide actionable `nextPrompt` feedback.",
|
|
561
|
+
"Emit `SYMPHIFO_STATUS=done` only when the work is acceptable.",
|
|
562
|
+
]
|
|
563
|
+
: [
|
|
564
|
+
"Role: executor.",
|
|
565
|
+
"Implement the required changes in the workspace.",
|
|
566
|
+
"Use any planner guidance or prior reviewer feedback already persisted in the workspace.",
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
const overlayInstructions = provider.overlays?.includes("impeccable")
|
|
570
|
+
? [
|
|
571
|
+
"Impeccable overlay is active.",
|
|
572
|
+
"Raise the bar on UI polish, clarity, responsiveness, visual hierarchy, and interaction quality.",
|
|
573
|
+
provider.role === "reviewer"
|
|
574
|
+
? "Review with a stricter frontend and product-quality standard than a normal correctness-only pass."
|
|
575
|
+
: "When touching frontend work, do not settle for baseline implementation quality.",
|
|
576
|
+
]
|
|
577
|
+
: provider.overlays?.includes("frontend-design")
|
|
578
|
+
? [
|
|
579
|
+
"Frontend-design overlay is active.",
|
|
580
|
+
"Prefer stronger hierarchy, spacing, and readability decisions over generic implementation choices.",
|
|
581
|
+
]
|
|
582
|
+
: [];
|
|
583
|
+
|
|
584
|
+
const sections = [
|
|
585
|
+
...roleInstructions,
|
|
586
|
+
...overlayInstructions,
|
|
587
|
+
...(provider.profileInstructions
|
|
588
|
+
? ["", "## Agent Profile", provider.profileInstructions]
|
|
589
|
+
: []),
|
|
590
|
+
...(skillContext ? ["", skillContext] : []),
|
|
591
|
+
...(provider.capabilityCategory
|
|
592
|
+
? [
|
|
593
|
+
"",
|
|
594
|
+
`Capability routing: ${provider.capabilityCategory}.`,
|
|
595
|
+
`Selection reason: ${provider.selectionReason ?? "No additional routing reason."}`,
|
|
596
|
+
...(provider.overlays?.length ? [`Overlays: ${provider.overlays.join(", ")}.`] : []),
|
|
597
|
+
]
|
|
598
|
+
: []),
|
|
599
|
+
...(issue.paths?.length
|
|
600
|
+
? ["", `Target paths: ${issue.paths.join(", ")}`]
|
|
601
|
+
: []),
|
|
602
|
+
"",
|
|
603
|
+
`Workspace: ${workspacePath}`,
|
|
604
|
+
"",
|
|
605
|
+
basePrompt,
|
|
606
|
+
];
|
|
607
|
+
|
|
608
|
+
return sections.join("\n");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function runCommandWithTimeout(
|
|
612
|
+
command: string,
|
|
613
|
+
workspacePath: string,
|
|
614
|
+
issue: IssueEntry,
|
|
615
|
+
config: RuntimeConfig,
|
|
616
|
+
promptText: string,
|
|
617
|
+
promptFile: string,
|
|
618
|
+
extraEnv: Record<string, string> = {},
|
|
619
|
+
): Promise<{ success: boolean; code: number | null; output: string }> {
|
|
620
|
+
return new Promise((resolve) => {
|
|
621
|
+
const started = Date.now();
|
|
622
|
+
const resultFile = extraEnv.SYMPHIFO_RESULT_FILE;
|
|
623
|
+
if (resultFile && extraEnv.SYMPHIFO_PRESERVE_RESULT_FILE !== "1") {
|
|
624
|
+
rmSync(resultFile, { force: true });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const child = spawn(command, {
|
|
628
|
+
shell: true,
|
|
629
|
+
cwd: workspacePath,
|
|
630
|
+
env: {
|
|
631
|
+
...env,
|
|
632
|
+
SYMPHIFO_ISSUE_ID: issue.id,
|
|
633
|
+
SYMPHIFO_ISSUE_IDENTIFIER: issue.identifier,
|
|
634
|
+
SYMPHIFO_ISSUE_TITLE: issue.title,
|
|
635
|
+
SYMPHIFO_ISSUE_PRIORITY: String(issue.priority),
|
|
636
|
+
SYMPHIFO_WORKSPACE_PATH: workspacePath,
|
|
637
|
+
SYMPHIFO_ISSUE_JSON: JSON.stringify(issue),
|
|
638
|
+
SYMPHIFO_PROMPT: promptText,
|
|
639
|
+
SYMPHIFO_PROMPT_FILE: promptFile,
|
|
640
|
+
...extraEnv,
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
let output = "";
|
|
645
|
+
let timedOut = false;
|
|
646
|
+
|
|
647
|
+
child.stdout?.on("data", (chunk) => {
|
|
648
|
+
output = appendFileTail(output, String(chunk), config.logLinesTail);
|
|
649
|
+
});
|
|
650
|
+
child.stderr?.on("data", (chunk) => {
|
|
651
|
+
output = appendFileTail(output, String(chunk), config.logLinesTail);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, config.commandTimeoutMs);
|
|
655
|
+
|
|
656
|
+
child.on("error", () => {
|
|
657
|
+
clearTimeout(timer);
|
|
658
|
+
resolve({ success: false, code: null, output: `Command execution failed for issue ${issue.id}.` });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
child.on("close", (code) => {
|
|
662
|
+
clearTimeout(timer);
|
|
663
|
+
if (timedOut) {
|
|
664
|
+
resolve({ success: false, code: null, output: appendFileTail(output, `\nExecution timeout after ${config.commandTimeoutMs}ms.`, config.logLinesTail) });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const duration = Math.max(0, Date.now() - started);
|
|
668
|
+
if (code === 0) {
|
|
669
|
+
resolve({ success: true, code, output: appendFileTail(output, `\nExecution succeeded in ${duration}ms.`, config.logLinesTail) });
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
resolve({ success: false, code, output: appendFileTail(output, `\nCommand exit code ${code ?? "unknown"} after ${duration}ms.`, config.logLinesTail) });
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function runHook(
|
|
678
|
+
command: string,
|
|
679
|
+
workspacePath: string,
|
|
680
|
+
issue: IssueEntry,
|
|
681
|
+
hookName: string,
|
|
682
|
+
extraEnv: Record<string, string> = {},
|
|
683
|
+
): Promise<void> {
|
|
684
|
+
if (!command.trim()) return;
|
|
685
|
+
|
|
686
|
+
const result = await runCommandWithTimeout(command, workspacePath, issue, {
|
|
687
|
+
pollIntervalMs: 0,
|
|
688
|
+
workerConcurrency: 1,
|
|
689
|
+
commandTimeoutMs: 300_000,
|
|
690
|
+
maxAttemptsDefault: 1,
|
|
691
|
+
retryDelayMs: 0,
|
|
692
|
+
staleInProgressTimeoutMs: 0,
|
|
693
|
+
logLinesTail: 12_000,
|
|
694
|
+
agentProvider: normalizeAgentProvider(env.SYMPHIFO_AGENT_PROVIDER ?? "codex"),
|
|
695
|
+
agentCommand: command,
|
|
696
|
+
maxTurns: 1,
|
|
697
|
+
runMode: "filesystem",
|
|
698
|
+
}, "", "", { SYMPHIFO_HOOK_NAME: hookName, ...extraEnv });
|
|
699
|
+
|
|
700
|
+
if (!result.success) {
|
|
701
|
+
throw new Error(`${hookName} hook failed: ${result.output}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function prepareWorkspace(
|
|
706
|
+
issue: IssueEntry,
|
|
707
|
+
workflowDefinition: WorkflowDefinition | null,
|
|
708
|
+
): Promise<{ workspacePath: string; promptText: string; promptFile: string }> {
|
|
709
|
+
const safeId = idToSafePath(issue.id);
|
|
710
|
+
const workspaceRoot = join(WORKSPACE_ROOT, safeId);
|
|
711
|
+
const createdNow = !existsSync(workspaceRoot);
|
|
712
|
+
|
|
713
|
+
if (createdNow) {
|
|
714
|
+
mkdirSync(workspaceRoot, { recursive: true });
|
|
715
|
+
if (workflowDefinition?.afterCreateHook) {
|
|
716
|
+
await runHook(workflowDefinition.afterCreateHook, workspaceRoot, issue, "after_create");
|
|
717
|
+
} else {
|
|
718
|
+
cpSync(SOURCE_ROOT, workspaceRoot, {
|
|
719
|
+
recursive: true,
|
|
720
|
+
force: true,
|
|
721
|
+
filter: (sourcePath) => !sourcePath.startsWith(WORKSPACE_ROOT),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const metaPath = join(workspaceRoot, "symphifo-issue.json");
|
|
727
|
+
const promptText = buildPrompt(issue, workflowDefinition);
|
|
728
|
+
const promptFile = join(workspaceRoot, "symphifo-prompt.md");
|
|
729
|
+
writeFileSync(metaPath, JSON.stringify({ ...issue, runtimeSource: SOURCE_ROOT, bootstrapAt: now() }, null, 2), "utf8");
|
|
730
|
+
writeFileSync(promptFile, `${promptText}\n`, "utf8");
|
|
731
|
+
|
|
732
|
+
issue.workspacePath = workspaceRoot;
|
|
733
|
+
issue.workspacePreparedAt = now();
|
|
734
|
+
|
|
735
|
+
return { workspacePath: workspaceRoot, promptText, promptFile };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function runAgentSession(
|
|
739
|
+
state: RuntimeState,
|
|
740
|
+
issue: IssueEntry,
|
|
741
|
+
provider: AgentProviderDefinition,
|
|
742
|
+
cycle: number,
|
|
743
|
+
workspacePath: string,
|
|
744
|
+
basePromptText: string,
|
|
745
|
+
basePromptFile: string,
|
|
746
|
+
): Promise<AgentSessionResult> {
|
|
747
|
+
const maxTurns = clamp(state.config.maxTurns, 1, 16);
|
|
748
|
+
const attempt = issue.attempts + 1;
|
|
749
|
+
const sessionLookupKey = buildProviderSessionKey(issue, attempt, provider, cycle);
|
|
750
|
+
const loadedSession = await loadAgentSessionState(sessionLookupKey, issue, attempt, maxTurns);
|
|
751
|
+
const sessionKey = loadedSession.key;
|
|
752
|
+
const session = loadedSession.session;
|
|
753
|
+
let previousOutput = session.lastOutput;
|
|
754
|
+
let nextPrompt = session.nextPrompt;
|
|
755
|
+
let lastCode: number | null = session.lastCode;
|
|
756
|
+
let lastOutput = session.lastOutput;
|
|
757
|
+
const resultFile = join(workspacePath, `symphifo-result-${provider.role}-${provider.provider}.json`);
|
|
758
|
+
|
|
759
|
+
if (session.status === "done" && session.turns.length > 0) {
|
|
760
|
+
return { success: true, blocked: false, continueRequested: false, code: session.lastCode, output: session.lastOutput, turns: session.turns.length };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const turnIndex = session.turns.length + 1;
|
|
764
|
+
if (turnIndex > maxTurns) {
|
|
765
|
+
session.status = "blocked";
|
|
766
|
+
session.lastOutput = appendFileTail(lastOutput, `\nAgent requested additional turns beyond configured limit (${maxTurns}).`, state.config.logLinesTail);
|
|
767
|
+
await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
|
|
768
|
+
return { success: false, blocked: true, continueRequested: false, code: lastCode, output: session.lastOutput, turns: session.turns.length };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const turnPrompt = buildTurnPrompt(issue, basePromptText, previousOutput, turnIndex, maxTurns, nextPrompt);
|
|
772
|
+
const turnPromptFile = turnIndex === 1
|
|
773
|
+
? basePromptFile
|
|
774
|
+
: join(workspacePath, `symphifo-turn-${String(turnIndex).padStart(2, "0")}.md`);
|
|
775
|
+
|
|
776
|
+
if (turnIndex > 1) writeFileSync(turnPromptFile, `${turnPrompt}\n`, "utf8");
|
|
777
|
+
|
|
778
|
+
session.status = "running";
|
|
779
|
+
session.lastPrompt = turnPrompt;
|
|
780
|
+
session.lastPromptFile = turnPromptFile;
|
|
781
|
+
session.maxTurns = maxTurns;
|
|
782
|
+
await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
|
|
783
|
+
|
|
784
|
+
const turnStartedAt = now();
|
|
785
|
+
const turnEnv = {
|
|
786
|
+
SYMPHIFO_AGENT_PROVIDER: provider.provider,
|
|
787
|
+
SYMPHIFO_AGENT_ROLE: provider.role,
|
|
788
|
+
SYMPHIFO_SESSION_KEY: sessionKey,
|
|
789
|
+
SYMPHIFO_SESSION_ID: `${issue.id}-attempt-${attempt}`,
|
|
790
|
+
SYMPHIFO_TURN_INDEX: String(turnIndex),
|
|
791
|
+
SYMPHIFO_MAX_TURNS: String(maxTurns),
|
|
792
|
+
SYMPHIFO_TURN_PROMPT: turnPrompt,
|
|
793
|
+
SYMPHIFO_TURN_PROMPT_FILE: turnPromptFile,
|
|
794
|
+
SYMPHIFO_CONTINUE: turnIndex > 1 ? "1" : "0",
|
|
795
|
+
SYMPHIFO_PREVIOUS_OUTPUT: previousOutput,
|
|
796
|
+
SYMPHIFO_RESULT_FILE: resultFile,
|
|
797
|
+
SYMPHIFO_AGENT_PROFILE: provider.profile,
|
|
798
|
+
SYMPHIFO_AGENT_PROFILE_FILE: provider.profilePath,
|
|
799
|
+
SYMPHIFO_AGENT_PROFILE_INSTRUCTIONS: provider.profileInstructions,
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const workflowDefinition = state._workflowDefinition as WorkflowDefinition | null | undefined;
|
|
803
|
+
if (workflowDefinition?.beforeRunHook) {
|
|
804
|
+
await runHook(workflowDefinition.beforeRunHook, workspacePath, issue, "before_run", turnEnv);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
addEvent(state, issue.id, "runner", `Turn ${turnIndex}/${maxTurns} started for ${issue.identifier}.`);
|
|
808
|
+
|
|
809
|
+
const turnResult = await runCommandWithTimeout(provider.command, workspacePath, issue, state.config, turnPrompt, turnPromptFile, turnEnv);
|
|
810
|
+
|
|
811
|
+
if (workflowDefinition?.afterRunHook) {
|
|
812
|
+
await runHook(workflowDefinition.afterRunHook, workspacePath, issue, "after_run", {
|
|
813
|
+
...turnEnv,
|
|
814
|
+
SYMPHIFO_LAST_EXIT_CODE: String(turnResult.code ?? ""),
|
|
815
|
+
SYMPHIFO_LAST_OUTPUT: turnResult.output,
|
|
816
|
+
SYMPHIFO_PRESERVE_RESULT_FILE: "1",
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const directive = readAgentDirective(workspacePath, turnResult.output, turnResult.success);
|
|
821
|
+
lastCode = turnResult.code;
|
|
822
|
+
lastOutput = turnResult.output;
|
|
823
|
+
previousOutput = turnResult.output;
|
|
824
|
+
nextPrompt = directive.nextPrompt;
|
|
825
|
+
|
|
826
|
+
session.turns.push({
|
|
827
|
+
turn: turnIndex,
|
|
828
|
+
startedAt: turnStartedAt,
|
|
829
|
+
completedAt: now(),
|
|
830
|
+
promptFile: turnPromptFile,
|
|
831
|
+
prompt: turnPrompt,
|
|
832
|
+
output: turnResult.output,
|
|
833
|
+
code: turnResult.code,
|
|
834
|
+
success: turnResult.success,
|
|
835
|
+
directiveStatus: directive.status,
|
|
836
|
+
directiveSummary: directive.summary,
|
|
837
|
+
nextPrompt: directive.nextPrompt,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
session.lastCode = lastCode;
|
|
841
|
+
session.lastOutput = lastOutput;
|
|
842
|
+
session.lastDirectiveStatus = directive.status;
|
|
843
|
+
session.lastDirectiveSummary = directive.summary;
|
|
844
|
+
session.nextPrompt = nextPrompt;
|
|
845
|
+
|
|
846
|
+
const directiveSummary = directive.summary ? ` ${directive.summary}` : "";
|
|
847
|
+
addEvent(state, issue.id, "runner", `Turn ${turnIndex}/${maxTurns} finished with status ${directive.status}.${directiveSummary}`.trim());
|
|
848
|
+
|
|
849
|
+
if (!turnResult.success || directive.status === "failed") {
|
|
850
|
+
session.status = "failed";
|
|
851
|
+
await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
|
|
852
|
+
return { success: false, blocked: false, continueRequested: false, code: lastCode, output: lastOutput, turns: turnIndex };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (directive.status === "blocked") {
|
|
856
|
+
session.status = "blocked";
|
|
857
|
+
await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
|
|
858
|
+
return { success: false, blocked: true, continueRequested: false, code: lastCode, output: lastOutput, turns: turnIndex };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (directive.status === "continue") {
|
|
862
|
+
session.status = "running";
|
|
863
|
+
await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
|
|
864
|
+
return { success: false, blocked: false, continueRequested: true, code: lastCode, output: lastOutput, turns: turnIndex };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
session.status = "done";
|
|
868
|
+
await persistAgentSessionState(sessionKey, issue, provider, cycle, session);
|
|
869
|
+
return { success: true, blocked: false, continueRequested: false, code: lastCode, output: lastOutput, turns: turnIndex };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export async function runAgentPipeline(
|
|
873
|
+
state: RuntimeState,
|
|
874
|
+
issue: IssueEntry,
|
|
875
|
+
workspacePath: string,
|
|
876
|
+
basePromptText: string,
|
|
877
|
+
basePromptFile: string,
|
|
878
|
+
workflowDefinition: WorkflowDefinition | null,
|
|
879
|
+
): Promise<AgentSessionResult> {
|
|
880
|
+
const providers = getEffectiveAgentProviders(state, issue, workflowDefinition);
|
|
881
|
+
const attempt = issue.attempts + 1;
|
|
882
|
+
const { pipeline, key: pipelineFile } = await loadAgentPipelineState(issue, attempt, providers);
|
|
883
|
+
const activeProvider = providers[clamp(pipeline.activeIndex, 0, Math.max(0, providers.length - 1))];
|
|
884
|
+
const executorIndex = providers.findIndex((provider) => provider.role === "executor");
|
|
885
|
+
|
|
886
|
+
// Discover skills and build context
|
|
887
|
+
const skills = discoverSkills(workspacePath);
|
|
888
|
+
const skillContext = buildSkillContext(skills);
|
|
889
|
+
|
|
890
|
+
// Write skills reference to workspace
|
|
891
|
+
if (skillContext) {
|
|
892
|
+
writeFileSync(join(workspacePath, "symphifo-skills.md"), skillContext, "utf8");
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const providerPrompt = buildProviderBasePrompt(activeProvider, issue, basePromptText, workspacePath, skillContext);
|
|
896
|
+
|
|
897
|
+
if (!activeProvider.command.trim()) {
|
|
898
|
+
throw new Error(`No command configured for provider ${activeProvider.provider} (${activeProvider.role}).`);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
pipeline.history.push(`[${now()}] Running ${activeProvider.role}:${activeProvider.provider} in cycle ${pipeline.cycle}.`);
|
|
902
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
903
|
+
|
|
904
|
+
// Attach workflowDefinition to state for session hooks
|
|
905
|
+
(state as any)._workflowDefinition = workflowDefinition;
|
|
906
|
+
|
|
907
|
+
const result = await runAgentSession(state, issue, activeProvider, pipeline.cycle, workspacePath, providerPrompt, basePromptFile);
|
|
908
|
+
|
|
909
|
+
if (result.success) {
|
|
910
|
+
if (pipeline.activeIndex < providers.length - 1) {
|
|
911
|
+
pipeline.activeIndex += 1;
|
|
912
|
+
pipeline.history.push(`[${now()}] ${activeProvider.role}:${activeProvider.provider} completed; advancing to next provider.`);
|
|
913
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
914
|
+
return { success: false, blocked: false, continueRequested: true, code: result.code, output: result.output, turns: result.turns };
|
|
915
|
+
}
|
|
916
|
+
pipeline.history.push(`[${now()}] Final provider ${activeProvider.role}:${activeProvider.provider} completed the issue.`);
|
|
917
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
918
|
+
return result;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (result.continueRequested && activeProvider.role === "reviewer" && executorIndex >= 0) {
|
|
922
|
+
pipeline.cycle += 1;
|
|
923
|
+
pipeline.activeIndex = executorIndex;
|
|
924
|
+
pipeline.history.push(`[${now()}] Reviewer requested rework; returning to executor for cycle ${pipeline.cycle}.`);
|
|
925
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
926
|
+
return result;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (result.continueRequested) {
|
|
930
|
+
pipeline.history.push(`[${now()}] ${activeProvider.role}:${activeProvider.provider} requested another turn.`);
|
|
931
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
932
|
+
return result;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (result.blocked) {
|
|
936
|
+
pipeline.history.push(`[${now()}] ${activeProvider.role}:${activeProvider.provider} blocked the pipeline.`);
|
|
937
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
pipeline.history.push(`[${now()}] ${activeProvider.role}:${activeProvider.provider} failed the pipeline.`);
|
|
942
|
+
await persistAgentPipelineState(pipelineFile, pipeline);
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export async function runIssueOnce(
|
|
947
|
+
state: RuntimeState,
|
|
948
|
+
issue: IssueEntry,
|
|
949
|
+
running: Set<string>,
|
|
950
|
+
workflowDefinition: WorkflowDefinition | null,
|
|
951
|
+
): Promise<void> {
|
|
952
|
+
const startTs = Date.now();
|
|
953
|
+
const resuming = issue.state === "In Progress";
|
|
954
|
+
running.add(issue.id);
|
|
955
|
+
state.metrics.activeWorkers += 1;
|
|
956
|
+
issue.startedAt = issue.startedAt ?? now();
|
|
957
|
+
|
|
958
|
+
if (resuming) {
|
|
959
|
+
issue.updatedAt = now();
|
|
960
|
+
issue.history.push(`[${issue.updatedAt}] Resuming persisted runner for ${issue.identifier}.`);
|
|
961
|
+
addEvent(state, issue.id, "progress", `Runner resumed for ${issue.identifier}.`);
|
|
962
|
+
} else {
|
|
963
|
+
transition(issue, "In Progress", `Starting local runner for ${issue.identifier}.`);
|
|
964
|
+
state.metrics.inProgress += 1;
|
|
965
|
+
state.metrics.queued = Math.max(state.metrics.queued - 1, 0);
|
|
966
|
+
addEvent(state, issue.id, "progress", `Runner started for ${issue.identifier}.`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
const workspaceDerivedPaths = hydrateIssuePathsFromWorkspace(issue);
|
|
971
|
+
if ((issue.paths ?? []).length > 0) {
|
|
972
|
+
issue.inferredPaths = [...new Set([...(issue.inferredPaths ?? []), ...inferCapabilityPaths({
|
|
973
|
+
id: issue.id,
|
|
974
|
+
identifier: issue.identifier,
|
|
975
|
+
title: issue.title,
|
|
976
|
+
description: issue.description,
|
|
977
|
+
labels: issue.labels,
|
|
978
|
+
paths: issue.paths,
|
|
979
|
+
})])];
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const { workspacePath, promptText, promptFile } = await prepareWorkspace(issue, workflowDefinition);
|
|
983
|
+
addEvent(state, issue.id, "info", `Workspace ready at ${workspacePath}.`);
|
|
984
|
+
|
|
985
|
+
const routedProviders = getEffectiveAgentProviders(state, issue, workflowDefinition);
|
|
986
|
+
addEvent(state, issue.id, "info",
|
|
987
|
+
`Capability routing selected ${routedProviders.map((p) => `${p.role}:${p.provider}${p.profile ? `:${p.profile}` : ""}`).join(", ")}.`);
|
|
988
|
+
|
|
989
|
+
const routingSignals = describeRoutingSignals(issue, workspaceDerivedPaths);
|
|
990
|
+
if (routingSignals) {
|
|
991
|
+
addEvent(state, issue.id, "info", `Capability routing signals: ${routingSignals}.`);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const runResult = await runAgentPipeline(state, issue, workspacePath, promptText, promptFile, workflowDefinition);
|
|
995
|
+
|
|
996
|
+
issue.durationMs = Date.now() - startTs;
|
|
997
|
+
issue.commandExitCode = runResult.code;
|
|
998
|
+
issue.commandOutputTail = runResult.output;
|
|
999
|
+
|
|
1000
|
+
if (runResult.success) {
|
|
1001
|
+
transition(issue, "In Review", `Agent session finished successfully in ${runResult.turns} turn(s) for ${issue.identifier}.`);
|
|
1002
|
+
issue.lastError = undefined;
|
|
1003
|
+
await sleep(250);
|
|
1004
|
+
transition(issue, "Done", `Issue accepted by local review stage.`);
|
|
1005
|
+
addEvent(state, issue.id, "runner", `Issue ${issue.identifier} moved to Done.`);
|
|
1006
|
+
issue.completedAt = now();
|
|
1007
|
+
} else if (runResult.continueRequested) {
|
|
1008
|
+
issue.updatedAt = now();
|
|
1009
|
+
issue.commandExitCode = runResult.code;
|
|
1010
|
+
issue.commandOutputTail = runResult.output;
|
|
1011
|
+
issue.lastError = undefined;
|
|
1012
|
+
issue.history.push(`[${issue.updatedAt}] Agent requested another turn (${runResult.turns}/${state.config.maxTurns}).`);
|
|
1013
|
+
addEvent(state, issue.id, "runner", `Issue ${issue.identifier} queued for next turn.`);
|
|
1014
|
+
} else {
|
|
1015
|
+
issue.lastError = runResult.output;
|
|
1016
|
+
issue.attempts += 1;
|
|
1017
|
+
|
|
1018
|
+
if (issue.attempts >= issue.maxAttempts) {
|
|
1019
|
+
issue.commandExitCode = runResult.code;
|
|
1020
|
+
transition(issue, "Cancelled", `Max attempts reached (${issue.attempts}/${issue.maxAttempts}).`);
|
|
1021
|
+
addEvent(state, issue.id, "error", `Issue ${issue.identifier} cancelled after repeated failures.`);
|
|
1022
|
+
} else {
|
|
1023
|
+
issue.nextRetryAt = getNextRetryAt(issue, state.config.retryDelayMs);
|
|
1024
|
+
transition(issue, "Blocked",
|
|
1025
|
+
`${runResult.blocked ? "Agent requested manual intervention" : "Failure"} on attempt ${issue.attempts}/${issue.maxAttempts}; retry scheduled at ${issue.nextRetryAt}.`);
|
|
1026
|
+
addEvent(state, issue.id, "error", `Issue ${issue.identifier} blocked waiting for retry.`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
issue.attempts += 1;
|
|
1031
|
+
issue.lastError = String(error);
|
|
1032
|
+
|
|
1033
|
+
if (issue.attempts >= issue.maxAttempts) {
|
|
1034
|
+
transition(issue, "Cancelled", `Issue failed unexpectedly: ${issue.lastError}`);
|
|
1035
|
+
addEvent(state, issue.id, "error", `Issue ${issue.identifier} cancelled unexpectedly.`);
|
|
1036
|
+
} else {
|
|
1037
|
+
issue.nextRetryAt = getNextRetryAt(issue, state.config.retryDelayMs);
|
|
1038
|
+
transition(issue, "Blocked", `Unexpected failure. Retry scheduled at ${issue.nextRetryAt}.`);
|
|
1039
|
+
addEvent(state, issue.id, "error", `Issue ${issue.identifier} blocked after unexpected failure.`);
|
|
1040
|
+
}
|
|
1041
|
+
} finally {
|
|
1042
|
+
issue.updatedAt = now();
|
|
1043
|
+
state.metrics.activeWorkers = Math.max(state.metrics.activeWorkers - 1, 0);
|
|
1044
|
+
running.delete(issue.id);
|
|
1045
|
+
state.metrics = computeMetrics(state.issues);
|
|
1046
|
+
state.metrics.activeWorkers = Math.max(state.metrics.activeWorkers, 0);
|
|
1047
|
+
state.updatedAt = now();
|
|
1048
|
+
await persistState(state);
|
|
1049
|
+
}
|
|
1050
|
+
}
|