@sweny-ai/core 0.1.163 → 0.1.165

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.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Workflow } from "../types.js";
3
+ import { type ResumeContext } from "./resume.js";
3
4
  export declare function loadWorkflowFile(filePath: string, knownSkills?: Set<string>): Workflow;
4
5
  export declare function workflowRunAction(file: string | undefined, options: Record<string, unknown> & {
5
6
  json?: boolean;
@@ -13,7 +14,9 @@ export declare function workflowRunAction(file: string | undefined, options: Rec
13
14
  maxCost?: string;
14
15
  yes?: boolean;
15
16
  decider?: string;
16
- }): Promise<void>;
17
+ },
18
+ /** Set by `sweny workflow resume` (#363): the journal, run id and original input. */
19
+ resume?: ResumeContext): Promise<void>;
17
20
  export declare function workflowExportAction(name: string): void;
18
21
  export declare function workflowValidateAction(file: string, options: {
19
22
  json?: boolean;
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
@@ -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.
@@ -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
  }
@@ -335,6 +374,13 @@ export async function execute(workflow, input, options) {
335
374
  let budgetStop;
336
375
  let previous;
337
376
  while (true) {
377
+ if (replay?.kind === "checkpoint") {
378
+ // Resume (#363): the agent finished this visit before the crash. Reuse its result and
379
+ // write intents; the write stage below skips what the journal shows was already applied.
380
+ ({ result, agentRunFailed, attempt } = replay);
381
+ intents.push(...replay.intents);
382
+ break;
383
+ }
338
384
  const refusal = gate?.refuse ?? budgetCheck?.refuse;
339
385
  if (refusal) {
340
386
  // Not an agent failure: fail_soft never softens a policy refusal.
@@ -536,13 +582,14 @@ export async function execute(workflow, input, options) {
536
582
  // may veto, and only then does sweny call the skill's own write handler.
537
583
  if (outputDecls.length > 0) {
538
584
  if (result.status === "success" && !agentRunFailed) {
585
+ journal?.checkpoint(currentId, iteration, { result, intents, agentRunFailed, attempt });
539
586
  const stage = await applySafeOutputs({
540
587
  nodeId: currentId,
541
588
  declarations: outputDecls,
542
589
  intents,
543
590
  policy: workflow.safe_outputs,
544
591
  nodeSkills: node.skills,
545
- skills,
592
+ skills: journal ? journal.wrapWrites(currentId, iteration, skills) : skills,
546
593
  config,
547
594
  env: runEnv,
548
595
  actor,
@@ -574,6 +621,7 @@ export async function execute(workflow, input, options) {
574
621
  if (skippedWrites.length > 0)
575
622
  result = { ...result, skippedWrites };
576
623
  results.set(currentId, result);
624
+ journal?.nodeEnd(currentId, iteration, result, writeState);
577
625
  trace.steps.push(attempt > 0
578
626
  ? { node: currentId, status: result.status, iteration, retryAttempt: attempt }
579
627
  : { node: currentId, status: result.status, iteration });
@@ -593,11 +641,13 @@ export async function execute(workflow, input, options) {
593
641
  break;
594
642
  }
595
643
  // Dry run gate + routing — shared with requires path via advanceFromNode helper.
644
+ const routedFrom = currentId;
596
645
  currentId = await advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, {
597
646
  signal,
598
647
  timeoutMs,
599
648
  shadow,
600
649
  });
650
+ journal?.route(routedFrom, currentId);
601
651
  }
602
652
  safeObserve(observer, {
603
653
  type: "workflow:end",
@@ -33,6 +33,13 @@ import { type AuthProbe } from "./auth.js";
33
33
  export { CODEX_CAPABILITIES };
34
34
  /** Oldest Codex CLI whose flags this adapter uses (`--ignore-user-config`, `--ignore-rules`). */
35
35
  export declare const MIN_CODEX_VERSION = "0.159.0";
36
+ /**
37
+ * The Codex CLI the capability claims in capabilities.ts were read from and the contract suite
38
+ * was written against (openai/codex rust-v0.159.2). action.yml installs this version by default.
39
+ * A newer CLI is not refused (it is only a warning, see `preflight`): the claims are about a
40
+ * version, not a range, so a newer one is unverified rather than known-bad.
41
+ */
42
+ export declare const TESTED_CODEX_VERSION = "0.159.2";
36
43
  /**
37
44
  * Codex features sweny always turns off, so a node sees only the tools sweny
38
45
  * gave it: ChatGPT apps and plugins (their own MCP servers), tool suggestions,
@@ -41,6 +41,13 @@ import { codexAuth } from "./auth.js";
41
41
  export { CODEX_CAPABILITIES };
42
42
  /** Oldest Codex CLI whose flags this adapter uses (`--ignore-user-config`, `--ignore-rules`). */
43
43
  export const MIN_CODEX_VERSION = "0.159.0";
44
+ /**
45
+ * The Codex CLI the capability claims in capabilities.ts were read from and the contract suite
46
+ * was written against (openai/codex rust-v0.159.2). action.yml installs this version by default.
47
+ * A newer CLI is not refused (it is only a warning, see `preflight`): the claims are about a
48
+ * version, not a range, so a newer one is unverified rather than known-bad.
49
+ */
50
+ export const TESTED_CODEX_VERSION = "0.159.2";
44
51
  /** Same system prompt Claude Code nodes get, sent as Codex developer instructions. */
45
52
  const SYSTEM_PROMPT = `You are a step in an automated workflow. Execute the instruction precisely using the tools available to you. Be thorough but concise. When you're done, summarize your findings and results.`;
46
53
  /**
@@ -348,6 +355,13 @@ export class CodexHarness {
348
355
  return;
349
356
  }
350
357
  this.version = m[1];
358
+ // Newer than the tested version: the declared capabilities may no longer hold. Warn once
359
+ // (the result is cached per instance). Not `degraded`: that is a per-node policy report and
360
+ // there is no node yet; not a refusal: it would break anyone who chose `latest` on purpose.
361
+ if (compareVersions(m[1], TESTED_CODEX_VERSION) > 0) {
362
+ this.logger.warn(`codex ${m[1]} is newer than ${TESTED_CODEX_VERSION}, the version sweny's Codex capabilities were tested against; ` +
363
+ `flags and event shapes may differ. Pin @openai/codex@${TESTED_CODEX_VERSION} (action input codex-version) for the tested behavior.`);
364
+ }
351
365
  resolve({ ok: true, version: m[1] });
352
366
  });
353
367
  });