agent-relay 3.2.18 → 3.2.22
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/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +233 -55
- package/dist/src/cli/commands/cloud.d.ts +1 -9
- package/dist/src/cli/commands/cloud.d.ts.map +1 -1
- package/dist/src/cli/commands/cloud.js +326 -323
- package/dist/src/cli/commands/cloud.js.map +1 -1
- package/dist/src/cli/commands/connect.d.ts.map +1 -1
- package/dist/src/cli/commands/connect.js +6 -10
- package/dist/src/cli/commands/connect.js.map +1 -1
- package/package.json +16 -10
- package/packages/acp-bridge/package.json +2 -2
- package/packages/brand/README.md +36 -0
- package/packages/brand/brand.css +226 -0
- package/packages/brand/package.json +20 -0
- package/packages/cloud/dist/api-client.d.ts +33 -0
- package/packages/cloud/dist/api-client.d.ts.map +1 -0
- package/packages/cloud/dist/api-client.js +123 -0
- package/packages/cloud/dist/api-client.js.map +1 -0
- package/packages/cloud/dist/auth.d.ts +13 -0
- package/packages/cloud/dist/auth.d.ts.map +1 -0
- package/packages/cloud/dist/auth.js +248 -0
- package/packages/cloud/dist/auth.js.map +1 -0
- package/packages/cloud/dist/index.d.ts +5 -0
- package/packages/cloud/dist/index.d.ts.map +1 -0
- package/packages/cloud/dist/index.js +5 -0
- package/packages/cloud/dist/index.js.map +1 -0
- package/packages/cloud/dist/types.d.ts +73 -0
- package/packages/cloud/dist/types.d.ts.map +1 -0
- package/packages/cloud/dist/types.js +19 -0
- package/packages/cloud/dist/types.js.map +1 -0
- package/packages/cloud/dist/workflows.d.ts +34 -0
- package/packages/cloud/dist/workflows.d.ts.map +1 -0
- package/packages/cloud/dist/workflows.js +389 -0
- package/packages/cloud/dist/workflows.js.map +1 -0
- package/packages/cloud/package.json +44 -0
- package/packages/cloud/src/api-client.ts +169 -0
- package/packages/cloud/src/auth.ts +314 -0
- package/packages/cloud/src/index.ts +41 -0
- package/packages/cloud/src/types.ts +97 -0
- package/packages/cloud/src/workflows.ts +539 -0
- package/packages/cloud/tsconfig.json +21 -0
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts +2 -0
- package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts.map +1 -0
- package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js +62 -0
- package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js.map +1 -0
- package/packages/sdk/dist/workflows/cli.js +46 -2
- package/packages/sdk/dist/workflows/cli.js.map +1 -1
- package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
- package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/file-db.js +20 -3
- package/packages/sdk/dist/workflows/file-db.js.map +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts +10 -1
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +233 -50
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
- package/packages/sdk/src/__tests__/workflow-runner.test.ts +73 -2
- package/packages/sdk/src/workflows/__tests__/e2big-and-verify.test.ts +117 -0
- package/packages/sdk/src/workflows/cli.ts +53 -2
- package/packages/sdk/src/workflows/file-db.ts +22 -3
- package/packages/sdk/src/workflows/runner.ts +283 -49
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +2 -0
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { spawn as cpSpawn } from 'node:child_process';
|
|
7
7
|
import { randomBytes } from 'node:crypto';
|
|
8
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
9
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { createWriteStream, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
9
|
+
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import chalk from 'chalk';
|
|
12
13
|
import { parse as parseYaml } from 'yaml';
|
|
@@ -114,6 +115,7 @@ export class WorkflowRunner {
|
|
|
114
115
|
activeReviewers = new Map();
|
|
115
116
|
/** Structured CLI session reports captured during the current run, keyed by step name. */
|
|
116
117
|
agentReports = new Map();
|
|
118
|
+
static PTY_TASK_ARG_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB
|
|
117
119
|
constructor(options = {}) {
|
|
118
120
|
this.db = options.db ?? new InMemoryWorkflowDb();
|
|
119
121
|
this.workspaceId = options.workspaceId ?? 'local';
|
|
@@ -1461,34 +1463,46 @@ export class WorkflowRunner {
|
|
|
1461
1463
|
});
|
|
1462
1464
|
}
|
|
1463
1465
|
/** Resume a previously paused or partially completed run. */
|
|
1464
|
-
async resume(runId, vars) {
|
|
1466
|
+
async resume(runId, vars, config) {
|
|
1465
1467
|
// Set up abort controller early so callers can abort() even during setup
|
|
1466
1468
|
this.abortController = new AbortController();
|
|
1467
1469
|
this.paused = false;
|
|
1468
|
-
|
|
1470
|
+
let run = await this.db.getRun(runId);
|
|
1471
|
+
let stepStates = new Map();
|
|
1469
1472
|
if (!run) {
|
|
1470
|
-
|
|
1473
|
+
const reconstructed = this.reconstructRunFromCache(runId, config);
|
|
1474
|
+
if (!reconstructed) {
|
|
1475
|
+
throw new Error(`Run "${runId}" not found (no database entry or cached step outputs)`);
|
|
1476
|
+
}
|
|
1477
|
+
this.log('[resume] Reconstructing run from cached step outputs (workflow-runs.jsonl missing)');
|
|
1478
|
+
run = reconstructed.run;
|
|
1479
|
+
stepStates = reconstructed.stepStates;
|
|
1480
|
+
await this.db.insertRun(run);
|
|
1481
|
+
for (const [, state] of stepStates) {
|
|
1482
|
+
await this.db.insertStep(state.row);
|
|
1483
|
+
}
|
|
1471
1484
|
}
|
|
1472
1485
|
this.persistRunIdHint(runId);
|
|
1473
1486
|
if (run.status !== 'running' && run.status !== 'failed') {
|
|
1474
1487
|
throw new Error(`Run "${runId}" is in status "${run.status}" and cannot be resumed`);
|
|
1475
1488
|
}
|
|
1476
|
-
const
|
|
1489
|
+
const resolvedConfig = vars ? this.resolveVariables(run.config, vars) : run.config;
|
|
1477
1490
|
// Resolve path definitions (same as execute()) so workdir lookups work on resume
|
|
1478
|
-
const pathResult = this.resolvePathDefinitions(
|
|
1491
|
+
const pathResult = this.resolvePathDefinitions(resolvedConfig.paths, this.cwd);
|
|
1479
1492
|
if (pathResult.errors.length > 0) {
|
|
1480
1493
|
throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
|
|
1481
1494
|
}
|
|
1482
1495
|
this.resolvedPaths = pathResult.resolved;
|
|
1483
|
-
const workflows =
|
|
1496
|
+
const workflows = resolvedConfig.workflows ?? [];
|
|
1484
1497
|
const workflow = workflows.find((w) => w.name === run.workflowName);
|
|
1485
1498
|
if (!workflow) {
|
|
1486
1499
|
throw new Error(`Workflow "${run.workflowName}" not found in stored config`);
|
|
1487
1500
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1501
|
+
if (stepStates.size === 0) {
|
|
1502
|
+
const existingSteps = await this.db.getStepsByRunId(runId);
|
|
1503
|
+
for (const stepRow of existingSteps) {
|
|
1504
|
+
stepStates.set(stepRow.stepName, { row: stepRow });
|
|
1505
|
+
}
|
|
1492
1506
|
}
|
|
1493
1507
|
// Reset failed steps to pending for retry
|
|
1494
1508
|
for (const [, state] of stepStates) {
|
|
@@ -1507,7 +1521,7 @@ export class WorkflowRunner {
|
|
|
1507
1521
|
return this.runWorkflowCore({
|
|
1508
1522
|
run,
|
|
1509
1523
|
workflow,
|
|
1510
|
-
config,
|
|
1524
|
+
config: resolvedConfig,
|
|
1511
1525
|
stepStates,
|
|
1512
1526
|
isResume: true,
|
|
1513
1527
|
});
|
|
@@ -2783,6 +2797,7 @@ export class WorkflowRunner {
|
|
|
2783
2797
|
let ownerOutput;
|
|
2784
2798
|
let ownerElapsed;
|
|
2785
2799
|
let completionReason;
|
|
2800
|
+
let promptTaskText;
|
|
2786
2801
|
if (usesDedicatedOwner) {
|
|
2787
2802
|
const result = await this.executeSupervisedAgentStep(step, { specialist: effectiveSpecialist, owner: effectiveOwner, reviewer: reviewDef }, resolvedTask, timeoutMs);
|
|
2788
2803
|
specialistOutput = result.specialistOutput;
|
|
@@ -2823,13 +2838,19 @@ export class WorkflowRunner {
|
|
|
2823
2838
|
: undefined,
|
|
2824
2839
|
});
|
|
2825
2840
|
const output = typeof spawnResult === 'string' ? spawnResult : spawnResult.output;
|
|
2841
|
+
promptTaskText =
|
|
2842
|
+
typeof spawnResult === 'string'
|
|
2843
|
+
? effectiveOwner.interactive === false
|
|
2844
|
+
? undefined
|
|
2845
|
+
: ownerTask
|
|
2846
|
+
: spawnResult.promptTaskText ?? ownerTask;
|
|
2826
2847
|
lastExitCode = typeof spawnResult === 'string' ? undefined : spawnResult.exitCode;
|
|
2827
2848
|
lastExitSignal = typeof spawnResult === 'string' ? undefined : spawnResult.exitSignal;
|
|
2828
2849
|
ownerElapsed = Date.now() - ownerStartTime;
|
|
2829
2850
|
this.log(`[${step.name}] Owner "${effectiveOwner.name}" exited`);
|
|
2830
2851
|
if (usesOwnerFlow) {
|
|
2831
2852
|
try {
|
|
2832
|
-
const completionDecision = this.resolveOwnerCompletionDecision(step, output, output, ownerTask,
|
|
2853
|
+
const completionDecision = this.resolveOwnerCompletionDecision(step, output, output, promptTaskText ?? ownerTask, promptTaskText ?? ownerTask);
|
|
2833
2854
|
completionReason = completionDecision.completionReason;
|
|
2834
2855
|
}
|
|
2835
2856
|
catch (error) {
|
|
@@ -2864,7 +2885,7 @@ export class WorkflowRunner {
|
|
|
2864
2885
|
// Self-owned interactive steps still need verification fallback so
|
|
2865
2886
|
// explicit OWNER_DECISION output is not mandatory for the happy path.
|
|
2866
2887
|
if (step.verification && (!usesOwnerFlow || !usesDedicatedOwner) && !completionReason) {
|
|
2867
|
-
const verificationResult = this.runVerification(step.verification, specialistOutput, step.name,
|
|
2888
|
+
const verificationResult = this.runVerification(step.verification, specialistOutput, step.name, promptTaskText);
|
|
2868
2889
|
completionReason = verificationResult.completionReason;
|
|
2869
2890
|
}
|
|
2870
2891
|
// Retry-style owner decisions are control-flow signals, not terminal success states.
|
|
@@ -3120,7 +3141,8 @@ export class WorkflowRunner {
|
|
|
3120
3141
|
detail: `Worker ${workerRuntimeName} exited`,
|
|
3121
3142
|
raw: { worker: workerRuntimeName, exitCode: result.exitCode, exitSignal: result.exitSignal },
|
|
3122
3143
|
});
|
|
3123
|
-
if (step.verification?.type === 'output_contains' &&
|
|
3144
|
+
if (step.verification?.type === 'output_contains' &&
|
|
3145
|
+
this.outputContainsVerificationToken(result.output, step.verification.value, result.promptTaskText)) {
|
|
3124
3146
|
this.log(`[${step.name}] Verification gate observed: output contains ${JSON.stringify(step.verification.value)}`);
|
|
3125
3147
|
}
|
|
3126
3148
|
})
|
|
@@ -3163,8 +3185,9 @@ export class WorkflowRunner {
|
|
|
3163
3185
|
const ownerElapsed = Date.now() - ownerStartTime;
|
|
3164
3186
|
const ownerOutput = ownerResultObj.output;
|
|
3165
3187
|
this.log(`[${step.name}] Owner "${supervised.owner.name}" exited`);
|
|
3166
|
-
const
|
|
3167
|
-
const
|
|
3188
|
+
const workerResultObj = await workerPromise;
|
|
3189
|
+
const specialistOutput = workerResultObj.output;
|
|
3190
|
+
const completionDecision = this.resolveOwnerCompletionDecision(step, ownerOutput, specialistOutput, ownerResultObj.promptTaskText ?? supervisorTask, workerResultObj.promptTaskText ?? specialistTask);
|
|
3168
3191
|
return {
|
|
3169
3192
|
specialistOutput,
|
|
3170
3193
|
ownerOutput,
|
|
@@ -3379,6 +3402,10 @@ export class WorkflowRunner {
|
|
|
3379
3402
|
}
|
|
3380
3403
|
hasOwnerCompletionMarker(step, output, injectedTaskText) {
|
|
3381
3404
|
const marker = `STEP_COMPLETE:${step.name}`;
|
|
3405
|
+
const strippedOutput = this.stripInjectedTaskEcho(output, injectedTaskText);
|
|
3406
|
+
if (strippedOutput.includes(marker)) {
|
|
3407
|
+
return true;
|
|
3408
|
+
}
|
|
3382
3409
|
const taskHasMarker = injectedTaskText.includes(marker);
|
|
3383
3410
|
const first = output.indexOf(marker);
|
|
3384
3411
|
if (first === -1) {
|
|
@@ -3451,6 +3478,49 @@ export class WorkflowRunner {
|
|
|
3451
3478
|
.filter((line) => patterns.every((pattern) => !pattern.test(line)))
|
|
3452
3479
|
.join('\n');
|
|
3453
3480
|
}
|
|
3481
|
+
stripInjectedTaskEcho(output, injectedTaskText) {
|
|
3482
|
+
if (!injectedTaskText) {
|
|
3483
|
+
return output;
|
|
3484
|
+
}
|
|
3485
|
+
const candidates = [
|
|
3486
|
+
injectedTaskText,
|
|
3487
|
+
injectedTaskText.replace(/\r\n/g, '\n'),
|
|
3488
|
+
injectedTaskText.replace(/\n/g, '\r\n'),
|
|
3489
|
+
].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
|
|
3490
|
+
for (const candidate of candidates) {
|
|
3491
|
+
const start = output.indexOf(candidate);
|
|
3492
|
+
if (start !== -1) {
|
|
3493
|
+
return output.slice(0, start) + output.slice(start + candidate.length);
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
return output;
|
|
3497
|
+
}
|
|
3498
|
+
outputContainsVerificationToken(output, token, injectedTaskText) {
|
|
3499
|
+
if (!token) {
|
|
3500
|
+
return false;
|
|
3501
|
+
}
|
|
3502
|
+
return this.stripInjectedTaskEcho(output, injectedTaskText).includes(token);
|
|
3503
|
+
}
|
|
3504
|
+
prepareInteractiveSpawnTask(agentName, taskText) {
|
|
3505
|
+
if (Buffer.byteLength(taskText, 'utf8') <= WorkflowRunner.PTY_TASK_ARG_SIZE_LIMIT) {
|
|
3506
|
+
return {
|
|
3507
|
+
spawnTaskText: taskText,
|
|
3508
|
+
promptTaskText: taskText,
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
const taskTmpDir = mkdtempSync(path.join(tmpdir(), 'relay-pty-task-'));
|
|
3512
|
+
const taskTmpFile = path.join(taskTmpDir, `${agentName}-${Date.now()}.txt`);
|
|
3513
|
+
writeFileSync(taskTmpFile, taskText, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
3514
|
+
const promptTaskText = `TASK_FILE:${taskTmpFile}\n` +
|
|
3515
|
+
'Read that file completely before taking any action.\n' +
|
|
3516
|
+
'Treat the file contents as the full workflow task and follow them exactly.\n' +
|
|
3517
|
+
'Do not ask for the task again.';
|
|
3518
|
+
return {
|
|
3519
|
+
spawnTaskText: promptTaskText,
|
|
3520
|
+
promptTaskText,
|
|
3521
|
+
taskTmpFile,
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3454
3524
|
firstMeaningfulLine(output) {
|
|
3455
3525
|
return output
|
|
3456
3526
|
.split('\n')
|
|
@@ -4044,6 +4114,7 @@ export class WorkflowRunner {
|
|
|
4044
4114
|
'(b) outputting the exact text "/exit" on its own line as a fallback. ' +
|
|
4045
4115
|
'Do not wait for further input — terminate immediately after finishing. ' +
|
|
4046
4116
|
'Do NOT spawn sub-agents unless the task explicitly requires it.';
|
|
4117
|
+
const preparedTask = this.prepareInteractiveSpawnTask(agentName, taskWithExit);
|
|
4047
4118
|
// Register PTY output listener before spawning so we capture everything
|
|
4048
4119
|
this.ptyOutputBuffers.set(agentName, []);
|
|
4049
4120
|
// Open a log file so `agents:logs <name>` works for workflow-spawned agents
|
|
@@ -4077,7 +4148,7 @@ export class WorkflowRunner {
|
|
|
4077
4148
|
model: agentDef.constraints?.model,
|
|
4078
4149
|
args: interactiveSpawnPolicy.args,
|
|
4079
4150
|
channels: agentChannels,
|
|
4080
|
-
task:
|
|
4151
|
+
task: preparedTask.spawnTaskText,
|
|
4081
4152
|
idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
|
|
4082
4153
|
cwd: agentCwd,
|
|
4083
4154
|
});
|
|
@@ -4159,7 +4230,7 @@ export class WorkflowRunner {
|
|
|
4159
4230
|
// Register agent handle for hub-mediated nudging
|
|
4160
4231
|
this.activeAgentHandles.set(agentName, agent);
|
|
4161
4232
|
// Wait for agent to exit, with idle nudging if configured
|
|
4162
|
-
exitResult = await this.waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, options.preserveOnIdle ?? this.shouldPreserveIdleSupervisor(agentDef, step, options.evidenceRole));
|
|
4233
|
+
exitResult = await this.waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, preparedTask.promptTaskText, options.preserveOnIdle ?? this.shouldPreserveIdleSupervisor(agentDef, step, options.evidenceRole));
|
|
4163
4234
|
// Stop heartbeat now that agent has exited
|
|
4164
4235
|
stopHeartbeat?.();
|
|
4165
4236
|
if (exitResult === 'timeout') {
|
|
@@ -4169,7 +4240,7 @@ export class WorkflowRunner {
|
|
|
4169
4240
|
let timeoutRecovered = false;
|
|
4170
4241
|
if (step.verification) {
|
|
4171
4242
|
const ptyOutput = (this.ptyOutputBuffers.get(agentName) ?? []).join('');
|
|
4172
|
-
const verificationResult = this.runVerification(step.verification, ptyOutput, step.name,
|
|
4243
|
+
const verificationResult = this.runVerification(step.verification, ptyOutput, step.name, preparedTask.promptTaskText, { allowFailure: true });
|
|
4173
4244
|
if (verificationResult.passed) {
|
|
4174
4245
|
this.log(`[${step.name}] Agent timed out but verification passed — treating as complete`);
|
|
4175
4246
|
this.postToChannel(`**[${step.name}]** Agent idle after completing work — verification passed, releasing`);
|
|
@@ -4216,6 +4287,9 @@ export class WorkflowRunner {
|
|
|
4216
4287
|
this.unregisterWorker(agentName);
|
|
4217
4288
|
this.supervisedRuntimeAgents.delete(agentName);
|
|
4218
4289
|
this.runtimeStepAgents.delete(agentName);
|
|
4290
|
+
if (preparedTask.taskTmpFile) {
|
|
4291
|
+
await unlink(preparedTask.taskTmpFile).catch(() => undefined);
|
|
4292
|
+
}
|
|
4219
4293
|
}
|
|
4220
4294
|
let output;
|
|
4221
4295
|
if (ptyChunks.length > 0) {
|
|
@@ -4243,6 +4317,7 @@ export class WorkflowRunner {
|
|
|
4243
4317
|
output,
|
|
4244
4318
|
exitCode: agent?.exitCode,
|
|
4245
4319
|
exitSignal: agent?.exitSignal,
|
|
4320
|
+
promptTaskText: preparedTask.promptTaskText,
|
|
4246
4321
|
};
|
|
4247
4322
|
}
|
|
4248
4323
|
// ── Idle nudging ────────────────────────────────────────────────────────
|
|
@@ -4288,7 +4363,7 @@ export class WorkflowRunner {
|
|
|
4288
4363
|
* Wait for agent exit with idle detection and nudging.
|
|
4289
4364
|
* If no idle nudge config is set, falls through to simple waitForExit.
|
|
4290
4365
|
*/
|
|
4291
|
-
async waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, preserveIdleSupervisor = false) {
|
|
4366
|
+
async waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, promptTaskText, preserveIdleSupervisor = false) {
|
|
4292
4367
|
const nudgeConfig = this.currentConfig?.swarm.idleNudge;
|
|
4293
4368
|
if (!nudgeConfig) {
|
|
4294
4369
|
if (preserveIdleSupervisor) {
|
|
@@ -4309,22 +4384,10 @@ export class WorkflowRunner {
|
|
|
4309
4384
|
]);
|
|
4310
4385
|
if (result.kind === 'idle' && result.result === 'idle') {
|
|
4311
4386
|
// Check verification before treating idle as complete.
|
|
4312
|
-
// Mirror runVerification's double-occurrence guard: if the task text
|
|
4313
|
-
// contains the token (from the prompt instruction), require a second
|
|
4314
|
-
// occurrence from the agent's actual output to avoid false positives.
|
|
4315
4387
|
if (step.verification && step.verification.type === 'output_contains') {
|
|
4316
4388
|
const token = step.verification.value;
|
|
4317
4389
|
const ptyOutput = (this.ptyOutputBuffers.get(agent.name) ?? []).join('');
|
|
4318
|
-
const
|
|
4319
|
-
const taskHasToken = taskText.includes(token);
|
|
4320
|
-
let verificationPassed = true;
|
|
4321
|
-
if (taskHasToken) {
|
|
4322
|
-
const first = ptyOutput.indexOf(token);
|
|
4323
|
-
verificationPassed = first !== -1 && ptyOutput.includes(token, first + token.length);
|
|
4324
|
-
}
|
|
4325
|
-
else {
|
|
4326
|
-
verificationPassed = ptyOutput.includes(token);
|
|
4327
|
-
}
|
|
4390
|
+
const verificationPassed = this.outputContainsVerificationToken(ptyOutput, token, promptTaskText);
|
|
4328
4391
|
if (!verificationPassed) {
|
|
4329
4392
|
// The broker fires agent_idle only once per idle transition.
|
|
4330
4393
|
// If the agent is still working (will produce output then idle again),
|
|
@@ -4504,22 +4567,8 @@ export class WorkflowRunner {
|
|
|
4504
4567
|
};
|
|
4505
4568
|
switch (check.type) {
|
|
4506
4569
|
case 'output_contains': {
|
|
4507
|
-
// Guard against false positives: the PTY captures the injected task text
|
|
4508
|
-
// verbatim, so if the verification token appears in the task itself the
|
|
4509
|
-
// check would pass immediately without the agent doing any real work.
|
|
4510
|
-
// When the task contains the token, require a SECOND occurrence — one
|
|
4511
|
-
// from the task injection and one from the agent's actual response.
|
|
4512
4570
|
const token = check.value;
|
|
4513
|
-
|
|
4514
|
-
if (taskHasToken) {
|
|
4515
|
-
const first = output.indexOf(token);
|
|
4516
|
-
const hasSecond = first !== -1 && output.includes(token, first + token.length);
|
|
4517
|
-
if (!hasSecond) {
|
|
4518
|
-
return fail(`Verification failed for "${stepName}": output does not contain "${token}" ` +
|
|
4519
|
-
`(token found only in task injection — agent must output it explicitly)`);
|
|
4520
|
-
}
|
|
4521
|
-
}
|
|
4522
|
-
else if (!output.includes(token)) {
|
|
4571
|
+
if (!this.outputContainsVerificationToken(output, token, injectedTaskText)) {
|
|
4523
4572
|
return fail(`Verification failed for "${stepName}": output does not contain "${token}"`);
|
|
4524
4573
|
}
|
|
4525
4574
|
break;
|
|
@@ -5096,8 +5145,15 @@ export class WorkflowRunner {
|
|
|
5096
5145
|
.replace(/-+/g, '-')
|
|
5097
5146
|
.slice(0, 32);
|
|
5098
5147
|
}
|
|
5148
|
+
/** Validate that a runId is safe for use in file paths (no traversal). */
|
|
5149
|
+
validateRunId(runId) {
|
|
5150
|
+
if (/[/\\]|^\.\.?$/.test(runId) || runId.includes('..')) {
|
|
5151
|
+
throw new Error(`Invalid runId: "${runId}" contains path traversal characters`);
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5099
5154
|
/** Directory for persisted step outputs: .agent-relay/step-outputs/{runId}/ */
|
|
5100
5155
|
getStepOutputDir(runId) {
|
|
5156
|
+
this.validateRunId(runId);
|
|
5101
5157
|
return path.join(this.cwd, '.agent-relay', 'step-outputs', runId);
|
|
5102
5158
|
}
|
|
5103
5159
|
/** Persist step output to disk and post full output as a channel message. */
|
|
@@ -5182,6 +5238,133 @@ export class WorkflowRunner {
|
|
|
5182
5238
|
return undefined;
|
|
5183
5239
|
}
|
|
5184
5240
|
}
|
|
5241
|
+
/** Match the best workflow from config given a set of cached step names. */
|
|
5242
|
+
matchWorkflowFromCache(workflows, cachedStepNames) {
|
|
5243
|
+
if (workflows.length === 1)
|
|
5244
|
+
return workflows[0];
|
|
5245
|
+
if (cachedStepNames.size === 0) {
|
|
5246
|
+
// No cached steps to disambiguate — ambiguous when multiple workflows exist
|
|
5247
|
+
this.log('[resume] Multiple workflows in config with empty cache — cannot disambiguate');
|
|
5248
|
+
return null;
|
|
5249
|
+
}
|
|
5250
|
+
// Score each workflow by how many cached steps match, excluding those with unknown steps
|
|
5251
|
+
const scored = workflows
|
|
5252
|
+
.map((candidate) => ({
|
|
5253
|
+
workflow: candidate,
|
|
5254
|
+
matchedSteps: candidate.steps.filter((step) => cachedStepNames.has(step.name)).length,
|
|
5255
|
+
unknownSteps: [...cachedStepNames].filter((name) => !candidate.steps.some((step) => step.name === name)).length,
|
|
5256
|
+
}))
|
|
5257
|
+
.filter((candidate) => candidate.unknownSteps === 0)
|
|
5258
|
+
.sort((a, b) => b.matchedSteps - a.matchedSteps);
|
|
5259
|
+
return scored[0]?.workflow ?? null;
|
|
5260
|
+
}
|
|
5261
|
+
reconstructRunFromCache(runId, config) {
|
|
5262
|
+
const stepOutputDir = this.getStepOutputDir(runId);
|
|
5263
|
+
if (!existsSync(stepOutputDir))
|
|
5264
|
+
return null;
|
|
5265
|
+
let resumeConfig = config ?? this.currentConfig;
|
|
5266
|
+
if (!resumeConfig) {
|
|
5267
|
+
// Attempt to load config from relay.yaml on disk (resume() may call before runWorkflowCore sets currentConfig)
|
|
5268
|
+
const yamlPath = path.join(this.cwd, 'relay.yaml');
|
|
5269
|
+
if (existsSync(yamlPath)) {
|
|
5270
|
+
try {
|
|
5271
|
+
const raw = readFileSync(yamlPath, 'utf-8');
|
|
5272
|
+
resumeConfig = this.parseYamlString(raw, yamlPath);
|
|
5273
|
+
}
|
|
5274
|
+
catch {
|
|
5275
|
+
return null;
|
|
5276
|
+
}
|
|
5277
|
+
}
|
|
5278
|
+
else {
|
|
5279
|
+
return null;
|
|
5280
|
+
}
|
|
5281
|
+
}
|
|
5282
|
+
let entries;
|
|
5283
|
+
try {
|
|
5284
|
+
entries = readdirSync(stepOutputDir, { withFileTypes: true });
|
|
5285
|
+
}
|
|
5286
|
+
catch {
|
|
5287
|
+
return null;
|
|
5288
|
+
}
|
|
5289
|
+
const cachedStepNames = new Set(entries
|
|
5290
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
5291
|
+
.map((entry) => entry.name.slice(0, -3))
|
|
5292
|
+
.filter(Boolean));
|
|
5293
|
+
const workflows = resumeConfig.workflows ?? [];
|
|
5294
|
+
if (workflows.length === 0)
|
|
5295
|
+
return null;
|
|
5296
|
+
// Empty cache directory is valid — all steps will be re-run
|
|
5297
|
+
const workflow = this.matchWorkflowFromCache(workflows, cachedStepNames);
|
|
5298
|
+
if (!workflow)
|
|
5299
|
+
return null;
|
|
5300
|
+
// Use actual file modification times from cached outputs instead of synthetic timestamps
|
|
5301
|
+
const stepMtimes = new Map();
|
|
5302
|
+
let earliestMtime = Date.now();
|
|
5303
|
+
for (const stepName of cachedStepNames) {
|
|
5304
|
+
try {
|
|
5305
|
+
const mdPath = path.join(stepOutputDir, `${stepName}.md`);
|
|
5306
|
+
const reportPath = path.join(stepOutputDir, `${stepName}.report.json`);
|
|
5307
|
+
const mdStat = existsSync(mdPath) ? statSync(mdPath) : null;
|
|
5308
|
+
const reportStat = existsSync(reportPath) ? statSync(reportPath) : null;
|
|
5309
|
+
// Use the latest mtime between .md and .report.json
|
|
5310
|
+
const mtime = Math.max(mdStat?.mtimeMs ?? 0, reportStat?.mtimeMs ?? 0);
|
|
5311
|
+
if (mtime > 0) {
|
|
5312
|
+
stepMtimes.set(stepName, new Date(mtime).toISOString());
|
|
5313
|
+
if (mtime < earliestMtime)
|
|
5314
|
+
earliestMtime = mtime;
|
|
5315
|
+
}
|
|
5316
|
+
}
|
|
5317
|
+
catch {
|
|
5318
|
+
// Fall back to current time if stat fails
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
const fallbackTime = new Date().toISOString();
|
|
5322
|
+
const completedSteps = new Set(workflow.steps.filter((step) => cachedStepNames.has(step.name)).map((step) => step.name));
|
|
5323
|
+
// Heuristic: mark the first eligible non-completed step as failed (the likely failure point)
|
|
5324
|
+
const failedStepName = workflow.steps.find((step) => !completedSteps.has(step.name) && (step.dependsOn ?? []).every((dep) => completedSteps.has(dep)))?.name;
|
|
5325
|
+
const runStartedAt = new Date(earliestMtime).toISOString();
|
|
5326
|
+
const run = {
|
|
5327
|
+
id: runId,
|
|
5328
|
+
workspaceId: this.workspaceId,
|
|
5329
|
+
workflowName: workflow.name,
|
|
5330
|
+
pattern: resumeConfig.swarm.pattern,
|
|
5331
|
+
status: 'failed',
|
|
5332
|
+
config: resumeConfig,
|
|
5333
|
+
startedAt: runStartedAt,
|
|
5334
|
+
createdAt: runStartedAt,
|
|
5335
|
+
updatedAt: fallbackTime,
|
|
5336
|
+
};
|
|
5337
|
+
const stepStates = new Map();
|
|
5338
|
+
for (const step of workflow.steps) {
|
|
5339
|
+
const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
|
|
5340
|
+
const cachedOutput = completedSteps.has(step.name) ? this.loadStepOutput(runId, step.name) : undefined;
|
|
5341
|
+
const status = completedSteps.has(step.name) ? 'completed' : step.name === failedStepName ? 'failed' : 'pending';
|
|
5342
|
+
const stepRow = {
|
|
5343
|
+
id: this.generateId(),
|
|
5344
|
+
runId,
|
|
5345
|
+
stepName: step.name,
|
|
5346
|
+
agentName: isNonAgent ? null : (step.agent ?? null),
|
|
5347
|
+
stepType: isNonAgent ? step.type : 'agent',
|
|
5348
|
+
status,
|
|
5349
|
+
task: step.type === 'deterministic'
|
|
5350
|
+
? (step.command ?? '')
|
|
5351
|
+
: step.type === 'worktree'
|
|
5352
|
+
? (step.branch ?? '')
|
|
5353
|
+
: step.type === 'integration'
|
|
5354
|
+
? (`${step.integration}.${step.action}`)
|
|
5355
|
+
: (step.task ?? ''),
|
|
5356
|
+
dependsOn: step.dependsOn ?? [],
|
|
5357
|
+
output: cachedOutput,
|
|
5358
|
+
error: status === 'failed' ? 'Recovered from cached step outputs' : undefined,
|
|
5359
|
+
completedAt: status === 'completed' ? (stepMtimes.get(step.name) ?? fallbackTime) : undefined,
|
|
5360
|
+
retryCount: 0,
|
|
5361
|
+
createdAt: stepMtimes.get(step.name) ?? fallbackTime,
|
|
5362
|
+
updatedAt: stepMtimes.get(step.name) ?? fallbackTime,
|
|
5363
|
+
};
|
|
5364
|
+
stepStates.set(step.name, { row: stepRow });
|
|
5365
|
+
}
|
|
5366
|
+
return { run, stepStates };
|
|
5367
|
+
}
|
|
5185
5368
|
/** Get or create the worker logs directory (.agent-relay/team/worker-logs) */
|
|
5186
5369
|
getWorkerLogsDir() {
|
|
5187
5370
|
const logsDir = path.join(this.cwd, '.agent-relay', 'team', 'worker-logs');
|