@agent-relay/sdk 3.1.14 → 3.1.17
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/dist/__tests__/e2e-owner-review.test.d.ts +16 -0
- package/dist/__tests__/e2e-owner-review.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-owner-review.test.js +640 -0
- package/dist/__tests__/e2e-owner-review.test.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -1
- package/dist/client.js.map +1 -1
- package/dist/workflows/cli.js +10 -0
- package/dist/workflows/cli.js.map +1 -1
- package/dist/workflows/runner.d.ts +51 -0
- package/dist/workflows/runner.d.ts.map +1 -1
- package/dist/workflows/runner.js +655 -33
- package/dist/workflows/runner.js.map +1 -1
- package/dist/workflows/trajectory.d.ts +22 -1
- package/dist/workflows/trajectory.d.ts.map +1 -1
- package/dist/workflows/trajectory.js +55 -8
- package/dist/workflows/trajectory.js.map +1 -1
- package/dist/workflows/types.d.ts +26 -0
- package/dist/workflows/types.d.ts.map +1 -1
- package/dist/workflows/types.js.map +1 -1
- package/dist/workflows/validator.d.ts.map +1 -1
- package/dist/workflows/validator.js +29 -0
- package/dist/workflows/validator.js.map +1 -1
- package/package.json +2 -2
package/dist/workflows/runner.js
CHANGED
|
@@ -86,6 +86,10 @@ export class WorkflowRunner {
|
|
|
86
86
|
lastIdleLog = new Map();
|
|
87
87
|
/** Tracks last logged activity type per agent to avoid duplicate status lines. */
|
|
88
88
|
lastActivity = new Map();
|
|
89
|
+
/** Runtime-name lookup for agents participating in supervised owner flows. */
|
|
90
|
+
supervisedRuntimeAgents = new Map();
|
|
91
|
+
/** Resolved named paths from the top-level `paths` config, keyed by name → absolute directory. */
|
|
92
|
+
resolvedPaths = new Map();
|
|
89
93
|
constructor(options = {}) {
|
|
90
94
|
this.db = options.db ?? new InMemoryWorkflowDb();
|
|
91
95
|
this.workspaceId = options.workspaceId ?? 'local';
|
|
@@ -95,6 +99,76 @@ export class WorkflowRunner {
|
|
|
95
99
|
this.workersPath = path.join(this.cwd, '.agent-relay', 'team', 'workers.json');
|
|
96
100
|
this.executor = options.executor;
|
|
97
101
|
}
|
|
102
|
+
// ── Path resolution ─────────────────────────────────────────────────────
|
|
103
|
+
/** Expand environment variables like $HOME or $VAR in a path string. */
|
|
104
|
+
static resolveEnvVars(p) {
|
|
105
|
+
return p.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, varName) => {
|
|
106
|
+
return process.env[varName] ?? _match;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve and validate the top-level `paths` definitions from the config.
|
|
111
|
+
* Returns a map of name → absolute directory path.
|
|
112
|
+
* Throws if a required path does not exist.
|
|
113
|
+
*/
|
|
114
|
+
resolvePathDefinitions(pathDefs, baseCwd) {
|
|
115
|
+
const resolved = new Map();
|
|
116
|
+
const errors = [];
|
|
117
|
+
const warnings = [];
|
|
118
|
+
if (!pathDefs || pathDefs.length === 0)
|
|
119
|
+
return { resolved, errors, warnings };
|
|
120
|
+
const seenNames = new Set();
|
|
121
|
+
for (const pd of pathDefs) {
|
|
122
|
+
if (seenNames.has(pd.name)) {
|
|
123
|
+
errors.push(`Duplicate path name "${pd.name}"`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
seenNames.add(pd.name);
|
|
127
|
+
const expanded = WorkflowRunner.resolveEnvVars(pd.path);
|
|
128
|
+
const abs = path.resolve(baseCwd, expanded);
|
|
129
|
+
resolved.set(pd.name, abs);
|
|
130
|
+
const isRequired = pd.required !== false; // default true
|
|
131
|
+
if (!existsSync(abs)) {
|
|
132
|
+
if (isRequired) {
|
|
133
|
+
errors.push(`Path "${pd.name}" resolves to "${abs}" which does not exist (required)`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
warnings.push(`Path "${pd.name}" resolves to "${abs}" which does not exist (optional)`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { resolved, errors, warnings };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Resolve an agent's effective working directory, considering `workdir` (named path reference)
|
|
144
|
+
* and `cwd` (explicit path). `workdir` takes precedence when both are set.
|
|
145
|
+
*/
|
|
146
|
+
resolveAgentCwd(agent) {
|
|
147
|
+
if (agent.workdir) {
|
|
148
|
+
const resolved = this.resolvedPaths.get(agent.workdir);
|
|
149
|
+
if (!resolved) {
|
|
150
|
+
throw new Error(`Agent "${agent.name}" references workdir "${agent.workdir}" which is not defined in paths`);
|
|
151
|
+
}
|
|
152
|
+
return resolved;
|
|
153
|
+
}
|
|
154
|
+
if (agent.cwd) {
|
|
155
|
+
return path.resolve(this.cwd, agent.cwd);
|
|
156
|
+
}
|
|
157
|
+
return this.cwd;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Resolve a step's working directory from its `workdir` field (named path reference).
|
|
161
|
+
* Returns undefined if no workdir is set.
|
|
162
|
+
*/
|
|
163
|
+
resolveStepWorkdir(step) {
|
|
164
|
+
if (!step.workdir)
|
|
165
|
+
return undefined;
|
|
166
|
+
const resolved = this.resolvedPaths.get(step.workdir);
|
|
167
|
+
if (!resolved) {
|
|
168
|
+
throw new Error(`Step "${step.name}" references workdir "${step.workdir}" which is not defined in paths`);
|
|
169
|
+
}
|
|
170
|
+
return resolved;
|
|
171
|
+
}
|
|
98
172
|
// ── Progress logging ────────────────────────────────────────────────────
|
|
99
173
|
/** Log a progress message with elapsed time since run start. */
|
|
100
174
|
log(msg) {
|
|
@@ -346,6 +420,17 @@ export class WorkflowRunner {
|
|
|
346
420
|
estimatedWaves: 0,
|
|
347
421
|
};
|
|
348
422
|
}
|
|
423
|
+
// 1b. Resolve and validate named paths
|
|
424
|
+
const pathResult = this.resolvePathDefinitions(resolved.paths, this.cwd);
|
|
425
|
+
errors.push(...pathResult.errors);
|
|
426
|
+
warnings.push(...pathResult.warnings);
|
|
427
|
+
const dryRunPaths = pathResult.resolved;
|
|
428
|
+
// Validate workdir references on agents
|
|
429
|
+
for (const agent of resolved.agents) {
|
|
430
|
+
if (agent.workdir && !dryRunPaths.has(agent.workdir)) {
|
|
431
|
+
errors.push(`Agent "${agent.name}" references workdir "${agent.workdir}" which is not defined in paths`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
349
434
|
// 2. Find target workflow
|
|
350
435
|
const workflows = resolved.workflows ?? [];
|
|
351
436
|
const workflow = workflowName ? workflows.find((w) => w.name === workflowName) : workflows[0];
|
|
@@ -411,6 +496,12 @@ export class WorkflowRunner {
|
|
|
411
496
|
stepAgentCounts.set(step.agent, (stepAgentCounts.get(step.agent) ?? 0) + 1);
|
|
412
497
|
}
|
|
413
498
|
}
|
|
499
|
+
// Validate workdir references on steps
|
|
500
|
+
for (const step of resolvedSteps) {
|
|
501
|
+
if (step.workdir && !dryRunPaths.has(step.workdir)) {
|
|
502
|
+
errors.push(`Step "${step.name}" references workdir "${step.workdir}" which is not defined in paths`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
414
505
|
// Validate cwd paths
|
|
415
506
|
for (const agent of resolved.agents) {
|
|
416
507
|
if (agent.cwd) {
|
|
@@ -499,7 +590,7 @@ export class WorkflowRunner {
|
|
|
499
590
|
name: a.name,
|
|
500
591
|
cli: a.cli,
|
|
501
592
|
role: a.role,
|
|
502
|
-
cwd: a.cwd,
|
|
593
|
+
cwd: a.workdir ? dryRunPaths.get(a.workdir) : a.cwd,
|
|
503
594
|
stepCount: stepAgentCounts.get(a.name) ?? 0,
|
|
504
595
|
}));
|
|
505
596
|
// 5. Simulate execution waves
|
|
@@ -775,6 +866,17 @@ export class WorkflowRunner {
|
|
|
775
866
|
/** Execute a named workflow from a validated config. */
|
|
776
867
|
async execute(config, workflowName, vars) {
|
|
777
868
|
const resolved = vars ? this.resolveVariables(config, vars) : config;
|
|
869
|
+
// Resolve and validate named paths from the top-level `paths` config
|
|
870
|
+
const pathResult = this.resolvePathDefinitions(resolved.paths, this.cwd);
|
|
871
|
+
if (pathResult.errors.length > 0) {
|
|
872
|
+
throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
|
|
873
|
+
}
|
|
874
|
+
this.resolvedPaths = pathResult.resolved;
|
|
875
|
+
if (this.resolvedPaths.size > 0) {
|
|
876
|
+
for (const [name, abs] of this.resolvedPaths) {
|
|
877
|
+
console.log(`[workflow] path "${name}" → ${abs}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
778
880
|
const workflows = resolved.workflows ?? [];
|
|
779
881
|
const workflow = workflowName ? workflows.find((w) => w.name === workflowName) : workflows[0];
|
|
780
882
|
if (!workflow) {
|
|
@@ -841,6 +943,12 @@ export class WorkflowRunner {
|
|
|
841
943
|
throw new Error(`Run "${runId}" is in status "${run.status}" and cannot be resumed`);
|
|
842
944
|
}
|
|
843
945
|
const config = vars ? this.resolveVariables(run.config, vars) : run.config;
|
|
946
|
+
// Resolve path definitions (same as execute()) so workdir lookups work on resume
|
|
947
|
+
const pathResult = this.resolvePathDefinitions(config.paths, this.cwd);
|
|
948
|
+
if (pathResult.errors.length > 0) {
|
|
949
|
+
throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
|
|
950
|
+
}
|
|
951
|
+
this.resolvedPaths = pathResult.resolved;
|
|
844
952
|
const workflows = config.workflows ?? [];
|
|
845
953
|
const workflow = workflows.find((w) => w.name === run.workflowName);
|
|
846
954
|
if (!workflow) {
|
|
@@ -988,6 +1096,10 @@ export class WorkflowRunner {
|
|
|
988
1096
|
const fromShort = msg.from.replace(/-[a-f0-9]{6,}$/, '');
|
|
989
1097
|
const toShort = msg.to.replace(/-[a-f0-9]{6,}$/, '');
|
|
990
1098
|
this.log(`[msg] ${fromShort} → ${toShort}: ${body}`);
|
|
1099
|
+
const supervision = this.supervisedRuntimeAgents.get(msg.from);
|
|
1100
|
+
if (supervision?.role === 'owner') {
|
|
1101
|
+
void this.trajectory?.ownerMonitoringEvent(supervision.stepName, supervision.logicalName, `Messaged ${msg.to}: ${msg.text.slice(0, 120)}`, { to: msg.to, text: msg.text });
|
|
1102
|
+
}
|
|
991
1103
|
};
|
|
992
1104
|
this.relay.onAgentSpawned = (agent) => {
|
|
993
1105
|
// Skip agents already managed by step execution
|
|
@@ -1101,6 +1213,7 @@ export class WorkflowRunner {
|
|
|
1101
1213
|
}
|
|
1102
1214
|
this.lastIdleLog.clear();
|
|
1103
1215
|
this.lastActivity.clear();
|
|
1216
|
+
this.supervisedRuntimeAgents.clear();
|
|
1104
1217
|
this.log('Shutting down broker...');
|
|
1105
1218
|
await this.relay?.shutdown();
|
|
1106
1219
|
this.relay = undefined;
|
|
@@ -1386,10 +1499,12 @@ export class WorkflowRunner {
|
|
|
1386
1499
|
const value = this.resolveDotPath(key, stepOutputContext);
|
|
1387
1500
|
return value !== undefined ? String(value) : _match;
|
|
1388
1501
|
});
|
|
1502
|
+
// Resolve step workdir (named path reference) for deterministic steps
|
|
1503
|
+
const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
|
|
1389
1504
|
try {
|
|
1390
1505
|
// Delegate to executor if present
|
|
1391
1506
|
if (this.executor?.executeDeterministicStep) {
|
|
1392
|
-
const result = await this.executor.executeDeterministicStep(step, resolvedCommand,
|
|
1507
|
+
const result = await this.executor.executeDeterministicStep(step, resolvedCommand, stepCwd);
|
|
1393
1508
|
const failOnError = step.failOnError !== false;
|
|
1394
1509
|
if (failOnError && result.exitCode !== 0) {
|
|
1395
1510
|
throw new Error(`Command failed with exit code ${result.exitCode}: ${result.output.slice(0, 500)}`);
|
|
@@ -1412,7 +1527,7 @@ export class WorkflowRunner {
|
|
|
1412
1527
|
const output = await new Promise((resolve, reject) => {
|
|
1413
1528
|
const child = cpSpawn('sh', ['-c', resolvedCommand], {
|
|
1414
1529
|
stdio: 'pipe',
|
|
1415
|
-
cwd:
|
|
1530
|
+
cwd: stepCwd,
|
|
1416
1531
|
env: { ...process.env },
|
|
1417
1532
|
});
|
|
1418
1533
|
const stdoutChunks = [];
|
|
@@ -1527,6 +1642,8 @@ export class WorkflowRunner {
|
|
|
1527
1642
|
? this.interpolateStepTask(step.path, stepOutputContext)
|
|
1528
1643
|
: path.join('.worktrees', step.name);
|
|
1529
1644
|
const createBranch = step.createBranch !== false;
|
|
1645
|
+
// Resolve workdir for worktree steps (same as deterministic/agent steps)
|
|
1646
|
+
const stepCwd = this.resolveStepWorkdir(step) ?? this.cwd;
|
|
1530
1647
|
if (!branch) {
|
|
1531
1648
|
const errorMsg = 'Worktree step missing required "branch" field';
|
|
1532
1649
|
await this.markStepFailed(state, errorMsg, runId);
|
|
@@ -1535,14 +1652,14 @@ export class WorkflowRunner {
|
|
|
1535
1652
|
try {
|
|
1536
1653
|
// Build the git worktree command
|
|
1537
1654
|
// If createBranch is true and branch doesn't exist, use -b flag
|
|
1538
|
-
const absoluteWorktreePath = path.resolve(
|
|
1655
|
+
const absoluteWorktreePath = path.resolve(stepCwd, worktreePath);
|
|
1539
1656
|
// First, check if the branch already exists
|
|
1540
1657
|
const checkBranchCmd = `git rev-parse --verify --quiet ${branch} 2>/dev/null`;
|
|
1541
1658
|
let branchExists = false;
|
|
1542
1659
|
await new Promise((resolve) => {
|
|
1543
1660
|
const checkChild = cpSpawn('sh', ['-c', checkBranchCmd], {
|
|
1544
1661
|
stdio: 'pipe',
|
|
1545
|
-
cwd:
|
|
1662
|
+
cwd: stepCwd,
|
|
1546
1663
|
env: { ...process.env },
|
|
1547
1664
|
});
|
|
1548
1665
|
checkChild.on('close', (code) => {
|
|
@@ -1570,7 +1687,7 @@ export class WorkflowRunner {
|
|
|
1570
1687
|
const output = await new Promise((resolve, reject) => {
|
|
1571
1688
|
const child = cpSpawn('sh', ['-c', worktreeCmd], {
|
|
1572
1689
|
stdio: 'pipe',
|
|
1573
|
-
cwd:
|
|
1690
|
+
cwd: stepCwd,
|
|
1574
1691
|
env: { ...process.env },
|
|
1575
1692
|
});
|
|
1576
1693
|
const stdoutChunks = [];
|
|
@@ -1669,10 +1786,26 @@ export class WorkflowRunner {
|
|
|
1669
1786
|
if (!rawAgentDef) {
|
|
1670
1787
|
throw new Error(`Agent "${agentName}" not found in config`);
|
|
1671
1788
|
}
|
|
1672
|
-
const
|
|
1673
|
-
const
|
|
1789
|
+
const specialistDef = WorkflowRunner.resolveAgentDef(rawAgentDef);
|
|
1790
|
+
const usesOwnerFlow = specialistDef.interactive !== false;
|
|
1791
|
+
const ownerDef = usesOwnerFlow ? this.resolveAutoStepOwner(specialistDef, agentMap) : specialistDef;
|
|
1792
|
+
const reviewDef = usesOwnerFlow ? this.resolveAutoReviewAgent(ownerDef, agentMap) : undefined;
|
|
1793
|
+
const supervised = {
|
|
1794
|
+
specialist: specialistDef,
|
|
1795
|
+
owner: ownerDef,
|
|
1796
|
+
reviewer: reviewDef,
|
|
1797
|
+
};
|
|
1798
|
+
const usesDedicatedOwner = usesOwnerFlow && ownerDef.name !== specialistDef.name;
|
|
1799
|
+
const maxRetries = step.retries ??
|
|
1800
|
+
ownerDef.constraints?.retries ??
|
|
1801
|
+
specialistDef.constraints?.retries ??
|
|
1802
|
+
errorHandling?.maxRetries ??
|
|
1803
|
+
0;
|
|
1674
1804
|
const retryDelay = errorHandling?.retryDelayMs ?? 1000;
|
|
1675
|
-
const timeoutMs = step.timeoutMs ??
|
|
1805
|
+
const timeoutMs = step.timeoutMs ??
|
|
1806
|
+
ownerDef.constraints?.timeoutMs ??
|
|
1807
|
+
specialistDef.constraints?.timeoutMs ??
|
|
1808
|
+
this.currentConfig?.swarm?.timeoutMs;
|
|
1676
1809
|
let lastError;
|
|
1677
1810
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1678
1811
|
this.checkAborted();
|
|
@@ -1697,52 +1830,110 @@ export class WorkflowRunner {
|
|
|
1697
1830
|
updatedAt: new Date().toISOString(),
|
|
1698
1831
|
});
|
|
1699
1832
|
this.emit({ type: 'step:started', runId, stepName: step.name });
|
|
1700
|
-
this.postToChannel(`**[${step.name}]** Started (
|
|
1701
|
-
await this.trajectory?.stepStarted(step,
|
|
1833
|
+
this.postToChannel(`**[${step.name}]** Started (owner: ${ownerDef.name}, specialist: ${specialistDef.name})`);
|
|
1834
|
+
await this.trajectory?.stepStarted(step, ownerDef.name, {
|
|
1835
|
+
role: usesDedicatedOwner ? 'owner' : 'specialist',
|
|
1836
|
+
owner: ownerDef.name,
|
|
1837
|
+
specialist: specialistDef.name,
|
|
1838
|
+
reviewer: reviewDef?.name,
|
|
1839
|
+
});
|
|
1840
|
+
if (usesDedicatedOwner) {
|
|
1841
|
+
await this.trajectory?.stepSupervisionAssigned(step, supervised);
|
|
1842
|
+
}
|
|
1843
|
+
this.emit({
|
|
1844
|
+
type: 'step:owner-assigned',
|
|
1845
|
+
runId,
|
|
1846
|
+
stepName: step.name,
|
|
1847
|
+
ownerName: ownerDef.name,
|
|
1848
|
+
specialistName: specialistDef.name,
|
|
1849
|
+
});
|
|
1702
1850
|
// Resolve step-output variables (e.g. {{steps.plan.output}}) at execution time
|
|
1703
1851
|
const stepOutputContext = this.buildStepOutputContext(stepStates, runId);
|
|
1704
1852
|
let resolvedTask = this.interpolateStepTask(step.task ?? '', stepOutputContext);
|
|
1705
1853
|
// If this is an interactive agent, append awareness of non-interactive workers
|
|
1706
1854
|
// so the lead knows not to message them and to use step output chaining instead
|
|
1707
|
-
if (
|
|
1855
|
+
if (specialistDef.interactive !== false || ownerDef.interactive !== false) {
|
|
1708
1856
|
const nonInteractiveInfo = this.buildNonInteractiveAwareness(agentMap, stepStates);
|
|
1709
1857
|
if (nonInteractiveInfo) {
|
|
1710
1858
|
resolvedTask += nonInteractiveInfo;
|
|
1711
1859
|
}
|
|
1712
1860
|
}
|
|
1713
|
-
//
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1861
|
+
// Apply step-level workdir override to agent definitions if present
|
|
1862
|
+
const applyStepWorkdir = (def) => {
|
|
1863
|
+
if (step.workdir) {
|
|
1864
|
+
const stepWorkdir = this.resolveStepWorkdir(step);
|
|
1865
|
+
if (stepWorkdir) {
|
|
1866
|
+
return { ...def, cwd: stepWorkdir, workdir: undefined };
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
return def;
|
|
1870
|
+
};
|
|
1871
|
+
const effectiveSpecialist = applyStepWorkdir(specialistDef);
|
|
1872
|
+
const effectiveOwner = applyStepWorkdir(ownerDef);
|
|
1873
|
+
let specialistOutput;
|
|
1874
|
+
let ownerOutput;
|
|
1875
|
+
let ownerElapsed;
|
|
1876
|
+
if (usesDedicatedOwner) {
|
|
1877
|
+
const result = await this.executeSupervisedAgentStep(step, { specialist: effectiveSpecialist, owner: effectiveOwner, reviewer: reviewDef }, resolvedTask, timeoutMs);
|
|
1878
|
+
specialistOutput = result.specialistOutput;
|
|
1879
|
+
ownerOutput = result.ownerOutput;
|
|
1880
|
+
ownerElapsed = result.ownerElapsed;
|
|
1881
|
+
}
|
|
1882
|
+
else {
|
|
1883
|
+
const ownerTask = this.injectStepOwnerContract(step, resolvedTask, effectiveOwner, effectiveSpecialist);
|
|
1884
|
+
this.log(`[${step.name}] Spawning owner "${effectiveOwner.name}" (cli: ${effectiveOwner.cli})${step.workdir ? ` [workdir: ${step.workdir}]` : ''}`);
|
|
1885
|
+
const resolvedStep = { ...step, task: ownerTask };
|
|
1886
|
+
const ownerStartTime = Date.now();
|
|
1887
|
+
const output = this.executor
|
|
1888
|
+
? await this.executor.executeAgentStep(resolvedStep, effectiveOwner, ownerTask, timeoutMs)
|
|
1889
|
+
: await this.spawnAndWait(effectiveOwner, resolvedStep, timeoutMs);
|
|
1890
|
+
ownerElapsed = Date.now() - ownerStartTime;
|
|
1891
|
+
this.log(`[${step.name}] Owner "${effectiveOwner.name}" exited`);
|
|
1892
|
+
if (usesOwnerFlow) {
|
|
1893
|
+
this.assertOwnerCompletionMarker(step, output, ownerTask);
|
|
1894
|
+
}
|
|
1895
|
+
specialistOutput = output;
|
|
1896
|
+
ownerOutput = output;
|
|
1897
|
+
}
|
|
1720
1898
|
// Run verification if configured
|
|
1721
1899
|
if (step.verification) {
|
|
1722
|
-
this.runVerification(step.verification,
|
|
1900
|
+
this.runVerification(step.verification, specialistOutput, step.name, resolvedTask);
|
|
1901
|
+
}
|
|
1902
|
+
// Every interactive step gets a review pass; pick a dedicated reviewer when available.
|
|
1903
|
+
let combinedOutput = specialistOutput;
|
|
1904
|
+
if (usesOwnerFlow && reviewDef) {
|
|
1905
|
+
const remainingMs = timeoutMs ? Math.max(0, timeoutMs - ownerElapsed) : undefined;
|
|
1906
|
+
const reviewOutput = await this.runStepReviewGate(step, resolvedTask, specialistOutput, ownerOutput, ownerDef, reviewDef, remainingMs);
|
|
1907
|
+
combinedOutput = this.combineStepAndReviewOutput(specialistOutput, reviewOutput);
|
|
1723
1908
|
}
|
|
1724
1909
|
// Mark completed
|
|
1725
1910
|
state.row.status = 'completed';
|
|
1726
|
-
state.row.output =
|
|
1911
|
+
state.row.output = combinedOutput;
|
|
1727
1912
|
state.row.completedAt = new Date().toISOString();
|
|
1728
1913
|
await this.db.updateStep(state.row.id, {
|
|
1729
1914
|
status: 'completed',
|
|
1730
|
-
output,
|
|
1915
|
+
output: combinedOutput,
|
|
1731
1916
|
completedAt: state.row.completedAt,
|
|
1732
1917
|
updatedAt: new Date().toISOString(),
|
|
1733
1918
|
});
|
|
1734
1919
|
// Persist step output to disk so it survives restarts and is inspectable
|
|
1735
|
-
await this.persistStepOutput(runId, step.name,
|
|
1736
|
-
this.emit({ type: 'step:completed', runId, stepName: step.name, output });
|
|
1737
|
-
await this.trajectory?.stepCompleted(step,
|
|
1920
|
+
await this.persistStepOutput(runId, step.name, combinedOutput);
|
|
1921
|
+
this.emit({ type: 'step:completed', runId, stepName: step.name, output: combinedOutput });
|
|
1922
|
+
await this.trajectory?.stepCompleted(step, combinedOutput, attempt + 1);
|
|
1738
1923
|
return;
|
|
1739
1924
|
}
|
|
1740
1925
|
catch (err) {
|
|
1741
1926
|
lastError = err instanceof Error ? err.message : String(err);
|
|
1927
|
+
const ownerTimedOut = usesDedicatedOwner
|
|
1928
|
+
? /\bowner timed out\b/i.test(lastError)
|
|
1929
|
+
: /\btimed out\b/i.test(lastError) && !lastError.includes(`${step.name}-review`);
|
|
1930
|
+
if (ownerTimedOut) {
|
|
1931
|
+
this.emit({ type: 'step:owner-timeout', runId, stepName: step.name, ownerName: ownerDef.name });
|
|
1932
|
+
}
|
|
1742
1933
|
}
|
|
1743
1934
|
}
|
|
1744
1935
|
// All retries exhausted — record root-cause diagnosis and mark failed
|
|
1745
|
-
const nonInteractive =
|
|
1936
|
+
const nonInteractive = ownerDef.interactive === false || ['worker', 'reviewer', 'analyst'].includes(ownerDef.preset ?? '');
|
|
1746
1937
|
const verificationValue = typeof step.verification === 'object' && 'value' in step.verification
|
|
1747
1938
|
? String(step.verification.value)
|
|
1748
1939
|
: undefined;
|
|
@@ -1755,6 +1946,430 @@ export class WorkflowRunner {
|
|
|
1755
1946
|
await this.markStepFailed(state, lastError ?? 'Unknown error', runId);
|
|
1756
1947
|
throw new Error(`Step "${step.name}" failed after ${maxRetries} retries: ${lastError ?? 'Unknown error'}`);
|
|
1757
1948
|
}
|
|
1949
|
+
injectStepOwnerContract(step, resolvedTask, ownerDef, specialistDef) {
|
|
1950
|
+
if (ownerDef.interactive === false)
|
|
1951
|
+
return resolvedTask;
|
|
1952
|
+
const specialistNote = ownerDef.name === specialistDef.name
|
|
1953
|
+
? ''
|
|
1954
|
+
: `Specialist intended for this step: "${specialistDef.name}" (${specialistDef.role ?? specialistDef.cli}).`;
|
|
1955
|
+
return (resolvedTask +
|
|
1956
|
+
'\n\n---\n' +
|
|
1957
|
+
`STEP OWNER CONTRACT:\n` +
|
|
1958
|
+
`- You are the accountable owner for step "${step.name}".\n` +
|
|
1959
|
+
(specialistNote ? `- ${specialistNote}\n` : '') +
|
|
1960
|
+
`- If you delegate, you must still verify completion yourself.\n` +
|
|
1961
|
+
`- Before exiting, provide an explicit completion line: STEP_COMPLETE:${step.name}\n` +
|
|
1962
|
+
`- Then self-terminate immediately with /exit.`);
|
|
1963
|
+
}
|
|
1964
|
+
buildOwnerSupervisorTask(step, originalTask, supervised, workerRuntimeName) {
|
|
1965
|
+
const verificationGuide = this.buildSupervisorVerificationGuide(step.verification);
|
|
1966
|
+
const channelLine = this.channel ? `#${this.channel}` : '(workflow channel unavailable)';
|
|
1967
|
+
return (`You are the step owner/supervisor for step "${step.name}".\n\n` +
|
|
1968
|
+
`Worker: ${supervised.specialist.name} (runtime: ${workerRuntimeName}) on ${channelLine}\n` +
|
|
1969
|
+
`Task: ${originalTask}\n\n` +
|
|
1970
|
+
`Your job: Monitor the worker and determine when the task is complete.\n\n` +
|
|
1971
|
+
`How to verify completion:\n` +
|
|
1972
|
+
`- Watch ${channelLine} for the worker's progress messages and mirrored PTY output\n` +
|
|
1973
|
+
`- Check file changes: run \`git diff --stat\` or inspect expected files directly\n` +
|
|
1974
|
+
`- Ask the worker directly on ${channelLine} if you need a status update\n` +
|
|
1975
|
+
verificationGuide +
|
|
1976
|
+
`\nWhen you're satisfied the work is done correctly:\n` +
|
|
1977
|
+
`Output exactly: STEP_COMPLETE:${step.name}`);
|
|
1978
|
+
}
|
|
1979
|
+
buildSupervisorVerificationGuide(verification) {
|
|
1980
|
+
if (!verification)
|
|
1981
|
+
return '';
|
|
1982
|
+
switch (verification.type) {
|
|
1983
|
+
case 'output_contains':
|
|
1984
|
+
return `- Verification gate: confirm the worker output contains ${JSON.stringify(verification.value)}\n`;
|
|
1985
|
+
case 'file_exists':
|
|
1986
|
+
return `- Verification gate: confirm the file exists at ${JSON.stringify(verification.value)}\n`;
|
|
1987
|
+
case 'exit_code':
|
|
1988
|
+
return `- Verification gate: confirm the worker exits with code ${JSON.stringify(verification.value)}\n`;
|
|
1989
|
+
case 'custom':
|
|
1990
|
+
return `- Verification gate: apply the custom verification rule ${JSON.stringify(verification.value)}\n`;
|
|
1991
|
+
default:
|
|
1992
|
+
return '';
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
async executeSupervisedAgentStep(step, supervised, resolvedTask, timeoutMs) {
|
|
1996
|
+
if (this.executor) {
|
|
1997
|
+
const supervisorTask = this.buildOwnerSupervisorTask(step, resolvedTask, supervised, supervised.specialist.name);
|
|
1998
|
+
const specialistStep = { ...step, task: resolvedTask };
|
|
1999
|
+
const ownerStep = {
|
|
2000
|
+
...step,
|
|
2001
|
+
name: `${step.name}-owner`,
|
|
2002
|
+
agent: supervised.owner.name,
|
|
2003
|
+
task: supervisorTask,
|
|
2004
|
+
};
|
|
2005
|
+
this.log(`[${step.name}] Spawning specialist "${supervised.specialist.name}" and owner "${supervised.owner.name}"`);
|
|
2006
|
+
const specialistPromise = this.executor.executeAgentStep(specialistStep, supervised.specialist, resolvedTask, timeoutMs);
|
|
2007
|
+
const ownerStartTime = Date.now();
|
|
2008
|
+
const ownerOutput = await this.executor.executeAgentStep(ownerStep, supervised.owner, supervisorTask, timeoutMs);
|
|
2009
|
+
const ownerElapsed = Date.now() - ownerStartTime;
|
|
2010
|
+
this.assertOwnerCompletionMarker(step, ownerOutput, supervisorTask);
|
|
2011
|
+
const specialistOutput = await specialistPromise;
|
|
2012
|
+
return { specialistOutput, ownerOutput, ownerElapsed };
|
|
2013
|
+
}
|
|
2014
|
+
let workerHandle;
|
|
2015
|
+
let workerRuntimeName = supervised.specialist.name;
|
|
2016
|
+
let workerSpawned = false;
|
|
2017
|
+
let workerReleased = false;
|
|
2018
|
+
let resolveWorkerSpawn;
|
|
2019
|
+
let rejectWorkerSpawn;
|
|
2020
|
+
const workerReady = new Promise((resolve, reject) => {
|
|
2021
|
+
resolveWorkerSpawn = resolve;
|
|
2022
|
+
rejectWorkerSpawn = reject;
|
|
2023
|
+
});
|
|
2024
|
+
const specialistStep = { ...step, task: resolvedTask };
|
|
2025
|
+
this.log(`[${step.name}] Spawning specialist "${supervised.specialist.name}" (cli: ${supervised.specialist.cli})`);
|
|
2026
|
+
const workerPromise = this.spawnAndWait(supervised.specialist, specialistStep, timeoutMs, {
|
|
2027
|
+
agentNameSuffix: 'worker',
|
|
2028
|
+
onSpawned: ({ actualName, agent }) => {
|
|
2029
|
+
workerHandle = agent;
|
|
2030
|
+
workerRuntimeName = actualName;
|
|
2031
|
+
this.supervisedRuntimeAgents.set(actualName, {
|
|
2032
|
+
stepName: step.name,
|
|
2033
|
+
role: 'specialist',
|
|
2034
|
+
logicalName: supervised.specialist.name,
|
|
2035
|
+
});
|
|
2036
|
+
if (!workerSpawned) {
|
|
2037
|
+
workerSpawned = true;
|
|
2038
|
+
resolveWorkerSpawn();
|
|
2039
|
+
}
|
|
2040
|
+
},
|
|
2041
|
+
onChunk: ({ agentName, chunk }) => {
|
|
2042
|
+
this.forwardAgentChunkToChannel(step.name, 'Worker', agentName, chunk);
|
|
2043
|
+
},
|
|
2044
|
+
}).catch((error) => {
|
|
2045
|
+
if (!workerSpawned) {
|
|
2046
|
+
workerSpawned = true;
|
|
2047
|
+
rejectWorkerSpawn(error);
|
|
2048
|
+
}
|
|
2049
|
+
throw error;
|
|
2050
|
+
});
|
|
2051
|
+
const workerSettled = workerPromise.catch(() => undefined);
|
|
2052
|
+
workerPromise
|
|
2053
|
+
.then((output) => {
|
|
2054
|
+
workerReleased = true;
|
|
2055
|
+
this.postToChannel(`**[${step.name}]** Worker \`${workerRuntimeName}\` exited`);
|
|
2056
|
+
if (step.verification?.type === 'output_contains' && output.includes(step.verification.value)) {
|
|
2057
|
+
this.postToChannel(`**[${step.name}]** Verification gate observed: output contains ${JSON.stringify(step.verification.value)}`);
|
|
2058
|
+
}
|
|
2059
|
+
})
|
|
2060
|
+
.catch((error) => {
|
|
2061
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2062
|
+
this.postToChannel(`**[${step.name}]** Worker \`${workerRuntimeName}\` exited with error: ${message}`);
|
|
2063
|
+
});
|
|
2064
|
+
await workerReady;
|
|
2065
|
+
const supervisorTask = this.buildOwnerSupervisorTask(step, resolvedTask, supervised, workerRuntimeName);
|
|
2066
|
+
const ownerStep = {
|
|
2067
|
+
...step,
|
|
2068
|
+
name: `${step.name}-owner`,
|
|
2069
|
+
agent: supervised.owner.name,
|
|
2070
|
+
task: supervisorTask,
|
|
2071
|
+
};
|
|
2072
|
+
this.log(`[${step.name}] Spawning owner "${supervised.owner.name}" (cli: ${supervised.owner.cli})`);
|
|
2073
|
+
const ownerStartTime = Date.now();
|
|
2074
|
+
try {
|
|
2075
|
+
const ownerOutput = await this.spawnAndWait(supervised.owner, ownerStep, timeoutMs, {
|
|
2076
|
+
agentNameSuffix: 'owner',
|
|
2077
|
+
onSpawned: ({ actualName }) => {
|
|
2078
|
+
this.supervisedRuntimeAgents.set(actualName, {
|
|
2079
|
+
stepName: step.name,
|
|
2080
|
+
role: 'owner',
|
|
2081
|
+
logicalName: supervised.owner.name,
|
|
2082
|
+
});
|
|
2083
|
+
},
|
|
2084
|
+
onChunk: ({ chunk }) => {
|
|
2085
|
+
void this.recordOwnerMonitoringChunk(step, supervised.owner, chunk);
|
|
2086
|
+
},
|
|
2087
|
+
});
|
|
2088
|
+
const ownerElapsed = Date.now() - ownerStartTime;
|
|
2089
|
+
this.log(`[${step.name}] Owner "${supervised.owner.name}" exited`);
|
|
2090
|
+
this.assertOwnerCompletionMarker(step, ownerOutput, supervisorTask);
|
|
2091
|
+
const specialistOutput = await workerPromise;
|
|
2092
|
+
return { specialistOutput, ownerOutput, ownerElapsed };
|
|
2093
|
+
}
|
|
2094
|
+
catch (error) {
|
|
2095
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2096
|
+
if (!workerReleased && workerHandle) {
|
|
2097
|
+
await workerHandle.release().catch(() => undefined);
|
|
2098
|
+
}
|
|
2099
|
+
await workerSettled;
|
|
2100
|
+
if (/\btimed out\b/i.test(message)) {
|
|
2101
|
+
throw new Error(`Step "${step.name}" owner timed out after ${timeoutMs ?? 'unknown'}ms`);
|
|
2102
|
+
}
|
|
2103
|
+
throw error;
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
forwardAgentChunkToChannel(stepName, roleLabel, agentName, chunk) {
|
|
2107
|
+
const lines = WorkflowRunner.stripAnsi(chunk)
|
|
2108
|
+
.split('\n')
|
|
2109
|
+
.map((line) => line.trim())
|
|
2110
|
+
.filter(Boolean)
|
|
2111
|
+
.slice(0, 3);
|
|
2112
|
+
for (const line of lines) {
|
|
2113
|
+
this.postToChannel(`**[${stepName}]** ${roleLabel} \`${agentName}\`: ${line.slice(0, 280)}`);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
async recordOwnerMonitoringChunk(step, ownerDef, chunk) {
|
|
2117
|
+
const stripped = WorkflowRunner.stripAnsi(chunk);
|
|
2118
|
+
const details = [];
|
|
2119
|
+
if (/git diff --stat/i.test(stripped))
|
|
2120
|
+
details.push('Checked git diff stats');
|
|
2121
|
+
if (/\bls -la\b/i.test(stripped))
|
|
2122
|
+
details.push('Listed files for verification');
|
|
2123
|
+
if (/status update\?/i.test(stripped))
|
|
2124
|
+
details.push('Asked the worker for a status update');
|
|
2125
|
+
if (/STEP_COMPLETE:/i.test(stripped))
|
|
2126
|
+
details.push('Declared the step complete');
|
|
2127
|
+
for (const detail of details) {
|
|
2128
|
+
await this.trajectory?.ownerMonitoringEvent(step.name, ownerDef.name, detail, {
|
|
2129
|
+
output: stripped.slice(0, 240),
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
resolveAutoStepOwner(specialistDef, agentMap) {
|
|
2134
|
+
if (specialistDef.interactive === false)
|
|
2135
|
+
return specialistDef;
|
|
2136
|
+
const allDefs = [...agentMap.values()].map((d) => WorkflowRunner.resolveAgentDef(d));
|
|
2137
|
+
const candidates = allDefs.filter((d) => d.interactive !== false);
|
|
2138
|
+
const matchesHubRole = (text) => [...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`, 'i').test(text));
|
|
2139
|
+
const ownerish = (def) => {
|
|
2140
|
+
const nameLC = def.name.toLowerCase();
|
|
2141
|
+
const roleLC = def.role?.toLowerCase() ?? '';
|
|
2142
|
+
return matchesHubRole(nameLC) || matchesHubRole(roleLC);
|
|
2143
|
+
};
|
|
2144
|
+
const ownerPriority = (def) => {
|
|
2145
|
+
const roleLC = def.role?.toLowerCase() ?? '';
|
|
2146
|
+
const nameLC = def.name.toLowerCase();
|
|
2147
|
+
if (/\blead\b/.test(roleLC) || /\blead\b/.test(nameLC))
|
|
2148
|
+
return 6;
|
|
2149
|
+
if (/\bcoordinator\b/.test(roleLC) || /\bcoordinator\b/.test(nameLC))
|
|
2150
|
+
return 5;
|
|
2151
|
+
if (/\bsupervisor\b/.test(roleLC) || /\bsupervisor\b/.test(nameLC))
|
|
2152
|
+
return 4;
|
|
2153
|
+
if (/\borchestrator\b/.test(roleLC) || /\borchestrator\b/.test(nameLC))
|
|
2154
|
+
return 3;
|
|
2155
|
+
if (/\bhub\b/.test(roleLC) || /\bhub\b/.test(nameLC))
|
|
2156
|
+
return 2;
|
|
2157
|
+
return ownerish(def) ? 1 : 0;
|
|
2158
|
+
};
|
|
2159
|
+
const dedicatedOwner = candidates
|
|
2160
|
+
.filter((d) => d.name !== specialistDef.name && ownerish(d))
|
|
2161
|
+
.sort((a, b) => ownerPriority(b) - ownerPriority(a) || a.name.localeCompare(b.name))[0];
|
|
2162
|
+
if (dedicatedOwner)
|
|
2163
|
+
return dedicatedOwner;
|
|
2164
|
+
return specialistDef;
|
|
2165
|
+
}
|
|
2166
|
+
resolveAutoReviewAgent(ownerDef, agentMap) {
|
|
2167
|
+
const allDefs = [...agentMap.values()].map((d) => WorkflowRunner.resolveAgentDef(d));
|
|
2168
|
+
const isReviewer = (def) => {
|
|
2169
|
+
const roleLC = def.role?.toLowerCase() ?? '';
|
|
2170
|
+
const nameLC = def.name.toLowerCase();
|
|
2171
|
+
return (def.preset === 'reviewer' ||
|
|
2172
|
+
roleLC.includes('review') ||
|
|
2173
|
+
roleLC.includes('critic') ||
|
|
2174
|
+
roleLC.includes('verifier') ||
|
|
2175
|
+
roleLC.includes('qa') ||
|
|
2176
|
+
nameLC.includes('review'));
|
|
2177
|
+
};
|
|
2178
|
+
const reviewerPriority = (def) => {
|
|
2179
|
+
if (def.preset === 'reviewer')
|
|
2180
|
+
return 5;
|
|
2181
|
+
const roleLC = def.role?.toLowerCase() ?? '';
|
|
2182
|
+
const nameLC = def.name.toLowerCase();
|
|
2183
|
+
if (roleLC.includes('review') || nameLC.includes('review'))
|
|
2184
|
+
return 4;
|
|
2185
|
+
if (roleLC.includes('verifier') || roleLC.includes('qa'))
|
|
2186
|
+
return 3;
|
|
2187
|
+
if (roleLC.includes('critic'))
|
|
2188
|
+
return 2;
|
|
2189
|
+
return isReviewer(def) ? 1 : 0;
|
|
2190
|
+
};
|
|
2191
|
+
const dedicated = allDefs
|
|
2192
|
+
.filter((d) => d.name !== ownerDef.name && isReviewer(d))
|
|
2193
|
+
.sort((a, b) => reviewerPriority(b) - reviewerPriority(a) || a.name.localeCompare(b.name))[0];
|
|
2194
|
+
if (dedicated)
|
|
2195
|
+
return dedicated;
|
|
2196
|
+
const alternate = allDefs.find((d) => d.name !== ownerDef.name && d.interactive !== false);
|
|
2197
|
+
if (alternate)
|
|
2198
|
+
return alternate;
|
|
2199
|
+
// Self-review fallback — log a warning since owner reviewing itself is weak.
|
|
2200
|
+
return ownerDef;
|
|
2201
|
+
}
|
|
2202
|
+
assertOwnerCompletionMarker(step, output, injectedTaskText) {
|
|
2203
|
+
const marker = `STEP_COMPLETE:${step.name}`;
|
|
2204
|
+
const taskHasMarker = injectedTaskText.includes(marker);
|
|
2205
|
+
const first = output.indexOf(marker);
|
|
2206
|
+
if (first === -1) {
|
|
2207
|
+
throw new Error(`Step "${step.name}" owner completion marker missing: "${marker}"`);
|
|
2208
|
+
}
|
|
2209
|
+
// PTY output includes injected task text, so require a second marker occurrence
|
|
2210
|
+
// when the marker was present in the injected prompt (either owner contract or supervisor prompt).
|
|
2211
|
+
const outputLikelyContainsInjectedPrompt = output.includes('STEP OWNER CONTRACT') || output.includes('Output exactly: STEP_COMPLETE:');
|
|
2212
|
+
if (taskHasMarker && outputLikelyContainsInjectedPrompt) {
|
|
2213
|
+
const hasSecond = output.includes(marker, first + marker.length);
|
|
2214
|
+
if (!hasSecond) {
|
|
2215
|
+
throw new Error(`Step "${step.name}" owner completion marker missing in agent response: "${marker}"`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
async runStepReviewGate(step, resolvedTask, specialistOutput, ownerOutput, ownerDef, reviewerDef, timeoutMs) {
|
|
2220
|
+
const reviewSnippetMax = 12_000;
|
|
2221
|
+
let specialistSnippet = specialistOutput;
|
|
2222
|
+
if (specialistOutput.length > reviewSnippetMax) {
|
|
2223
|
+
const head = Math.floor(reviewSnippetMax / 2);
|
|
2224
|
+
const tail = reviewSnippetMax - head;
|
|
2225
|
+
const omitted = specialistOutput.length - head - tail;
|
|
2226
|
+
specialistSnippet =
|
|
2227
|
+
`${specialistOutput.slice(0, head)}\n` +
|
|
2228
|
+
`...[truncated ${omitted} chars for review]...\n` +
|
|
2229
|
+
`${specialistOutput.slice(specialistOutput.length - tail)}`;
|
|
2230
|
+
}
|
|
2231
|
+
let ownerSnippet = ownerOutput;
|
|
2232
|
+
if (ownerOutput.length > reviewSnippetMax) {
|
|
2233
|
+
const head = Math.floor(reviewSnippetMax / 2);
|
|
2234
|
+
const tail = reviewSnippetMax - head;
|
|
2235
|
+
const omitted = ownerOutput.length - head - tail;
|
|
2236
|
+
ownerSnippet =
|
|
2237
|
+
`${ownerOutput.slice(0, head)}\n` +
|
|
2238
|
+
`...[truncated ${omitted} chars for review]...\n` +
|
|
2239
|
+
`${ownerOutput.slice(ownerOutput.length - tail)}`;
|
|
2240
|
+
}
|
|
2241
|
+
const reviewTask = `Review workflow step "${step.name}" for completion and safe handoff.\n` +
|
|
2242
|
+
`Step owner: ${ownerDef.name}\n` +
|
|
2243
|
+
`Original objective:\n${resolvedTask}\n\n` +
|
|
2244
|
+
`Specialist output:\n${specialistSnippet}\n\n` +
|
|
2245
|
+
`Owner verification notes:\n${ownerSnippet}\n\n` +
|
|
2246
|
+
`Return exactly:\n` +
|
|
2247
|
+
`REVIEW_DECISION: APPROVE or REJECT\n` +
|
|
2248
|
+
`REVIEW_REASON: <one sentence>\n` +
|
|
2249
|
+
`Then output /exit.`;
|
|
2250
|
+
const safetyTimeoutMs = timeoutMs ?? 600_000;
|
|
2251
|
+
const reviewStep = {
|
|
2252
|
+
name: `${step.name}-review`,
|
|
2253
|
+
type: 'agent',
|
|
2254
|
+
agent: reviewerDef.name,
|
|
2255
|
+
task: reviewTask,
|
|
2256
|
+
};
|
|
2257
|
+
await this.trajectory?.registerAgent(reviewerDef.name, 'reviewer');
|
|
2258
|
+
this.postToChannel(`**[${step.name}]** Review started (reviewer: ${reviewerDef.name})`);
|
|
2259
|
+
const emitReviewCompleted = async (decision, reason) => {
|
|
2260
|
+
await this.trajectory?.reviewCompleted(step.name, reviewerDef.name, decision, reason);
|
|
2261
|
+
this.emit({
|
|
2262
|
+
type: 'step:review-completed',
|
|
2263
|
+
runId: this.currentRunId ?? '',
|
|
2264
|
+
stepName: step.name,
|
|
2265
|
+
reviewerName: reviewerDef.name,
|
|
2266
|
+
decision,
|
|
2267
|
+
});
|
|
2268
|
+
};
|
|
2269
|
+
if (this.executor) {
|
|
2270
|
+
const reviewOutput = await this.executor.executeAgentStep(reviewStep, reviewerDef, reviewTask, safetyTimeoutMs);
|
|
2271
|
+
const parsed = this.parseReviewDecision(reviewOutput);
|
|
2272
|
+
if (!parsed) {
|
|
2273
|
+
throw new Error(`Step "${step.name}" review response malformed from "${reviewerDef.name}" (missing REVIEW_DECISION)`);
|
|
2274
|
+
}
|
|
2275
|
+
await emitReviewCompleted(parsed.decision, parsed.reason);
|
|
2276
|
+
if (parsed.decision === 'rejected') {
|
|
2277
|
+
throw new Error(`Step "${step.name}" review rejected by "${reviewerDef.name}"`);
|
|
2278
|
+
}
|
|
2279
|
+
this.postToChannel(`**[${step.name}]** Review approved by \`${reviewerDef.name}\``);
|
|
2280
|
+
return reviewOutput;
|
|
2281
|
+
}
|
|
2282
|
+
let reviewerHandle;
|
|
2283
|
+
let reviewerReleased = false;
|
|
2284
|
+
let reviewOutput = '';
|
|
2285
|
+
let completedReview;
|
|
2286
|
+
let reviewCompletionPromise;
|
|
2287
|
+
const reviewCompletionStarted = { value: false };
|
|
2288
|
+
const startReviewCompletion = (parsed) => {
|
|
2289
|
+
if (reviewCompletionStarted.value)
|
|
2290
|
+
return;
|
|
2291
|
+
reviewCompletionStarted.value = true;
|
|
2292
|
+
completedReview = parsed;
|
|
2293
|
+
reviewCompletionPromise = (async () => {
|
|
2294
|
+
await emitReviewCompleted(parsed.decision, parsed.reason);
|
|
2295
|
+
if (reviewerHandle && !reviewerReleased) {
|
|
2296
|
+
reviewerReleased = true;
|
|
2297
|
+
await reviewerHandle.release().catch(() => undefined);
|
|
2298
|
+
}
|
|
2299
|
+
})();
|
|
2300
|
+
};
|
|
2301
|
+
try {
|
|
2302
|
+
reviewOutput = await this.spawnAndWait(reviewerDef, reviewStep, safetyTimeoutMs, {
|
|
2303
|
+
onSpawned: ({ agent }) => {
|
|
2304
|
+
reviewerHandle = agent;
|
|
2305
|
+
},
|
|
2306
|
+
onChunk: ({ chunk }) => {
|
|
2307
|
+
const nextOutput = reviewOutput + WorkflowRunner.stripAnsi(chunk);
|
|
2308
|
+
reviewOutput = nextOutput;
|
|
2309
|
+
const parsed = this.parseReviewDecision(nextOutput);
|
|
2310
|
+
if (parsed) {
|
|
2311
|
+
startReviewCompletion(parsed);
|
|
2312
|
+
}
|
|
2313
|
+
},
|
|
2314
|
+
});
|
|
2315
|
+
await reviewCompletionPromise;
|
|
2316
|
+
}
|
|
2317
|
+
catch (error) {
|
|
2318
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2319
|
+
if (/\btimed out\b/i.test(message)) {
|
|
2320
|
+
this.log(`[${step.name}] Review safety backstop timeout fired after ${safetyTimeoutMs}ms`);
|
|
2321
|
+
throw new Error(`Step "${step.name}" review safety backstop timed out after ${safetyTimeoutMs}ms`);
|
|
2322
|
+
}
|
|
2323
|
+
throw error;
|
|
2324
|
+
}
|
|
2325
|
+
if (!completedReview) {
|
|
2326
|
+
const parsed = this.parseReviewDecision(reviewOutput);
|
|
2327
|
+
if (!parsed) {
|
|
2328
|
+
throw new Error(`Step "${step.name}" review response malformed from "${reviewerDef.name}" (missing REVIEW_DECISION)`);
|
|
2329
|
+
}
|
|
2330
|
+
completedReview = parsed;
|
|
2331
|
+
await emitReviewCompleted(parsed.decision, parsed.reason);
|
|
2332
|
+
}
|
|
2333
|
+
if (completedReview.decision === 'rejected') {
|
|
2334
|
+
throw new Error(`Step "${step.name}" review rejected by "${reviewerDef.name}"`);
|
|
2335
|
+
}
|
|
2336
|
+
this.postToChannel(`**[${step.name}]** Review approved by \`${reviewerDef.name}\``);
|
|
2337
|
+
return reviewOutput;
|
|
2338
|
+
}
|
|
2339
|
+
parseReviewDecision(reviewOutput) {
|
|
2340
|
+
const decisionPattern = /REVIEW_DECISION:\s*(APPROVE|REJECT)/gi;
|
|
2341
|
+
const decisionMatches = [...reviewOutput.matchAll(decisionPattern)];
|
|
2342
|
+
if (decisionMatches.length === 0) {
|
|
2343
|
+
return null;
|
|
2344
|
+
}
|
|
2345
|
+
const outputLikelyContainsEchoedPrompt = reviewOutput.includes('Return exactly') || reviewOutput.includes('REVIEW_DECISION: APPROVE or REJECT');
|
|
2346
|
+
const decisionMatch = outputLikelyContainsEchoedPrompt && decisionMatches.length > 1
|
|
2347
|
+
? decisionMatches[decisionMatches.length - 1]
|
|
2348
|
+
: decisionMatches[0];
|
|
2349
|
+
const decision = decisionMatch?.[1]?.toUpperCase();
|
|
2350
|
+
if (decision !== 'APPROVE' && decision !== 'REJECT') {
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
const reasonPattern = /REVIEW_REASON:\s*(.+)/gi;
|
|
2354
|
+
const reasonMatches = [...reviewOutput.matchAll(reasonPattern)];
|
|
2355
|
+
const reasonMatch = outputLikelyContainsEchoedPrompt && reasonMatches.length > 1
|
|
2356
|
+
? reasonMatches[reasonMatches.length - 1]
|
|
2357
|
+
: reasonMatches[0];
|
|
2358
|
+
const reason = reasonMatch?.[1]?.trim();
|
|
2359
|
+
return {
|
|
2360
|
+
decision: decision === 'APPROVE' ? 'approved' : 'rejected',
|
|
2361
|
+
reason: reason && reason !== '<one sentence>' ? reason : undefined,
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
combineStepAndReviewOutput(stepOutput, reviewOutput) {
|
|
2365
|
+
const primary = stepOutput.trimEnd();
|
|
2366
|
+
const review = reviewOutput.trim();
|
|
2367
|
+
if (!review)
|
|
2368
|
+
return primary;
|
|
2369
|
+
if (!primary)
|
|
2370
|
+
return `REVIEW_OUTPUT\n${review}\n`;
|
|
2371
|
+
return `${primary}\n\n---\nREVIEW_OUTPUT\n${review}\n`;
|
|
2372
|
+
}
|
|
1758
2373
|
/**
|
|
1759
2374
|
* Build the CLI command and arguments for a non-interactive agent execution.
|
|
1760
2375
|
* Each CLI has a specific flag for one-shot prompt mode.
|
|
@@ -1882,7 +2497,7 @@ export class WorkflowRunner {
|
|
|
1882
2497
|
const output = await new Promise((resolve, reject) => {
|
|
1883
2498
|
const child = cpSpawn(cmd, args, {
|
|
1884
2499
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1885
|
-
cwd:
|
|
2500
|
+
cwd: this.resolveAgentCwd(agentDef),
|
|
1886
2501
|
env: this.getRelayEnv() ?? { ...process.env },
|
|
1887
2502
|
});
|
|
1888
2503
|
// Update workers.json with PID now that we have it
|
|
@@ -1978,7 +2593,7 @@ export class WorkflowRunner {
|
|
|
1978
2593
|
this.unregisterWorker(agentName);
|
|
1979
2594
|
}
|
|
1980
2595
|
}
|
|
1981
|
-
async spawnAndWait(agentDef, step, timeoutMs) {
|
|
2596
|
+
async spawnAndWait(agentDef, step, timeoutMs, options = {}) {
|
|
1982
2597
|
// Branch: non-interactive agents run as simple subprocesses
|
|
1983
2598
|
if (agentDef.interactive === false) {
|
|
1984
2599
|
return this.execNonInteractive(agentDef, step, timeoutMs);
|
|
@@ -1986,13 +2601,15 @@ export class WorkflowRunner {
|
|
|
1986
2601
|
if (!this.relay) {
|
|
1987
2602
|
throw new Error('AgentRelay not initialized');
|
|
1988
2603
|
}
|
|
1989
|
-
// Deterministic name: step name + first 8 chars of run ID.
|
|
1990
|
-
|
|
2604
|
+
// Deterministic name: step name + optional role suffix + first 8 chars of run ID.
|
|
2605
|
+
const requestedName = `${step.name}${options.agentNameSuffix ? `-${options.agentNameSuffix}` : ''}-${(this.currentRunId ?? this.generateShortId()).slice(0, 8)}`;
|
|
2606
|
+
let agentName = requestedName;
|
|
1991
2607
|
// Only inject delegation guidance for lead/coordinator agents, not spokes/workers.
|
|
1992
2608
|
// In non-hub patterns (pipeline, dag, etc.) every agent is autonomous so they all get it.
|
|
1993
2609
|
const role = agentDef.role?.toLowerCase() ?? '';
|
|
1994
2610
|
const nameLC = agentDef.name.toLowerCase();
|
|
1995
|
-
const isHub = WorkflowRunner.HUB_ROLES.has(nameLC) ||
|
|
2611
|
+
const isHub = WorkflowRunner.HUB_ROLES.has(nameLC) ||
|
|
2612
|
+
[...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`).test(role));
|
|
1996
2613
|
const pattern = this.currentConfig?.swarm.pattern;
|
|
1997
2614
|
const isHubPattern = pattern && WorkflowRunner.HUB_PATTERNS.has(pattern);
|
|
1998
2615
|
const delegationGuidance = isHub || !isHubPattern ? this.buildDelegationGuidance(agentDef.cli, timeoutMs) : '';
|
|
@@ -2021,6 +2638,7 @@ export class WorkflowRunner {
|
|
|
2021
2638
|
// Write raw output (with ANSI codes) to log file so dashboard's
|
|
2022
2639
|
// XTermLogViewer can render colors/formatting natively via xterm.js
|
|
2023
2640
|
logStream.write(chunk);
|
|
2641
|
+
options.onChunk?.({ agentName, chunk });
|
|
2024
2642
|
});
|
|
2025
2643
|
const agentChannels = this.channel ? [this.channel] : agentDef.channels;
|
|
2026
2644
|
let agent;
|
|
@@ -2028,6 +2646,7 @@ export class WorkflowRunner {
|
|
|
2028
2646
|
let stopHeartbeat;
|
|
2029
2647
|
let ptyChunks = [];
|
|
2030
2648
|
try {
|
|
2649
|
+
const agentCwd = this.resolveAgentCwd(agentDef);
|
|
2031
2650
|
agent = await this.relay.spawnPty({
|
|
2032
2651
|
name: agentName,
|
|
2033
2652
|
cli: agentDef.cli,
|
|
@@ -2036,7 +2655,7 @@ export class WorkflowRunner {
|
|
|
2036
2655
|
channels: agentChannels,
|
|
2037
2656
|
task: taskWithExit,
|
|
2038
2657
|
idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
|
|
2039
|
-
cwd:
|
|
2658
|
+
cwd: agentCwd !== this.cwd ? agentCwd : undefined,
|
|
2040
2659
|
});
|
|
2041
2660
|
// Re-key PTY maps if broker assigned a different name than requested
|
|
2042
2661
|
if (agent.name !== agentName) {
|
|
@@ -2068,10 +2687,12 @@ export class WorkflowRunner {
|
|
|
2068
2687
|
const stripped = WorkflowRunner.stripAnsi(chunk);
|
|
2069
2688
|
this.ptyOutputBuffers.get(agent.name)?.push(stripped);
|
|
2070
2689
|
newLogStream.write(chunk);
|
|
2690
|
+
options.onChunk?.({ agentName: agent.name, chunk });
|
|
2071
2691
|
});
|
|
2072
2692
|
}
|
|
2073
2693
|
agentName = agent.name;
|
|
2074
2694
|
}
|
|
2695
|
+
await options.onSpawned?.({ requestedName, actualName: agent.name, agent });
|
|
2075
2696
|
// Register in workers.json so `agents:kill` can find this agent
|
|
2076
2697
|
let workerPid;
|
|
2077
2698
|
try {
|
|
@@ -2141,6 +2762,7 @@ export class WorkflowRunner {
|
|
|
2141
2762
|
this.ptyLogStreams.delete(agentName);
|
|
2142
2763
|
}
|
|
2143
2764
|
this.unregisterWorker(agentName);
|
|
2765
|
+
this.supervisedRuntimeAgents.delete(agentName);
|
|
2144
2766
|
}
|
|
2145
2767
|
let output;
|
|
2146
2768
|
if (ptyChunks.length > 0) {
|
|
@@ -2298,7 +2920,7 @@ export class WorkflowRunner {
|
|
|
2298
2920
|
const role = agentDef.role?.toLowerCase() ?? '';
|
|
2299
2921
|
const nameLC = agentDef.name.toLowerCase();
|
|
2300
2922
|
if (WorkflowRunner.HUB_ROLES.has(nameLC) ||
|
|
2301
|
-
[...WorkflowRunner.HUB_ROLES].some((r) =>
|
|
2923
|
+
[...WorkflowRunner.HUB_ROLES].some((r) => new RegExp(`\\b${r}\\b`).test(role))) {
|
|
2302
2924
|
// Found a hub candidate — check if we have a live handle
|
|
2303
2925
|
const handle = this.activeAgentHandles.get(agentDef.name);
|
|
2304
2926
|
if (handle)
|