@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/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 (options.input && typeof options.input === "string") {
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
+ }
@@ -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
- /** Only this node's resolved skills contribute external servers. */
1009
- function resolveSkillMcpServers(skillIds, skills) {
1010
- return Object.fromEntries(skillIds.flatMap((id) => {
1011
- const mcp = skills.get(id)?.mcp;
1012
- return mcp ? [[id, { ...mcp, type: mcp.type ?? (mcp.command ? "stdio" : "http") }]] : [];
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
@@ -162,7 +162,12 @@ export declare class AcpHarness implements AgentHarness {
162
162
  ok: false;
163
163
  reason: string;
164
164
  }>;
165
- /** Env for the agent process: scoped like Claude Code's, with only the auth vars the operator named. */
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;