@sweny-ai/core 0.1.164 → 0.1.166
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/agent-env.d.ts +130 -13
- package/dist/agent-env.js +276 -20
- package/dist/cli/config-file.js +2 -0
- package/dist/cli/main.d.ts +4 -1
- package/dist/cli/main.js +72 -3
- package/dist/cli/resume.d.ts +56 -0
- package/dist/cli/resume.js +182 -0
- package/dist/executor.d.ts +7 -0
- package/dist/executor.js +87 -11
- package/dist/harness/acp.d.ts +6 -1
- package/dist/harness/acp.js +35 -17
- package/dist/harness/claude-code.d.ts +12 -0
- package/dist/harness/claude-code.js +37 -14
- package/dist/harness/codex.d.ts +5 -1
- package/dist/harness/codex.js +30 -16
- package/dist/harness/pi.d.ts +17 -1
- package/dist/harness/pi.js +54 -16
- package/dist/harness/policy.js +16 -0
- package/dist/harness/types.d.ts +16 -0
- package/dist/journal.d.ts +307 -0
- package/dist/journal.js +885 -0
- package/dist/schema.d.ts +156 -136
- package/dist/schema.js +21 -0
- package/dist/types.d.ts +15 -3
- package/package.json +1 -1
package/dist/cli/main.js
CHANGED
|
@@ -34,6 +34,8 @@ import { nonInteractiveUsage, runNew } from "./new.js";
|
|
|
34
34
|
import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
|
|
35
35
|
import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
|
|
36
36
|
import { registerRunsCommand } from "./runs.js";
|
|
37
|
+
import { JournalLockedError, JournalMismatchError, RunJournal } from "../journal.js";
|
|
38
|
+
import { journalDisabled, prepareResume, RESUME_SHARED_RUN_FLAGS, WORKFLOW_RESUME_OPTIONS, } from "./resume.js";
|
|
37
39
|
import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
|
|
38
40
|
import { createVerboseToolObserver } from "./verbose-observer.js";
|
|
39
41
|
import { createRunLogger, renderReceiptLine, summarizeRun, writeStepSummary, WORKFLOW_RUN_DESCRIPTION, WORKFLOW_RUN_OPTIONS, } from "./run-output.js";
|
|
@@ -730,7 +732,9 @@ export function loadWorkflowFile(filePath, knownSkills) {
|
|
|
730
732
|
console.error(chalk.yellow(` \u26A0 ${w}`));
|
|
731
733
|
return result.workflow;
|
|
732
734
|
}
|
|
733
|
-
export async function workflowRunAction(file, options
|
|
735
|
+
export async function workflowRunAction(file, options,
|
|
736
|
+
/** Set by `sweny workflow resume` (#363): the journal, run id and original input. */
|
|
737
|
+
resume) {
|
|
734
738
|
if (options.decider !== undefined && options.decider !== "off" && options.decider !== "shadow") {
|
|
735
739
|
console.error(chalk.red(`\n --decider must be off or shadow, got "${options.decider}"\n`));
|
|
736
740
|
process.exit(1);
|
|
@@ -877,7 +881,10 @@ export async function workflowRunAction(file, options) {
|
|
|
877
881
|
};
|
|
878
882
|
// Build workflow input — prefer --input JSON if provided, else fall back to config-derived input
|
|
879
883
|
let workflowInput;
|
|
880
|
-
if (
|
|
884
|
+
if (resume) {
|
|
885
|
+
workflowInput = resume.input;
|
|
886
|
+
}
|
|
887
|
+
else if (options.input && typeof options.input === "string") {
|
|
881
888
|
const parsedInput = parseInputFlag(options.input);
|
|
882
889
|
if (!parsedInput.ok) {
|
|
883
890
|
for (const line of parsedInput.lines)
|
|
@@ -962,7 +969,18 @@ export async function workflowRunAction(file, options) {
|
|
|
962
969
|
}
|
|
963
970
|
// Run history (#388): metadata-only record under .sweny/runs/, written once at run end.
|
|
964
971
|
const nodeTimer = createNodeTimer();
|
|
965
|
-
const runId = newRunId(runStart);
|
|
972
|
+
const runId = resume?.runId ?? newRunId(runStart);
|
|
973
|
+
// Run journal (#363): what `sweny workflow resume <run-id>` continues from.
|
|
974
|
+
const journal = resume?.journal ??
|
|
975
|
+
(journalDisabled(options.journal, fileConfig["journal"])
|
|
976
|
+
? undefined
|
|
977
|
+
: RunJournal.create({
|
|
978
|
+
runId,
|
|
979
|
+
workflowFile: path.resolve(file),
|
|
980
|
+
swenyVersion: version,
|
|
981
|
+
env: process.env,
|
|
982
|
+
logger: runLogger,
|
|
983
|
+
}));
|
|
966
984
|
let historyRecorded = false;
|
|
967
985
|
const recordHistory = (results, trace, crashed) => {
|
|
968
986
|
if (historyRecorded || historyDisabled(options.history, fileConfig["history"]))
|
|
@@ -1011,11 +1029,13 @@ export async function workflowRunAction(file, options) {
|
|
|
1011
1029
|
max_steps: wfMaxSteps,
|
|
1012
1030
|
...(options.decider ? { decider: options.decider } : {}),
|
|
1013
1031
|
stageOutputs: options.stage === true,
|
|
1032
|
+
journal,
|
|
1014
1033
|
...(spendBudget ? { budget: spendBudget } : {}),
|
|
1015
1034
|
harnessPolicy: resolveHarnessPolicy(process.env, typeof options.harnessPolicy === "string" ? options.harnessPolicy : undefined, runLogger),
|
|
1016
1035
|
}), wfTimeoutMs, `Workflow ${workflow.name}`);
|
|
1017
1036
|
const wfDurationMs = Date.now() - runStart;
|
|
1018
1037
|
const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
|
|
1038
|
+
journal?.end(wfHasFailed ? "failed" : "success");
|
|
1019
1039
|
recordHistory(results, trace, false);
|
|
1020
1040
|
// Close the cloud lifecycle session BEFORE the JSON early-exit so
|
|
1021
1041
|
// every workflow run reports a terminal status, regardless of how
|
|
@@ -1073,6 +1093,8 @@ export async function workflowRunAction(file, options) {
|
|
|
1073
1093
|
}
|
|
1074
1094
|
if (wfHasFailed) {
|
|
1075
1095
|
console.error(` ${renderReceiptLine(receipt, isTTY)}\n`);
|
|
1096
|
+
if (journal?.active)
|
|
1097
|
+
console.error(c.subtle(` resume with: sweny workflow resume ${runId}\n`));
|
|
1076
1098
|
process.exit(1);
|
|
1077
1099
|
return;
|
|
1078
1100
|
}
|
|
@@ -1080,8 +1102,17 @@ export async function workflowRunAction(file, options) {
|
|
|
1080
1102
|
process.exit(0);
|
|
1081
1103
|
}
|
|
1082
1104
|
catch (err) {
|
|
1105
|
+
// A refused resume ran nothing: leave the run's journal and history as they were.
|
|
1106
|
+
if (err instanceof JournalMismatchError || err instanceof JournalLockedError) {
|
|
1107
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1083
1111
|
console.error(formatCrashError(err));
|
|
1084
1112
|
runLogger.flush();
|
|
1113
|
+
journal?.end("crashed");
|
|
1114
|
+
if (journal)
|
|
1115
|
+
console.error(c.subtle(` resume with: sweny workflow resume ${runId}`));
|
|
1085
1116
|
recordHistory(nodeTimer.lastResults, undefined, true);
|
|
1086
1117
|
console.error(` ${renderReceiptLine(summarizeRun(new Map(), Date.now() - runStart, true), isTTY)}\n`);
|
|
1087
1118
|
// A crash must not leave a stale success comment behind.
|
|
@@ -1191,6 +1222,44 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
|
1191
1222
|
workflowRunCmd.option(flags, description);
|
|
1192
1223
|
}
|
|
1193
1224
|
workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
|
|
1225
|
+
workflowRunCmd.option("--no-journal", "Do not write a run journal to .sweny/runs/<run-id>/ (or set `journal: off` in .sweny.yml); the run cannot be resumed");
|
|
1226
|
+
const workflowResumeCmd = workflowCmd
|
|
1227
|
+
.command("resume <run-id>")
|
|
1228
|
+
.description("Resume a killed or failed `workflow run` from its journal: finished nodes are replayed without calling the agent, " +
|
|
1229
|
+
"the first unfinished node runs again, and writes already applied are not re-sent")
|
|
1230
|
+
.action(async (runRef, options) => {
|
|
1231
|
+
const discovered = configuredSkillsWithDiagnostics(process.env, process.cwd());
|
|
1232
|
+
const known = new Set([...discovered.skills.map((s) => s.id), ...builtinSkills.map((s) => s.id)]);
|
|
1233
|
+
const prepared = prepareResume(runRef, {
|
|
1234
|
+
force: options.force === true,
|
|
1235
|
+
allowRepeatWrites: options.allowRepeatWrites === true,
|
|
1236
|
+
plan: options.plan === true,
|
|
1237
|
+
workflow: typeof options.workflow === "string" ? options.workflow : undefined,
|
|
1238
|
+
input: typeof options.input === "string" ? options.input : undefined,
|
|
1239
|
+
}, { loadWorkflow: (f) => loadWorkflowFile(f, known), swenyVersion: version, env: process.env });
|
|
1240
|
+
const out = options.json ? process.stderr : process.stdout;
|
|
1241
|
+
for (const line of prepared.lines)
|
|
1242
|
+
out.write(` ${line}\n`);
|
|
1243
|
+
if (!prepared.ok) {
|
|
1244
|
+
console.error(chalk.red(`\n ${prepared.error}\n`));
|
|
1245
|
+
process.exit(1);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if ("planOnly" in prepared) {
|
|
1249
|
+
process.exit(0);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
// The run's own input goes to the executor (--input was only checked against it).
|
|
1253
|
+
const runOptions = { ...options, input: undefined };
|
|
1254
|
+
await workflowRunAction(prepared.ctx.file, runOptions, prepared.ctx);
|
|
1255
|
+
});
|
|
1256
|
+
for (const [flags, description] of WORKFLOW_RESUME_OPTIONS)
|
|
1257
|
+
workflowResumeCmd.option(flags, description);
|
|
1258
|
+
for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
1259
|
+
if (RESUME_SHARED_RUN_FLAGS.includes(flags))
|
|
1260
|
+
workflowResumeCmd.option(flags, description);
|
|
1261
|
+
}
|
|
1262
|
+
workflowResumeCmd.option("--no-history", "Do not update this run's record in .sweny/runs/");
|
|
1194
1263
|
registerRunsCommand(program);
|
|
1195
1264
|
workflowCmd
|
|
1196
1265
|
.command("diagram <file>")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny workflow resume <run-id>` (#363): checks a run journal, prints the
|
|
3
|
+
* resume plan, refuses what is unsafe, and hands `workflowRunAction` the
|
|
4
|
+
* journal, the run id and the run's input.
|
|
5
|
+
*
|
|
6
|
+
* Lives outside main.ts so tests can import it (main.ts parses argv on import).
|
|
7
|
+
*/
|
|
8
|
+
import type { Logger, Workflow } from "../types.js";
|
|
9
|
+
import { RunJournal, type ResumePlan } from "../journal.js";
|
|
10
|
+
/** `sweny workflow resume` options (commander camelCases the flags). */
|
|
11
|
+
export interface ResumeOptions {
|
|
12
|
+
force?: boolean;
|
|
13
|
+
allowRepeatWrites?: boolean;
|
|
14
|
+
/** Print the plan and exit without running anything. */
|
|
15
|
+
plan?: boolean;
|
|
16
|
+
/** Workflow file to load instead of the path the journal recorded. */
|
|
17
|
+
workflow?: string;
|
|
18
|
+
/** Same JSON as the original run's --input (needed when the journal redacted it). */
|
|
19
|
+
input?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Resume-only flags; the rest are shared with `workflow run`. */
|
|
22
|
+
export declare const WORKFLOW_RESUME_OPTIONS: ReadonlyArray<readonly [flags: string, description: string]>;
|
|
23
|
+
/** Shared `workflow run` flags that make sense on resume (no --dry-run, --stage or --list-nodes: they change the run). */
|
|
24
|
+
export declare const RESUME_SHARED_RUN_FLAGS: string[];
|
|
25
|
+
export interface ResumeContext {
|
|
26
|
+
runId: string;
|
|
27
|
+
file: string;
|
|
28
|
+
input: Record<string, unknown>;
|
|
29
|
+
journal: RunJournal;
|
|
30
|
+
plan: ResumePlan;
|
|
31
|
+
}
|
|
32
|
+
export type PrepareResumeResult = {
|
|
33
|
+
ok: true;
|
|
34
|
+
ctx: ResumeContext;
|
|
35
|
+
lines: string[];
|
|
36
|
+
} | {
|
|
37
|
+
ok: true;
|
|
38
|
+
planOnly: true;
|
|
39
|
+
lines: string[];
|
|
40
|
+
} | {
|
|
41
|
+
ok: false;
|
|
42
|
+
error: string;
|
|
43
|
+
lines: string[];
|
|
44
|
+
};
|
|
45
|
+
export interface PrepareResumeDeps {
|
|
46
|
+
cwd?: string;
|
|
47
|
+
loadWorkflow: (file: string) => Workflow;
|
|
48
|
+
swenyVersion?: string;
|
|
49
|
+
env?: Record<string, string | undefined>;
|
|
50
|
+
logger?: Logger;
|
|
51
|
+
}
|
|
52
|
+
/** One line per planned visit, for humans. */
|
|
53
|
+
export declare function formatResumePlan(plan: ResumePlan, mayRepeat: string[]): string[];
|
|
54
|
+
export declare function prepareResume(ref: string, opts: ResumeOptions, deps: PrepareResumeDeps): PrepareResumeResult;
|
|
55
|
+
/** Journal is on unless `--no-journal` (options.journal === false) or `.sweny.yml` has `journal: off`. */
|
|
56
|
+
export declare function journalDisabled(optionJournal: unknown, configJournal: unknown): boolean;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny workflow resume <run-id>` (#363): checks a run journal, prints the
|
|
3
|
+
* resume plan, refuses what is unsafe, and hands `workflowRunAction` the
|
|
4
|
+
* journal, the run id and the run's input.
|
|
5
|
+
*
|
|
6
|
+
* Lives outside main.ts so tests can import it (main.ts parses argv on import).
|
|
7
|
+
*/
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { validateRuntimeInput } from "../inputs.js";
|
|
10
|
+
import { JOURNAL_DIR, JOURNAL_FILE, JournalLockedError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, findJournalRun, journalDir, mayRepeatWrites, readJournal, workflowHashOf, } from "../journal.js";
|
|
11
|
+
import { parseInputFlag } from "./workflow-input.js";
|
|
12
|
+
/** Resume-only flags; the rest are shared with `workflow run`. */
|
|
13
|
+
export const WORKFLOW_RESUME_OPTIONS = [
|
|
14
|
+
["--plan", "Print what would be replayed and re-run, then exit without running anything"],
|
|
15
|
+
[
|
|
16
|
+
"--force",
|
|
17
|
+
"Resume even though the workflow, its instruction files, the input, or the configured skills/agent changed (warns)",
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
"--allow-repeat-writes",
|
|
21
|
+
"Re-run nodes that may repeat a write (not read-only, started before the crash), and re-send writes whose outcome cannot be confirmed on the provider",
|
|
22
|
+
],
|
|
23
|
+
["--workflow <file>", "Workflow file to load, when it moved since the run (its content must still match)"],
|
|
24
|
+
];
|
|
25
|
+
/** Shared `workflow run` flags that make sense on resume (no --dry-run, --stage or --list-nodes: they change the run). */
|
|
26
|
+
export const RESUME_SHARED_RUN_FLAGS = [
|
|
27
|
+
"--timeout <ms>",
|
|
28
|
+
"--max-steps <n>",
|
|
29
|
+
"--json",
|
|
30
|
+
"--stream",
|
|
31
|
+
"--verbose",
|
|
32
|
+
"--mermaid",
|
|
33
|
+
"--comment-file <path>",
|
|
34
|
+
"--input <json>",
|
|
35
|
+
"--agent <id>",
|
|
36
|
+
"--harness-policy <mode>",
|
|
37
|
+
];
|
|
38
|
+
/** One line per planned visit, for humans. */
|
|
39
|
+
export function formatResumePlan(plan, mayRepeat) {
|
|
40
|
+
const lines = [`Resume run ${plan.runId} (workflow ${plan.start.workflow_id}, attempt ${plan.attempts + 1})`];
|
|
41
|
+
const width = Math.max(4, ...plan.visits.map((v) => v.node.length), (plan.freshNode ?? "").length);
|
|
42
|
+
const label = (node, iteration) => (iteration > 1 ? `${node} (#${iteration})` : node).padEnd(width + 5);
|
|
43
|
+
for (const v of plan.visits) {
|
|
44
|
+
if (v.action === "replay") {
|
|
45
|
+
const writes = v.applied > 0 ? `, ${v.applied} write(s) not re-sent` : "";
|
|
46
|
+
lines.push(` ✓ ${label(v.node, v.iteration)} replay from journal (${v.status}${writes})`);
|
|
47
|
+
}
|
|
48
|
+
else if (v.action === "write-stage") {
|
|
49
|
+
const parts = ["agent result reused"];
|
|
50
|
+
if (v.applied > 0)
|
|
51
|
+
parts.push(`${v.applied} write(s) already applied, not re-sent`);
|
|
52
|
+
if (v.unconfirmed > 0)
|
|
53
|
+
parts.push(`${v.unconfirmed} write(s) to confirm on the provider first`);
|
|
54
|
+
lines.push(` ↻ ${label(v.node, v.iteration)} write stage only: ${parts.join("; ")}`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const risk = mayRepeat.includes(v.node) ? " (may repeat writes: the node is not read-only)" : "";
|
|
58
|
+
lines.push(` ▶ ${label(v.node, v.iteration)} run again${risk}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (plan.freshNode)
|
|
62
|
+
lines.push(` ▶ ${label(plan.freshNode, 1)} run (not started before the crash)`);
|
|
63
|
+
const last = plan.visits[plan.visits.length - 1];
|
|
64
|
+
if (!plan.freshNode && last?.action === "replay" && last.next === undefined) {
|
|
65
|
+
lines.push(` → route from ${last.node} is decided again (not journaled before the crash)`);
|
|
66
|
+
}
|
|
67
|
+
if (plan.finished)
|
|
68
|
+
lines.push(" every node finished; the resume only closes the run");
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
export function prepareResume(ref, opts, deps) {
|
|
72
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
73
|
+
const lines = [];
|
|
74
|
+
const fail = (error) => ({ ok: false, error, lines });
|
|
75
|
+
const runId = findJournalRun(ref, cwd);
|
|
76
|
+
if (!runId) {
|
|
77
|
+
return fail(`no run journal matches "${ref}" in ${JOURNAL_DIR}/. Runs started with --no-journal (or before journals existed) cannot be resumed.`);
|
|
78
|
+
}
|
|
79
|
+
const file = path.join(journalDir(cwd, runId), JOURNAL_FILE);
|
|
80
|
+
let read;
|
|
81
|
+
try {
|
|
82
|
+
// --plan stays read-only: a torn tail is reported, not cut.
|
|
83
|
+
read = readJournal(file, { repair: !opts.plan });
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err instanceof JournalVersionError)
|
|
87
|
+
return fail(err.message);
|
|
88
|
+
return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
|
+
}
|
|
90
|
+
if (read.corruptAtLine !== undefined) {
|
|
91
|
+
return fail(`run journal ${file} is damaged at line ${read.corruptAtLine}, with valid records after it. ` +
|
|
92
|
+
`That is not a torn last write, so it cannot be repaired safely. Start a new run.`);
|
|
93
|
+
}
|
|
94
|
+
if (read.truncatedBytes > 0) {
|
|
95
|
+
lines.push(opts.plan
|
|
96
|
+
? `note: the journal ends in a torn record (${read.truncatedBytes} bytes); resuming drops it`
|
|
97
|
+
: `note: dropped a torn record at the end of the journal (${read.truncatedBytes} bytes)`);
|
|
98
|
+
}
|
|
99
|
+
let plan;
|
|
100
|
+
try {
|
|
101
|
+
plan = buildResumePlan(read.records);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
105
|
+
}
|
|
106
|
+
if (plan.lastStatus === "success")
|
|
107
|
+
return fail(`run ${runId} already finished successfully; nothing to resume`);
|
|
108
|
+
const workflowFile = opts.workflow ?? plan.start.workflow_file;
|
|
109
|
+
if (!workflowFile)
|
|
110
|
+
return fail(`the journal does not record the workflow file; pass --workflow <file>`);
|
|
111
|
+
let workflow;
|
|
112
|
+
try {
|
|
113
|
+
workflow = deps.loadWorkflow(workflowFile);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
return fail(`cannot load workflow ${workflowFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
117
|
+
}
|
|
118
|
+
if (workflowHashOf(workflow) !== plan.start.workflow_hash) {
|
|
119
|
+
if (!opts.force) {
|
|
120
|
+
return fail(`workflow ${workflowFile} changed since run ${runId} started. Resuming would mix results from two ` +
|
|
121
|
+
`different workflows. Start a new run, or pass --force to resume anyway.`);
|
|
122
|
+
}
|
|
123
|
+
lines.push(`warning: --force: workflow ${workflowFile} changed since the run started`);
|
|
124
|
+
}
|
|
125
|
+
let input;
|
|
126
|
+
if (opts.input !== undefined) {
|
|
127
|
+
const parsed = parseInputFlag(opts.input);
|
|
128
|
+
if (!parsed.ok)
|
|
129
|
+
return fail(parsed.lines.join(" "));
|
|
130
|
+
const validated = validateRuntimeInput(workflow.inputs, parsed.value);
|
|
131
|
+
if (!validated.ok)
|
|
132
|
+
return fail(`--input: ${validated.errors.map((e) => `${e.field}: ${e.message}`).join("; ")}`);
|
|
133
|
+
input = validated.value;
|
|
134
|
+
if (canonicalHash(input) !== plan.start.input_hash) {
|
|
135
|
+
if (!opts.force)
|
|
136
|
+
return fail(`--input differs from the run's original input; pass the same JSON, or --force`);
|
|
137
|
+
lines.push("warning: --force: --input differs from the run's original input");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (plan.start.input_redacted) {
|
|
141
|
+
return fail(`the run's input held secrets, which the journal does not keep; pass the same --input again`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
input = (plan.start.input ?? {});
|
|
145
|
+
}
|
|
146
|
+
const mayRepeat = mayRepeatWrites(plan, workflow, input);
|
|
147
|
+
lines.push(...formatResumePlan(plan, mayRepeat));
|
|
148
|
+
if (opts.plan)
|
|
149
|
+
return { ok: true, planOnly: true, lines };
|
|
150
|
+
if (mayRepeat.length > 0 && !opts.allowRepeatWrites) {
|
|
151
|
+
return fail(`node(s) ${mayRepeat.join(", ")} started before the crash and can write outside safe outputs ` +
|
|
152
|
+
`(write tools, shell, git push). Running again may repeat those writes. Check what they did, ` +
|
|
153
|
+
`then resume with --allow-repeat-writes.`);
|
|
154
|
+
}
|
|
155
|
+
let journal;
|
|
156
|
+
try {
|
|
157
|
+
journal = RunJournal.openForResume({
|
|
158
|
+
runId,
|
|
159
|
+
cwd,
|
|
160
|
+
workflowFile: path.resolve(cwd, workflowFile),
|
|
161
|
+
swenyVersion: deps.swenyVersion,
|
|
162
|
+
env: deps.env,
|
|
163
|
+
logger: deps.logger,
|
|
164
|
+
read,
|
|
165
|
+
plan,
|
|
166
|
+
force: opts.force === true,
|
|
167
|
+
allowRepeatWrites: opts.allowRepeatWrites === true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (err instanceof JournalLockedError)
|
|
172
|
+
return fail(err.message);
|
|
173
|
+
return fail(`cannot open the run journal: ${err instanceof Error ? err.message : String(err)}`);
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, ctx: { runId, file: path.resolve(cwd, workflowFile), input, journal, plan }, lines };
|
|
176
|
+
}
|
|
177
|
+
/** Journal is on unless `--no-journal` (options.journal === false) or `.sweny.yml` has `journal: off`. */
|
|
178
|
+
export function journalDisabled(optionJournal, configJournal) {
|
|
179
|
+
if (optionJournal === false)
|
|
180
|
+
return true;
|
|
181
|
+
return typeof configJournal === "string" && ["off", "false", "no", "0"].includes(configJournal.trim().toLowerCase());
|
|
182
|
+
}
|
package/dist/executor.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { AgentHarness } from "./harness/types.js";
|
|
|
18
18
|
import type { DeciderMode } from "./decider.js";
|
|
19
19
|
import type { Budget } from "./budget.js";
|
|
20
20
|
import { type ActorInfo } from "./safe-outputs.js";
|
|
21
|
+
import type { ExecutionJournal } from "./journal.js";
|
|
21
22
|
export interface ExecuteOptions {
|
|
22
23
|
/** Registered skills (id → Skill) */
|
|
23
24
|
skills: Map<string, Skill>;
|
|
@@ -88,6 +89,12 @@ export interface ExecuteOptions {
|
|
|
88
89
|
* association in the `GITHUB_EVENT_PATH` payload.
|
|
89
90
|
*/
|
|
90
91
|
actor?: ActorInfo;
|
|
92
|
+
/**
|
|
93
|
+
* Run journal (#363): an append-only record of the run for `sweny workflow
|
|
94
|
+
* resume`. In resume mode it also replays the visits an earlier attempt
|
|
95
|
+
* finished, so their agents are not called again. Default: none.
|
|
96
|
+
*/
|
|
97
|
+
journal?: ExecutionJournal;
|
|
91
98
|
/**
|
|
92
99
|
* Run-wide spend ceiling (#449), tightening `workflow.budget` (the lowest of
|
|
93
100
|
* the two wins per unit). CLI: `--max-tokens`, `--max-cost`. Default: none.
|
package/dist/executor.js
CHANGED
|
@@ -21,7 +21,7 @@ import { buildRetryPreamble } from "./retry.js";
|
|
|
21
21
|
import { resolveExecutionModel } from "./model.js";
|
|
22
22
|
import { buildToolAliases } from "./skills/index.js";
|
|
23
23
|
import { validateWorkflow } from "./schema.js";
|
|
24
|
-
import { resolveAgentAccess } from "./agent-env.js";
|
|
24
|
+
import { grantedAgentEnv, resolveAgentAccess } from "./agent-env.js";
|
|
25
25
|
import { fenceUntrusted } from "./untrusted.js";
|
|
26
26
|
import { asClaude } from "./harness/compat.js";
|
|
27
27
|
import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
|
|
@@ -54,7 +54,7 @@ function throwIfAborted(signal) {
|
|
|
54
54
|
* - `trace`: full ordered execution trace including loops and routing decisions
|
|
55
55
|
*/
|
|
56
56
|
export async function execute(workflow, input, options) {
|
|
57
|
-
const { observer, signal, timeoutMs } = options;
|
|
57
|
+
const { observer, signal, timeoutMs, journal } = options;
|
|
58
58
|
// `harness` is the seam; a legacy `claude` object is used as-is (what `asClaude(claudeCompat(claude))` yields).
|
|
59
59
|
const claude = options.harness ? asClaude(options.harness) : options.claude;
|
|
60
60
|
if (!claude) {
|
|
@@ -138,6 +138,15 @@ export async function execute(workflow, input, options) {
|
|
|
138
138
|
logger,
|
|
139
139
|
});
|
|
140
140
|
trace.sources = resolvedSources;
|
|
141
|
+
journal?.begin({
|
|
142
|
+
workflow,
|
|
143
|
+
input,
|
|
144
|
+
sources: resolvedSources,
|
|
145
|
+
skills,
|
|
146
|
+
config,
|
|
147
|
+
writeState,
|
|
148
|
+
harnessId: options.harness?.id,
|
|
149
|
+
});
|
|
141
150
|
safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
|
|
142
151
|
safeObserve(observer, { type: "sources:resolved", sources: resolvedSources }, logger);
|
|
143
152
|
let currentId = workflow.entry;
|
|
@@ -164,6 +173,34 @@ export async function execute(workflow, input, options) {
|
|
|
164
173
|
const resolvedInstruction = resolvedSources[`nodes.${currentId}.instruction`].content;
|
|
165
174
|
safeObserve(observer, { type: "node:enter", node: currentId, instruction: resolvedInstruction }, logger);
|
|
166
175
|
logger.info(`→ ${node.name}`, { node: currentId });
|
|
176
|
+
// Resume (#363): a visit an earlier attempt finished is replayed from the journal, not re-run.
|
|
177
|
+
const replay = journal?.replay(currentId, iteration);
|
|
178
|
+
if (replay?.kind === "complete") {
|
|
179
|
+
results.set(currentId, replay.result);
|
|
180
|
+
trace.steps.push({ node: currentId, status: replay.result.status, iteration });
|
|
181
|
+
safeObserve(observer, { type: "node:exit", node: currentId, result: replay.result }, logger);
|
|
182
|
+
logger.info(` replayed from the run journal: ${replay.result.status}`, { node: currentId });
|
|
183
|
+
const from = currentId;
|
|
184
|
+
if (replay.next === undefined) {
|
|
185
|
+
currentId = await advanceFromNode(workflow, from, results, input, claude, observer, edgeCounts, logger, trace, {
|
|
186
|
+
signal,
|
|
187
|
+
timeoutMs,
|
|
188
|
+
});
|
|
189
|
+
journal?.route(from, currentId);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
if (replay.next !== null) {
|
|
193
|
+
const key = `${from}→${replay.next}`;
|
|
194
|
+
edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
|
|
195
|
+
const reason = whenLabel(workflow.edges.find((e) => e.from === from && e.to === replay.next)?.when) ?? "only path";
|
|
196
|
+
trace.edges.push({ from, to: replay.next, reason });
|
|
197
|
+
}
|
|
198
|
+
safeObserve(observer, { type: "route", from, to: replay.next ?? "(end)", reason: "replayed" }, logger);
|
|
199
|
+
currentId = replay.next;
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
journal?.nodeStart(currentId, iteration);
|
|
167
204
|
// Gather tools from the node's skills, then apply the node's optional
|
|
168
205
|
// skill-tool filter (`tools.allow` / `tools.deny`). Filtered tools are
|
|
169
206
|
// never registered for the run, so the model cannot see or call them.
|
|
@@ -199,7 +236,7 @@ export async function execute(workflow, input, options) {
|
|
|
199
236
|
}
|
|
200
237
|
}
|
|
201
238
|
const skillInstructions = resolveSkillInstructions(node.skills, skills);
|
|
202
|
-
const skillMcpServers = resolveSkillMcpServers(node.skills, skills);
|
|
239
|
+
const skillMcpServers = resolveSkillMcpServers(node.skills, skills, config);
|
|
203
240
|
// Runtime guard: if this node declares skills but none resolved, the node
|
|
204
241
|
// cannot do its job (e.g. "create a Linear issue" with no linear skill).
|
|
205
242
|
// The startup validate() warns about this possibility, but only throw when
|
|
@@ -247,6 +284,7 @@ export async function execute(workflow, input, options) {
|
|
|
247
284
|
toolCalls: [],
|
|
248
285
|
};
|
|
249
286
|
results.set(currentId, result);
|
|
287
|
+
journal?.nodeEnd(currentId, iteration, result, writeState);
|
|
250
288
|
trace.steps.push({ node: currentId, status: result.status, iteration });
|
|
251
289
|
safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
|
|
252
290
|
logger.warn(` requires ${onFail === "skip" ? "skipped" : "failed"}: ${requiresError}`, { node: currentId });
|
|
@@ -260,6 +298,7 @@ export async function execute(workflow, input, options) {
|
|
|
260
298
|
// Apply normal routing rules (dry run gate + resolveNext).
|
|
261
299
|
// TODO: dedupe with requires path — see advanceFromNode helper below
|
|
262
300
|
const next = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, { signal, timeoutMs, shadow });
|
|
301
|
+
journal?.route(currentId, next);
|
|
263
302
|
currentId = next;
|
|
264
303
|
continue;
|
|
265
304
|
}
|
|
@@ -302,8 +341,13 @@ export async function execute(workflow, input, options) {
|
|
|
302
341
|
let degradedLogged = false;
|
|
303
342
|
// #442: a staged or dry run cannot push. The harness blocks git push and
|
|
304
343
|
// withholds write tokens in this node's agent env (see withPushBlocked).
|
|
344
|
+
// Skill credentials stay in sweny: the agent gets only what the node grants
|
|
345
|
+
// with `agent_env`, never on a read-only node or in a staged run.
|
|
346
|
+
const granted = grantedAgentEnv(node.agent_env, { readOnly: readOnlyNode, staged: stageOutputs });
|
|
347
|
+
if (granted.dropped)
|
|
348
|
+
logger.warn(` ${granted.dropped}`, { node: currentId });
|
|
305
349
|
const agentAccess = {
|
|
306
|
-
...resolveAgentAccess(node.skills, skills),
|
|
350
|
+
...resolveAgentAccess(node.skills, skills, granted.grant),
|
|
307
351
|
...(stageOutputs ? { noPush: true } : {}),
|
|
308
352
|
};
|
|
309
353
|
// #365: a node or workflow that declares `permissions` or `outputs` gets
|
|
@@ -335,6 +379,13 @@ export async function execute(workflow, input, options) {
|
|
|
335
379
|
let budgetStop;
|
|
336
380
|
let previous;
|
|
337
381
|
while (true) {
|
|
382
|
+
if (replay?.kind === "checkpoint") {
|
|
383
|
+
// Resume (#363): the agent finished this visit before the crash. Reuse its result and
|
|
384
|
+
// write intents; the write stage below skips what the journal shows was already applied.
|
|
385
|
+
({ result, agentRunFailed, attempt } = replay);
|
|
386
|
+
intents.push(...replay.intents);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
338
389
|
const refusal = gate?.refuse ?? budgetCheck?.refuse;
|
|
339
390
|
if (refusal) {
|
|
340
391
|
// Not an agent failure: fail_soft never softens a policy refusal.
|
|
@@ -536,13 +587,14 @@ export async function execute(workflow, input, options) {
|
|
|
536
587
|
// may veto, and only then does sweny call the skill's own write handler.
|
|
537
588
|
if (outputDecls.length > 0) {
|
|
538
589
|
if (result.status === "success" && !agentRunFailed) {
|
|
590
|
+
journal?.checkpoint(currentId, iteration, { result, intents, agentRunFailed, attempt });
|
|
539
591
|
const stage = await applySafeOutputs({
|
|
540
592
|
nodeId: currentId,
|
|
541
593
|
declarations: outputDecls,
|
|
542
594
|
intents,
|
|
543
595
|
policy: workflow.safe_outputs,
|
|
544
596
|
nodeSkills: node.skills,
|
|
545
|
-
skills,
|
|
597
|
+
skills: journal ? journal.wrapWrites(currentId, iteration, skills) : skills,
|
|
546
598
|
config,
|
|
547
599
|
env: runEnv,
|
|
548
600
|
actor,
|
|
@@ -574,6 +626,7 @@ export async function execute(workflow, input, options) {
|
|
|
574
626
|
if (skippedWrites.length > 0)
|
|
575
627
|
result = { ...result, skippedWrites };
|
|
576
628
|
results.set(currentId, result);
|
|
629
|
+
journal?.nodeEnd(currentId, iteration, result, writeState);
|
|
577
630
|
trace.steps.push(attempt > 0
|
|
578
631
|
? { node: currentId, status: result.status, iteration, retryAttempt: attempt }
|
|
579
632
|
: { node: currentId, status: result.status, iteration });
|
|
@@ -593,11 +646,13 @@ export async function execute(workflow, input, options) {
|
|
|
593
646
|
break;
|
|
594
647
|
}
|
|
595
648
|
// Dry run gate + routing — shared with requires path via advanceFromNode helper.
|
|
649
|
+
const routedFrom = currentId;
|
|
596
650
|
currentId = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, {
|
|
597
651
|
signal,
|
|
598
652
|
timeoutMs,
|
|
599
653
|
shadow,
|
|
600
654
|
});
|
|
655
|
+
journal?.route(routedFrom, currentId);
|
|
601
656
|
}
|
|
602
657
|
safeObserve(observer, {
|
|
603
658
|
type: "workflow:end",
|
|
@@ -1005,12 +1060,33 @@ function dryRunNotice(skippedWrites) {
|
|
|
1005
1060
|
`Do not create, modify, post, or send anything. Do the analysis, and where this step would ` +
|
|
1006
1061
|
`normally write, describe exactly what it would have written instead.`);
|
|
1007
1062
|
}
|
|
1008
|
-
/**
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1063
|
+
/**
|
|
1064
|
+
* Only this node's resolved skills contribute external servers. A stdio
|
|
1065
|
+
* server gets its skill's declared credentials in its own `env` (explicit
|
|
1066
|
+
* server env wins): the agent env no longer carries them, so the server must
|
|
1067
|
+
* not rely on inheriting them from the agent process.
|
|
1068
|
+
*/
|
|
1069
|
+
function resolveSkillMcpServers(skillIds, skills, config = {}) {
|
|
1070
|
+
const servers = {};
|
|
1071
|
+
for (const id of skillIds) {
|
|
1072
|
+
const skill = skills.get(id);
|
|
1073
|
+
const mcp = skill?.mcp;
|
|
1074
|
+
if (!skill || !mcp)
|
|
1075
|
+
continue;
|
|
1076
|
+
const type = mcp.type ?? (mcp.command ? "stdio" : "http");
|
|
1077
|
+
if (type !== "stdio") {
|
|
1078
|
+
servers[id] = { ...mcp, type };
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const declared = {};
|
|
1082
|
+
for (const [key, field] of Object.entries(skill.config ?? {})) {
|
|
1083
|
+
if (field.env && config[key] !== undefined)
|
|
1084
|
+
declared[field.env] = config[key];
|
|
1085
|
+
}
|
|
1086
|
+
const env = { ...declared, ...(mcp.env ?? {}) };
|
|
1087
|
+
servers[id] = { ...mcp, type, ...(Object.keys(env).length > 0 ? { env } : {}) };
|
|
1088
|
+
}
|
|
1089
|
+
return servers;
|
|
1014
1090
|
}
|
|
1015
1091
|
function resolveTools(skillIds, skills) {
|
|
1016
1092
|
return skillIds
|
package/dist/harness/acp.d.ts
CHANGED
|
@@ -162,7 +162,12 @@ export declare class AcpHarness implements AgentHarness {
|
|
|
162
162
|
ok: false;
|
|
163
163
|
reason: string;
|
|
164
164
|
}>;
|
|
165
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Env for the agent process: scoped like Claude Code's, with only the auth
|
|
167
|
+
* vars the operator named. Skill credentials are dropped either way
|
|
168
|
+
* ({@link finishAgentEnv}) unless the node granted them with `agent_env`; an
|
|
169
|
+
* operator-named auth var stays, and the gate counts it if it is a write token.
|
|
170
|
+
*/
|
|
166
171
|
private buildEnv;
|
|
167
172
|
/** The node policy, with the harness policy mode and the watchdog budget applied. */
|
|
168
173
|
private compilePolicy;
|