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
|
@@ -52,6 +52,21 @@ type ExecuteOptions = {
|
|
|
52
52
|
previousRunId?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/** Flags that consume the next argument as their value. Single source of truth for CLI parsing. */
|
|
56
|
+
const FLAGS_WITH_VALUES = new Set(['--resume', '--workflow', '--start-from', '--previous-run-id']);
|
|
57
|
+
|
|
58
|
+
function getYamlPathArg(args: string[]): string | undefined {
|
|
59
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
60
|
+
const arg = args[i];
|
|
61
|
+
if (arg.startsWith('--')) {
|
|
62
|
+
if (FLAGS_WITH_VALUES.has(arg)) i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
return arg;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
interface RenderableTask {
|
|
56
71
|
output?: string;
|
|
57
72
|
title: string;
|
|
@@ -302,6 +317,7 @@ async function runWithListr(
|
|
|
302
317
|
|
|
303
318
|
async function main(): Promise<void> {
|
|
304
319
|
const args = process.argv.slice(2);
|
|
320
|
+
const yamlPath = getYamlPathArg(args);
|
|
305
321
|
|
|
306
322
|
if (args.length === 0 || args.includes('--help')) {
|
|
307
323
|
printUsage();
|
|
@@ -358,7 +374,37 @@ async function main(): Promise<void> {
|
|
|
358
374
|
break;
|
|
359
375
|
}
|
|
360
376
|
});
|
|
361
|
-
|
|
377
|
+
let result: RunnerResult;
|
|
378
|
+
try {
|
|
379
|
+
const resumeConfig = yamlPath ? await runner.parseYamlFile(yamlPath) : undefined;
|
|
380
|
+
if (resumeConfig) {
|
|
381
|
+
console.warn(
|
|
382
|
+
chalk.yellow(
|
|
383
|
+
'[workflow] warning: resuming with current config from disk — ' +
|
|
384
|
+
'if the workflow YAML changed since the original run, behaviour may differ'
|
|
385
|
+
)
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
result = await runner.resume(runId, undefined, resumeConfig);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
391
|
+
const isRunNotFound = message.startsWith(`Run "${runId}" not found`);
|
|
392
|
+
if (isRunNotFound) {
|
|
393
|
+
if (fileDb.hasStepOutputs(runId)) {
|
|
394
|
+
console.error(
|
|
395
|
+
chalk.red(
|
|
396
|
+
`Error: ${message}. Step outputs exist for this run, but persisted run state is missing from ${dbPath}. ` +
|
|
397
|
+
`Use --start-from with --previous-run-id ${runId} to recover from the cached step outputs instead.`
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
} else {
|
|
401
|
+
console.error(chalk.red(`Error: ${message}`));
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
console.error(chalk.red(`Error: ${message}`));
|
|
405
|
+
}
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
362
408
|
|
|
363
409
|
if (result.status === 'completed') {
|
|
364
410
|
console.log(chalk.green('\nWorkflow completed successfully.'));
|
|
@@ -371,7 +417,6 @@ async function main(): Promise<void> {
|
|
|
371
417
|
}
|
|
372
418
|
|
|
373
419
|
// ── Normal / validate / dry-run mode ──────────────────────────────────────
|
|
374
|
-
const yamlPath = args[0];
|
|
375
420
|
let workflowName: string | undefined;
|
|
376
421
|
|
|
377
422
|
const workflowIdx = args.indexOf('--workflow');
|
|
@@ -391,6 +436,12 @@ async function main(): Promise<void> {
|
|
|
391
436
|
previousRunId = args[prevRunIdx + 1];
|
|
392
437
|
}
|
|
393
438
|
|
|
439
|
+
if (!yamlPath) {
|
|
440
|
+
console.error(chalk.red('Error: workflow YAML path is required'));
|
|
441
|
+
printUsage();
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
394
445
|
const isValidate = args.includes('--validate');
|
|
395
446
|
const isDryRun = !!process.env.DRY_RUN;
|
|
396
447
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import type { WorkflowRunRow, WorkflowStepRow } from './types.js';
|
|
@@ -24,6 +24,7 @@ export class JsonFileWorkflowDb implements WorkflowDb {
|
|
|
24
24
|
|
|
25
25
|
/** Whether the storage directory is writable. False = silent no-op mode. */
|
|
26
26
|
private readonly writable: boolean;
|
|
27
|
+
private appendFailedOnce = false;
|
|
27
28
|
|
|
28
29
|
constructor(filePath: string) {
|
|
29
30
|
this.filePath = filePath;
|
|
@@ -43,14 +44,32 @@ export class JsonFileWorkflowDb implements WorkflowDb {
|
|
|
43
44
|
return this.writable;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
hasStepOutputs(runId: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
const dir = path.join(path.dirname(this.filePath), 'step-outputs', runId);
|
|
50
|
+
return existsSync(dir) && readdirSync(dir).length > 0;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
// ── Private helpers ─────────────────────────────────────────────────────
|
|
47
57
|
|
|
48
58
|
private append(entry: DbEntry): void {
|
|
49
59
|
if (!this.writable) return;
|
|
50
60
|
try {
|
|
51
61
|
appendFileSync(this.filePath, JSON.stringify(entry) + '\n', 'utf8');
|
|
52
|
-
} catch {
|
|
53
|
-
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (!this.appendFailedOnce) {
|
|
64
|
+
this.appendFailedOnce = true;
|
|
65
|
+
console.warn(
|
|
66
|
+
'[workflow] warning: failed to write run state to ' +
|
|
67
|
+
this.filePath +
|
|
68
|
+
' — --resume will not be available for this run. Use --start-from instead. ' +
|
|
69
|
+
'Error: ' +
|
|
70
|
+
(err instanceof Error ? err.message : String(err))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
54
73
|
}
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -9,6 +9,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
9
9
|
import {
|
|
10
10
|
createWriteStream,
|
|
11
11
|
existsSync,
|
|
12
|
+
mkdtempSync,
|
|
12
13
|
mkdirSync,
|
|
13
14
|
readFileSync,
|
|
14
15
|
readdirSync,
|
|
@@ -17,7 +18,8 @@ import {
|
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from 'node:fs';
|
|
19
20
|
import type { Dirent, WriteStream } from 'node:fs';
|
|
20
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
21
|
+
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
21
23
|
import path from 'node:path';
|
|
22
24
|
import chalk from 'chalk';
|
|
23
25
|
|
|
@@ -97,6 +99,7 @@ interface SpawnResult {
|
|
|
97
99
|
output: string;
|
|
98
100
|
exitCode?: number;
|
|
99
101
|
exitSignal?: string;
|
|
102
|
+
promptTaskText?: string;
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/** Error carrying exit code/signal from a failed subprocess spawn. */
|
|
@@ -364,6 +367,7 @@ export class WorkflowRunner {
|
|
|
364
367
|
private readonly activeReviewers = new Map<string, number>();
|
|
365
368
|
/** Structured CLI session reports captured during the current run, keyed by step name. */
|
|
366
369
|
private readonly agentReports = new Map<string, CliSessionReport>();
|
|
370
|
+
private static readonly PTY_TASK_ARG_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB
|
|
367
371
|
|
|
368
372
|
constructor(options: WorkflowRunnerOptions = {}) {
|
|
369
373
|
this.db = options.db ?? new InMemoryWorkflowDb();
|
|
@@ -1948,14 +1952,25 @@ export class WorkflowRunner {
|
|
|
1948
1952
|
}
|
|
1949
1953
|
|
|
1950
1954
|
/** Resume a previously paused or partially completed run. */
|
|
1951
|
-
async resume(runId: string, vars?: VariableContext): Promise<WorkflowRunRow> {
|
|
1955
|
+
async resume(runId: string, vars?: VariableContext, config?: RelayYamlConfig): Promise<WorkflowRunRow> {
|
|
1952
1956
|
// Set up abort controller early so callers can abort() even during setup
|
|
1953
1957
|
this.abortController = new AbortController();
|
|
1954
1958
|
this.paused = false;
|
|
1955
1959
|
|
|
1956
|
-
|
|
1960
|
+
let run = await this.db.getRun(runId);
|
|
1961
|
+
let stepStates = new Map<string, StepState>();
|
|
1957
1962
|
if (!run) {
|
|
1958
|
-
|
|
1963
|
+
const reconstructed = this.reconstructRunFromCache(runId, config);
|
|
1964
|
+
if (!reconstructed) {
|
|
1965
|
+
throw new Error(`Run "${runId}" not found (no database entry or cached step outputs)`);
|
|
1966
|
+
}
|
|
1967
|
+
this.log('[resume] Reconstructing run from cached step outputs (workflow-runs.jsonl missing)');
|
|
1968
|
+
run = reconstructed.run;
|
|
1969
|
+
stepStates = reconstructed.stepStates;
|
|
1970
|
+
await this.db.insertRun(run);
|
|
1971
|
+
for (const [, state] of stepStates) {
|
|
1972
|
+
await this.db.insertStep(state.row);
|
|
1973
|
+
}
|
|
1959
1974
|
}
|
|
1960
1975
|
this.persistRunIdHint(runId);
|
|
1961
1976
|
|
|
@@ -1963,25 +1978,26 @@ export class WorkflowRunner {
|
|
|
1963
1978
|
throw new Error(`Run "${runId}" is in status "${run.status}" and cannot be resumed`);
|
|
1964
1979
|
}
|
|
1965
1980
|
|
|
1966
|
-
const
|
|
1981
|
+
const resolvedConfig = vars ? this.resolveVariables(run.config, vars) : run.config;
|
|
1967
1982
|
|
|
1968
1983
|
// Resolve path definitions (same as execute()) so workdir lookups work on resume
|
|
1969
|
-
const pathResult = this.resolvePathDefinitions(
|
|
1984
|
+
const pathResult = this.resolvePathDefinitions(resolvedConfig.paths, this.cwd);
|
|
1970
1985
|
if (pathResult.errors.length > 0) {
|
|
1971
1986
|
throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
|
|
1972
1987
|
}
|
|
1973
1988
|
this.resolvedPaths = pathResult.resolved;
|
|
1974
1989
|
|
|
1975
|
-
const workflows =
|
|
1990
|
+
const workflows = resolvedConfig.workflows ?? [];
|
|
1976
1991
|
const workflow = workflows.find((w) => w.name === run.workflowName);
|
|
1977
1992
|
if (!workflow) {
|
|
1978
1993
|
throw new Error(`Workflow "${run.workflowName}" not found in stored config`);
|
|
1979
1994
|
}
|
|
1980
1995
|
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1996
|
+
if (stepStates.size === 0) {
|
|
1997
|
+
const existingSteps = await this.db.getStepsByRunId(runId);
|
|
1998
|
+
for (const stepRow of existingSteps) {
|
|
1999
|
+
stepStates.set(stepRow.stepName, { row: stepRow });
|
|
2000
|
+
}
|
|
1985
2001
|
}
|
|
1986
2002
|
|
|
1987
2003
|
// Reset failed steps to pending for retry
|
|
@@ -2002,7 +2018,7 @@ export class WorkflowRunner {
|
|
|
2002
2018
|
return this.runWorkflowCore({
|
|
2003
2019
|
run,
|
|
2004
2020
|
workflow,
|
|
2005
|
-
config,
|
|
2021
|
+
config: resolvedConfig,
|
|
2006
2022
|
stepStates,
|
|
2007
2023
|
isResume: true,
|
|
2008
2024
|
});
|
|
@@ -3539,6 +3555,7 @@ export class WorkflowRunner {
|
|
|
3539
3555
|
let ownerOutput: string;
|
|
3540
3556
|
let ownerElapsed: number;
|
|
3541
3557
|
let completionReason: WorkflowStepCompletionReason | undefined;
|
|
3558
|
+
let promptTaskText: string | undefined;
|
|
3542
3559
|
|
|
3543
3560
|
if (usesDedicatedOwner) {
|
|
3544
3561
|
const result = await this.executeSupervisedAgentStep(
|
|
@@ -3592,6 +3609,12 @@ export class WorkflowRunner {
|
|
|
3592
3609
|
: undefined,
|
|
3593
3610
|
});
|
|
3594
3611
|
const output = typeof spawnResult === 'string' ? spawnResult : spawnResult.output;
|
|
3612
|
+
promptTaskText =
|
|
3613
|
+
typeof spawnResult === 'string'
|
|
3614
|
+
? effectiveOwner.interactive === false
|
|
3615
|
+
? undefined
|
|
3616
|
+
: ownerTask
|
|
3617
|
+
: spawnResult.promptTaskText ?? ownerTask;
|
|
3595
3618
|
lastExitCode = typeof spawnResult === 'string' ? undefined : spawnResult.exitCode;
|
|
3596
3619
|
lastExitSignal = typeof spawnResult === 'string' ? undefined : spawnResult.exitSignal;
|
|
3597
3620
|
ownerElapsed = Date.now() - ownerStartTime;
|
|
@@ -3602,8 +3625,8 @@ export class WorkflowRunner {
|
|
|
3602
3625
|
step,
|
|
3603
3626
|
output,
|
|
3604
3627
|
output,
|
|
3605
|
-
ownerTask,
|
|
3606
|
-
|
|
3628
|
+
promptTaskText ?? ownerTask,
|
|
3629
|
+
promptTaskText ?? ownerTask
|
|
3607
3630
|
);
|
|
3608
3631
|
completionReason = completionDecision.completionReason;
|
|
3609
3632
|
} catch (error) {
|
|
@@ -3654,7 +3677,7 @@ export class WorkflowRunner {
|
|
|
3654
3677
|
step.verification,
|
|
3655
3678
|
specialistOutput,
|
|
3656
3679
|
step.name,
|
|
3657
|
-
|
|
3680
|
+
promptTaskText
|
|
3658
3681
|
);
|
|
3659
3682
|
completionReason = verificationResult.completionReason;
|
|
3660
3683
|
}
|
|
@@ -4028,7 +4051,14 @@ export class WorkflowRunner {
|
|
|
4028
4051
|
detail: `Worker ${workerRuntimeName} exited`,
|
|
4029
4052
|
raw: { worker: workerRuntimeName, exitCode: result.exitCode, exitSignal: result.exitSignal },
|
|
4030
4053
|
});
|
|
4031
|
-
if (
|
|
4054
|
+
if (
|
|
4055
|
+
step.verification?.type === 'output_contains' &&
|
|
4056
|
+
this.outputContainsVerificationToken(
|
|
4057
|
+
result.output,
|
|
4058
|
+
step.verification.value,
|
|
4059
|
+
result.promptTaskText
|
|
4060
|
+
)
|
|
4061
|
+
) {
|
|
4032
4062
|
this.log(
|
|
4033
4063
|
`[${step.name}] Verification gate observed: output contains ${JSON.stringify(step.verification.value)}`
|
|
4034
4064
|
);
|
|
@@ -4079,13 +4109,14 @@ export class WorkflowRunner {
|
|
|
4079
4109
|
const ownerElapsed = Date.now() - ownerStartTime;
|
|
4080
4110
|
const ownerOutput = ownerResultObj.output;
|
|
4081
4111
|
this.log(`[${step.name}] Owner "${supervised.owner.name}" exited`);
|
|
4082
|
-
const
|
|
4112
|
+
const workerResultObj = await workerPromise;
|
|
4113
|
+
const specialistOutput = workerResultObj.output;
|
|
4083
4114
|
const completionDecision = this.resolveOwnerCompletionDecision(
|
|
4084
4115
|
step,
|
|
4085
4116
|
ownerOutput,
|
|
4086
4117
|
specialistOutput,
|
|
4087
|
-
supervisorTask,
|
|
4088
|
-
|
|
4118
|
+
ownerResultObj.promptTaskText ?? supervisorTask,
|
|
4119
|
+
workerResultObj.promptTaskText ?? specialistTask
|
|
4089
4120
|
);
|
|
4090
4121
|
return {
|
|
4091
4122
|
specialistOutput,
|
|
@@ -4359,6 +4390,10 @@ export class WorkflowRunner {
|
|
|
4359
4390
|
injectedTaskText: string
|
|
4360
4391
|
): boolean {
|
|
4361
4392
|
const marker = `STEP_COMPLETE:${step.name}`;
|
|
4393
|
+
const strippedOutput = this.stripInjectedTaskEcho(output, injectedTaskText);
|
|
4394
|
+
if (strippedOutput.includes(marker)) {
|
|
4395
|
+
return true;
|
|
4396
|
+
}
|
|
4362
4397
|
const taskHasMarker = injectedTaskText.includes(marker);
|
|
4363
4398
|
const first = output.indexOf(marker);
|
|
4364
4399
|
if (first === -1) {
|
|
@@ -4448,6 +4483,65 @@ export class WorkflowRunner {
|
|
|
4448
4483
|
.join('\n');
|
|
4449
4484
|
}
|
|
4450
4485
|
|
|
4486
|
+
private stripInjectedTaskEcho(output: string, injectedTaskText?: string): string {
|
|
4487
|
+
if (!injectedTaskText) {
|
|
4488
|
+
return output;
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
const candidates = [
|
|
4492
|
+
injectedTaskText,
|
|
4493
|
+
injectedTaskText.replace(/\r\n/g, '\n'),
|
|
4494
|
+
injectedTaskText.replace(/\n/g, '\r\n'),
|
|
4495
|
+
].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
|
|
4496
|
+
|
|
4497
|
+
for (const candidate of candidates) {
|
|
4498
|
+
const start = output.indexOf(candidate);
|
|
4499
|
+
if (start !== -1) {
|
|
4500
|
+
return output.slice(0, start) + output.slice(start + candidate.length);
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
return output;
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4507
|
+
private outputContainsVerificationToken(
|
|
4508
|
+
output: string,
|
|
4509
|
+
token: string,
|
|
4510
|
+
injectedTaskText?: string
|
|
4511
|
+
): boolean {
|
|
4512
|
+
if (!token) {
|
|
4513
|
+
return false;
|
|
4514
|
+
}
|
|
4515
|
+
return this.stripInjectedTaskEcho(output, injectedTaskText).includes(token);
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
private prepareInteractiveSpawnTask(
|
|
4519
|
+
agentName: string,
|
|
4520
|
+
taskText: string
|
|
4521
|
+
): { spawnTaskText: string; promptTaskText: string; taskTmpFile?: string } {
|
|
4522
|
+
if (Buffer.byteLength(taskText, 'utf8') <= WorkflowRunner.PTY_TASK_ARG_SIZE_LIMIT) {
|
|
4523
|
+
return {
|
|
4524
|
+
spawnTaskText: taskText,
|
|
4525
|
+
promptTaskText: taskText,
|
|
4526
|
+
};
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
const taskTmpDir = mkdtempSync(path.join(tmpdir(), 'relay-pty-task-'));
|
|
4530
|
+
const taskTmpFile = path.join(taskTmpDir, `${agentName}-${Date.now()}.txt`);
|
|
4531
|
+
writeFileSync(taskTmpFile, taskText, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
4532
|
+
const promptTaskText =
|
|
4533
|
+
`TASK_FILE:${taskTmpFile}\n` +
|
|
4534
|
+
'Read that file completely before taking any action.\n' +
|
|
4535
|
+
'Treat the file contents as the full workflow task and follow them exactly.\n' +
|
|
4536
|
+
'Do not ask for the task again.';
|
|
4537
|
+
|
|
4538
|
+
return {
|
|
4539
|
+
spawnTaskText: promptTaskText,
|
|
4540
|
+
promptTaskText,
|
|
4541
|
+
taskTmpFile,
|
|
4542
|
+
};
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4451
4545
|
private firstMeaningfulLine(output: string): string | undefined {
|
|
4452
4546
|
return output
|
|
4453
4547
|
.split('\n')
|
|
@@ -5218,6 +5312,7 @@ export class WorkflowRunner {
|
|
|
5218
5312
|
'(b) outputting the exact text "/exit" on its own line as a fallback. ' +
|
|
5219
5313
|
'Do not wait for further input — terminate immediately after finishing. ' +
|
|
5220
5314
|
'Do NOT spawn sub-agents unless the task explicitly requires it.';
|
|
5315
|
+
const preparedTask = this.prepareInteractiveSpawnTask(agentName, taskWithExit);
|
|
5221
5316
|
|
|
5222
5317
|
// Register PTY output listener before spawning so we capture everything
|
|
5223
5318
|
this.ptyOutputBuffers.set(agentName, []);
|
|
@@ -5257,7 +5352,7 @@ export class WorkflowRunner {
|
|
|
5257
5352
|
model: agentDef.constraints?.model,
|
|
5258
5353
|
args: interactiveSpawnPolicy.args,
|
|
5259
5354
|
channels: agentChannels,
|
|
5260
|
-
task:
|
|
5355
|
+
task: preparedTask.spawnTaskText,
|
|
5261
5356
|
idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
|
|
5262
5357
|
cwd: agentCwd,
|
|
5263
5358
|
});
|
|
@@ -5368,6 +5463,7 @@ export class WorkflowRunner {
|
|
|
5368
5463
|
agentDef,
|
|
5369
5464
|
step,
|
|
5370
5465
|
timeoutMs,
|
|
5466
|
+
preparedTask.promptTaskText,
|
|
5371
5467
|
options.preserveOnIdle ?? this.shouldPreserveIdleSupervisor(agentDef, step, options.evidenceRole)
|
|
5372
5468
|
);
|
|
5373
5469
|
|
|
@@ -5385,7 +5481,7 @@ export class WorkflowRunner {
|
|
|
5385
5481
|
step.verification,
|
|
5386
5482
|
ptyOutput,
|
|
5387
5483
|
step.name,
|
|
5388
|
-
|
|
5484
|
+
preparedTask.promptTaskText,
|
|
5389
5485
|
{ allowFailure: true }
|
|
5390
5486
|
);
|
|
5391
5487
|
if (verificationResult.passed) {
|
|
@@ -5446,6 +5542,9 @@ export class WorkflowRunner {
|
|
|
5446
5542
|
this.unregisterWorker(agentName);
|
|
5447
5543
|
this.supervisedRuntimeAgents.delete(agentName);
|
|
5448
5544
|
this.runtimeStepAgents.delete(agentName);
|
|
5545
|
+
if (preparedTask.taskTmpFile) {
|
|
5546
|
+
await unlink(preparedTask.taskTmpFile).catch(() => undefined);
|
|
5547
|
+
}
|
|
5449
5548
|
}
|
|
5450
5549
|
|
|
5451
5550
|
let output: string;
|
|
@@ -5480,6 +5579,7 @@ export class WorkflowRunner {
|
|
|
5480
5579
|
output,
|
|
5481
5580
|
exitCode: agent?.exitCode,
|
|
5482
5581
|
exitSignal: agent?.exitSignal,
|
|
5582
|
+
promptTaskText: preparedTask.promptTaskText,
|
|
5483
5583
|
};
|
|
5484
5584
|
}
|
|
5485
5585
|
|
|
@@ -5547,6 +5647,7 @@ export class WorkflowRunner {
|
|
|
5547
5647
|
agentDef: AgentDefinition,
|
|
5548
5648
|
step: WorkflowStep,
|
|
5549
5649
|
timeoutMs?: number,
|
|
5650
|
+
promptTaskText?: string,
|
|
5550
5651
|
preserveIdleSupervisor = false
|
|
5551
5652
|
): Promise<'exited' | 'timeout' | 'released' | 'force-released'> {
|
|
5552
5653
|
const nudgeConfig = this.currentConfig?.swarm.idleNudge;
|
|
@@ -5572,21 +5673,14 @@ export class WorkflowRunner {
|
|
|
5572
5673
|
]);
|
|
5573
5674
|
if (result.kind === 'idle' && result.result === 'idle') {
|
|
5574
5675
|
// Check verification before treating idle as complete.
|
|
5575
|
-
// Mirror runVerification's double-occurrence guard: if the task text
|
|
5576
|
-
// contains the token (from the prompt instruction), require a second
|
|
5577
|
-
// occurrence from the agent's actual output to avoid false positives.
|
|
5578
5676
|
if (step.verification && step.verification.type === 'output_contains') {
|
|
5579
5677
|
const token = step.verification.value;
|
|
5580
5678
|
const ptyOutput = (this.ptyOutputBuffers.get(agent.name) ?? []).join('');
|
|
5581
|
-
const
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
verificationPassed = first !== -1 && ptyOutput.includes(token, first + token.length);
|
|
5587
|
-
} else {
|
|
5588
|
-
verificationPassed = ptyOutput.includes(token);
|
|
5589
|
-
}
|
|
5679
|
+
const verificationPassed = this.outputContainsVerificationToken(
|
|
5680
|
+
ptyOutput,
|
|
5681
|
+
token,
|
|
5682
|
+
promptTaskText
|
|
5683
|
+
);
|
|
5590
5684
|
if (!verificationPassed) {
|
|
5591
5685
|
// The broker fires agent_idle only once per idle transition.
|
|
5592
5686
|
// If the agent is still working (will produce output then idle again),
|
|
@@ -5798,23 +5892,8 @@ export class WorkflowRunner {
|
|
|
5798
5892
|
|
|
5799
5893
|
switch (check.type) {
|
|
5800
5894
|
case 'output_contains': {
|
|
5801
|
-
// Guard against false positives: the PTY captures the injected task text
|
|
5802
|
-
// verbatim, so if the verification token appears in the task itself the
|
|
5803
|
-
// check would pass immediately without the agent doing any real work.
|
|
5804
|
-
// When the task contains the token, require a SECOND occurrence — one
|
|
5805
|
-
// from the task injection and one from the agent's actual response.
|
|
5806
5895
|
const token = check.value;
|
|
5807
|
-
|
|
5808
|
-
if (taskHasToken) {
|
|
5809
|
-
const first = output.indexOf(token);
|
|
5810
|
-
const hasSecond = first !== -1 && output.includes(token, first + token.length);
|
|
5811
|
-
if (!hasSecond) {
|
|
5812
|
-
return fail(
|
|
5813
|
-
`Verification failed for "${stepName}": output does not contain "${token}" ` +
|
|
5814
|
-
`(token found only in task injection — agent must output it explicitly)`
|
|
5815
|
-
);
|
|
5816
|
-
}
|
|
5817
|
-
} else if (!output.includes(token)) {
|
|
5896
|
+
if (!this.outputContainsVerificationToken(output, token, injectedTaskText)) {
|
|
5818
5897
|
return fail(`Verification failed for "${stepName}": output does not contain "${token}"`);
|
|
5819
5898
|
}
|
|
5820
5899
|
break;
|
|
@@ -6480,8 +6559,16 @@ export class WorkflowRunner {
|
|
|
6480
6559
|
.slice(0, 32);
|
|
6481
6560
|
}
|
|
6482
6561
|
|
|
6562
|
+
/** Validate that a runId is safe for use in file paths (no traversal). */
|
|
6563
|
+
private validateRunId(runId: string): void {
|
|
6564
|
+
if (/[/\\]|^\.\.?$/.test(runId) || runId.includes('..')) {
|
|
6565
|
+
throw new Error(`Invalid runId: "${runId}" contains path traversal characters`);
|
|
6566
|
+
}
|
|
6567
|
+
}
|
|
6568
|
+
|
|
6483
6569
|
/** Directory for persisted step outputs: .agent-relay/step-outputs/{runId}/ */
|
|
6484
6570
|
private getStepOutputDir(runId: string): string {
|
|
6571
|
+
this.validateRunId(runId);
|
|
6485
6572
|
return path.join(this.cwd, '.agent-relay', 'step-outputs', runId);
|
|
6486
6573
|
}
|
|
6487
6574
|
|
|
@@ -6571,6 +6658,153 @@ export class WorkflowRunner {
|
|
|
6571
6658
|
}
|
|
6572
6659
|
}
|
|
6573
6660
|
|
|
6661
|
+
/** Match the best workflow from config given a set of cached step names. */
|
|
6662
|
+
private matchWorkflowFromCache(
|
|
6663
|
+
workflows: WorkflowDefinition[],
|
|
6664
|
+
cachedStepNames: Set<string>
|
|
6665
|
+
): WorkflowDefinition | null {
|
|
6666
|
+
if (workflows.length === 1) return workflows[0];
|
|
6667
|
+
|
|
6668
|
+
if (cachedStepNames.size === 0) {
|
|
6669
|
+
// No cached steps to disambiguate — ambiguous when multiple workflows exist
|
|
6670
|
+
this.log('[resume] Multiple workflows in config with empty cache — cannot disambiguate');
|
|
6671
|
+
return null;
|
|
6672
|
+
}
|
|
6673
|
+
|
|
6674
|
+
// Score each workflow by how many cached steps match, excluding those with unknown steps
|
|
6675
|
+
const scored = workflows
|
|
6676
|
+
.map((candidate) => ({
|
|
6677
|
+
workflow: candidate,
|
|
6678
|
+
matchedSteps: candidate.steps.filter((step) => cachedStepNames.has(step.name)).length,
|
|
6679
|
+
unknownSteps: [...cachedStepNames].filter(
|
|
6680
|
+
(name) => !candidate.steps.some((step) => step.name === name)
|
|
6681
|
+
).length,
|
|
6682
|
+
}))
|
|
6683
|
+
.filter((candidate) => candidate.unknownSteps === 0)
|
|
6684
|
+
.sort((a, b) => b.matchedSteps - a.matchedSteps);
|
|
6685
|
+
|
|
6686
|
+
return scored[0]?.workflow ?? null;
|
|
6687
|
+
}
|
|
6688
|
+
|
|
6689
|
+
private reconstructRunFromCache(
|
|
6690
|
+
runId: string,
|
|
6691
|
+
config?: RelayYamlConfig
|
|
6692
|
+
): { run: WorkflowRunRow; stepStates: Map<string, StepState> } | null {
|
|
6693
|
+
const stepOutputDir = this.getStepOutputDir(runId);
|
|
6694
|
+
if (!existsSync(stepOutputDir)) return null;
|
|
6695
|
+
|
|
6696
|
+
let resumeConfig = config ?? this.currentConfig;
|
|
6697
|
+
if (!resumeConfig) {
|
|
6698
|
+
// Attempt to load config from relay.yaml on disk (resume() may call before runWorkflowCore sets currentConfig)
|
|
6699
|
+
const yamlPath = path.join(this.cwd, 'relay.yaml');
|
|
6700
|
+
if (existsSync(yamlPath)) {
|
|
6701
|
+
try {
|
|
6702
|
+
const raw = readFileSync(yamlPath, 'utf-8');
|
|
6703
|
+
resumeConfig = this.parseYamlString(raw, yamlPath);
|
|
6704
|
+
} catch {
|
|
6705
|
+
return null;
|
|
6706
|
+
}
|
|
6707
|
+
} else {
|
|
6708
|
+
return null;
|
|
6709
|
+
}
|
|
6710
|
+
}
|
|
6711
|
+
|
|
6712
|
+
let entries: Dirent[];
|
|
6713
|
+
try {
|
|
6714
|
+
entries = readdirSync(stepOutputDir, { withFileTypes: true });
|
|
6715
|
+
} catch {
|
|
6716
|
+
return null;
|
|
6717
|
+
}
|
|
6718
|
+
|
|
6719
|
+
const cachedStepNames = new Set(
|
|
6720
|
+
entries
|
|
6721
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
6722
|
+
.map((entry) => entry.name.slice(0, -3))
|
|
6723
|
+
.filter(Boolean)
|
|
6724
|
+
);
|
|
6725
|
+
const workflows = resumeConfig.workflows ?? [];
|
|
6726
|
+
if (workflows.length === 0) return null;
|
|
6727
|
+
|
|
6728
|
+
// Empty cache directory is valid — all steps will be re-run
|
|
6729
|
+
const workflow = this.matchWorkflowFromCache(workflows, cachedStepNames);
|
|
6730
|
+
if (!workflow) return null;
|
|
6731
|
+
|
|
6732
|
+
// Use actual file modification times from cached outputs instead of synthetic timestamps
|
|
6733
|
+
const stepMtimes = new Map<string, string>();
|
|
6734
|
+
let earliestMtime = Date.now();
|
|
6735
|
+
for (const stepName of cachedStepNames) {
|
|
6736
|
+
try {
|
|
6737
|
+
const mdPath = path.join(stepOutputDir, `${stepName}.md`);
|
|
6738
|
+
const reportPath = path.join(stepOutputDir, `${stepName}.report.json`);
|
|
6739
|
+
const mdStat = existsSync(mdPath) ? statSync(mdPath) : null;
|
|
6740
|
+
const reportStat = existsSync(reportPath) ? statSync(reportPath) : null;
|
|
6741
|
+
// Use the latest mtime between .md and .report.json
|
|
6742
|
+
const mtime = Math.max(mdStat?.mtimeMs ?? 0, reportStat?.mtimeMs ?? 0);
|
|
6743
|
+
if (mtime > 0) {
|
|
6744
|
+
stepMtimes.set(stepName, new Date(mtime).toISOString());
|
|
6745
|
+
if (mtime < earliestMtime) earliestMtime = mtime;
|
|
6746
|
+
}
|
|
6747
|
+
} catch {
|
|
6748
|
+
// Fall back to current time if stat fails
|
|
6749
|
+
}
|
|
6750
|
+
}
|
|
6751
|
+
const fallbackTime = new Date().toISOString();
|
|
6752
|
+
|
|
6753
|
+
const completedSteps = new Set(workflow.steps.filter((step) => cachedStepNames.has(step.name)).map((step) => step.name));
|
|
6754
|
+
// Heuristic: mark the first eligible non-completed step as failed (the likely failure point)
|
|
6755
|
+
const failedStepName = workflow.steps.find(
|
|
6756
|
+
(step) => !completedSteps.has(step.name) && (step.dependsOn ?? []).every((dep) => completedSteps.has(dep))
|
|
6757
|
+
)?.name;
|
|
6758
|
+
|
|
6759
|
+
const runStartedAt = new Date(earliestMtime).toISOString();
|
|
6760
|
+
const run: WorkflowRunRow = {
|
|
6761
|
+
id: runId,
|
|
6762
|
+
workspaceId: this.workspaceId,
|
|
6763
|
+
workflowName: workflow.name,
|
|
6764
|
+
pattern: resumeConfig.swarm.pattern,
|
|
6765
|
+
status: 'failed',
|
|
6766
|
+
config: resumeConfig,
|
|
6767
|
+
startedAt: runStartedAt,
|
|
6768
|
+
createdAt: runStartedAt,
|
|
6769
|
+
updatedAt: fallbackTime,
|
|
6770
|
+
};
|
|
6771
|
+
|
|
6772
|
+
const stepStates = new Map<string, StepState>();
|
|
6773
|
+
for (const step of workflow.steps) {
|
|
6774
|
+
const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
|
|
6775
|
+
const cachedOutput = completedSteps.has(step.name) ? this.loadStepOutput(runId, step.name) : undefined;
|
|
6776
|
+
const status: WorkflowStepStatus =
|
|
6777
|
+
completedSteps.has(step.name) ? 'completed' : step.name === failedStepName ? 'failed' : 'pending';
|
|
6778
|
+
|
|
6779
|
+
const stepRow: WorkflowStepRow = {
|
|
6780
|
+
id: this.generateId(),
|
|
6781
|
+
runId,
|
|
6782
|
+
stepName: step.name,
|
|
6783
|
+
agentName: isNonAgent ? null : (step.agent ?? null),
|
|
6784
|
+
stepType: isNonAgent ? (step.type as 'deterministic' | 'worktree' | 'integration') : 'agent',
|
|
6785
|
+
status,
|
|
6786
|
+
task:
|
|
6787
|
+
step.type === 'deterministic'
|
|
6788
|
+
? (step.command ?? '')
|
|
6789
|
+
: step.type === 'worktree'
|
|
6790
|
+
? (step.branch ?? '')
|
|
6791
|
+
: step.type === 'integration'
|
|
6792
|
+
? (`${step.integration}.${step.action}`)
|
|
6793
|
+
: (step.task ?? ''),
|
|
6794
|
+
dependsOn: step.dependsOn ?? [],
|
|
6795
|
+
output: cachedOutput,
|
|
6796
|
+
error: status === 'failed' ? 'Recovered from cached step outputs' : undefined,
|
|
6797
|
+
completedAt: status === 'completed' ? (stepMtimes.get(step.name) ?? fallbackTime) : undefined,
|
|
6798
|
+
retryCount: 0,
|
|
6799
|
+
createdAt: stepMtimes.get(step.name) ?? fallbackTime,
|
|
6800
|
+
updatedAt: stepMtimes.get(step.name) ?? fallbackTime,
|
|
6801
|
+
};
|
|
6802
|
+
stepStates.set(step.name, { row: stepRow });
|
|
6803
|
+
}
|
|
6804
|
+
|
|
6805
|
+
return { run, stepStates };
|
|
6806
|
+
}
|
|
6807
|
+
|
|
6574
6808
|
/** Get or create the worker logs directory (.agent-relay/team/worker-logs) */
|
|
6575
6809
|
private getWorkerLogsDir(): string {
|
|
6576
6810
|
const logsDir = path.join(this.cwd, '.agent-relay', 'team', 'worker-logs');
|
|
@@ -218,6 +218,8 @@ public final class RelayObserver: NSObject, URLSessionWebSocketDelegate, @unchec
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
private func _handleSocketError(_ error: Error) {
|
|
221
|
+
isConnectionReady = false
|
|
222
|
+
|
|
221
223
|
guard reconnectAttempts < maxReconnectAttempts else {
|
|
222
224
|
_connectionState = .disconnected
|
|
223
225
|
let delegate = self.delegate
|