@sweny-ai/core 0.1.172 → 0.1.173
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.js +6 -1
- package/dist/budget.d.ts +8 -0
- package/dist/budget.js +11 -0
- package/dist/cli/final-output.d.ts +26 -6
- package/dist/cli/final-output.js +64 -15
- package/dist/cli/main.js +12 -4
- package/dist/cli/resume.js +10 -2
- package/dist/executor.js +42 -4
- package/dist/harness/sandbox-wrapper.js +9 -1
- package/dist/journal.d.ts +114 -17
- package/dist/journal.js +463 -36
- package/dist/schema.d.ts +97 -97
- package/package.json +1 -1
package/dist/agent-env.js
CHANGED
|
@@ -29,6 +29,7 @@ import { execFileSync } from "node:child_process";
|
|
|
29
29
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { homedir, tmpdir } from "node:os";
|
|
31
31
|
import * as path from "node:path";
|
|
32
|
+
import { runKeyDir } from "./journal.js";
|
|
32
33
|
// ─── Scoped env ──────────────────────────────────────────────────
|
|
33
34
|
/**
|
|
34
35
|
* Exact variable names always passed to the agent subprocess.
|
|
@@ -956,7 +957,11 @@ export function buildSandboxSettings(domains, opts = {}) {
|
|
|
956
957
|
},
|
|
957
958
|
credentials: {
|
|
958
959
|
envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
|
|
959
|
-
files: [
|
|
960
|
+
files: [
|
|
961
|
+
{ path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
|
|
962
|
+
// Run journal keys: an agent that could read them could forge journal records.
|
|
963
|
+
{ path: runKeyDir(), mode: "deny" },
|
|
964
|
+
],
|
|
960
965
|
},
|
|
961
966
|
};
|
|
962
967
|
}
|
package/dist/budget.d.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* the attempt's signal, which stops the agent (harness cancel).
|
|
17
17
|
* - between nodes: usage on the finished result is checked; a crossing fails the
|
|
18
18
|
* node, and the run halts before the next node starts.
|
|
19
|
+
* - across a resume: the run journal records each attempt's spend (live and
|
|
20
|
+
* final), and `sweny workflow resume` seeds the run total with it.
|
|
19
21
|
*/
|
|
20
22
|
import type { NodeUsage } from "./types.js";
|
|
21
23
|
/** The `budget` field of a node or workflow. */
|
|
@@ -69,6 +71,12 @@ export declare class BudgetGuard {
|
|
|
69
71
|
/** The run ceiling is already reached: no further attempt may start. */
|
|
70
72
|
runExhausted(): BudgetOverrun | undefined;
|
|
71
73
|
node(nodeLimits: SpendLimits): NodeBudget;
|
|
74
|
+
/**
|
|
75
|
+
* Spend the run made before this process (a resumed run's journaled usage).
|
|
76
|
+
* Counted against the run ceiling like any other spend, so a crash never
|
|
77
|
+
* resets the whole-run budget.
|
|
78
|
+
*/
|
|
79
|
+
seed(s: Spend): void;
|
|
72
80
|
/** @internal */
|
|
73
81
|
commit(s: Spend): void;
|
|
74
82
|
}
|
package/dist/budget.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* the attempt's signal, which stops the agent (harness cancel).
|
|
17
17
|
* - between nodes: usage on the finished result is checked; a crossing fails the
|
|
18
18
|
* node, and the run halts before the next node starts.
|
|
19
|
+
* - across a resume: the run journal records each attempt's spend (live and
|
|
20
|
+
* final), and `sweny workflow resume` seeds the run total with it.
|
|
19
21
|
*/
|
|
20
22
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
21
23
|
export function toLimits(b) {
|
|
@@ -89,6 +91,15 @@ export class BudgetGuard {
|
|
|
89
91
|
node(nodeLimits) {
|
|
90
92
|
return new NodeBudget(this, nodeLimits);
|
|
91
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Spend the run made before this process (a resumed run's journaled usage).
|
|
96
|
+
* Counted against the run ceiling like any other spend, so a crash never
|
|
97
|
+
* resets the whole-run budget.
|
|
98
|
+
*/
|
|
99
|
+
seed(s) {
|
|
100
|
+
const ok = (n) => (Number.isFinite(n) && n > 0 ? n : 0);
|
|
101
|
+
this.commit({ tokens: ok(s.tokens), costUsd: ok(s.costUsd) });
|
|
102
|
+
}
|
|
92
103
|
/** @internal */
|
|
93
104
|
commit(s) {
|
|
94
105
|
this.spent = { tokens: this.spent.tokens + s.tokens, costUsd: this.spent.costUsd + s.costUsd };
|
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Unlike run-history and the receipt, this DOES read node output. It is
|
|
6
6
|
* local-only: printed to the terminal and saved to
|
|
7
|
-
* `.sweny/runs/<run-id>/output.md
|
|
7
|
+
* `.sweny/runs/<run-id>/output.md` (mode 0600). Nothing here is sent to the cloud.
|
|
8
|
+
*
|
|
9
|
+
* Secrets: node data is redacted with the run journal's redactor before
|
|
10
|
+
* anything renders (secret env and skill credential values, secret-named
|
|
11
|
+
* fields, known token shapes). In CI the answer is not printed unless asked
|
|
12
|
+
* (`--show-output`), since CI logs are often visible to more people than the
|
|
13
|
+
* repo's secrets are.
|
|
8
14
|
*/
|
|
9
|
-
import type { JSONSchema, NodeResult, Workflow } from "../types.js";
|
|
15
|
+
import type { ExecutionTrace, JSONSchema, NodeResult, Workflow } from "../types.js";
|
|
10
16
|
/** Lines of the answer printed to the terminal before the rest is left in output.md. */
|
|
11
17
|
export declare const FINAL_OUTPUT_MAX_LINES = 60;
|
|
12
18
|
export interface FinalOutput {
|
|
@@ -18,12 +24,26 @@ export interface FinalOutput {
|
|
|
18
24
|
}
|
|
19
25
|
/** Render a node's declared output fields as a compact `key: value` block. Empty when nothing declared is present. */
|
|
20
26
|
export declare function renderSchemaOutput(data: Record<string, unknown>, schema: JSONSchema): string[];
|
|
27
|
+
export interface ResolveFinalOutputOptions {
|
|
28
|
+
/** The run's trace: the answer comes from the last successful visit in execution order. */
|
|
29
|
+
trace?: Pick<ExecutionTrace, "steps">;
|
|
30
|
+
/** Secret values to redact (default: secret-named values in `process.env`). */
|
|
31
|
+
secrets?: string[];
|
|
32
|
+
}
|
|
21
33
|
/**
|
|
22
|
-
* Pick what the user should see: the
|
|
23
|
-
*
|
|
34
|
+
* Pick what the user should see: the result of the last successful visit
|
|
35
|
+
* (from the trace, so a loop that ends on an earlier node shows that node) on
|
|
36
|
+
* success, the first failed node's error on failure. Without a trace, terminal
|
|
37
|
+
* nodes in result order. Everything is redacted first. Null when there is
|
|
24
38
|
* nothing to show (no schema fields and no summary text).
|
|
25
39
|
*/
|
|
26
|
-
export declare function resolveFinalOutput(workflow: Workflow, results: Map<string, NodeResult
|
|
40
|
+
export declare function resolveFinalOutput(workflow: Workflow, results: Map<string, NodeResult>, opts?: ResolveFinalOutputOptions): FinalOutput | null;
|
|
41
|
+
/** True when the environment is CI (GitHub Actions, or a truthy `CI`). */
|
|
42
|
+
export declare function isCiEnv(env: Record<string, string | undefined>): boolean;
|
|
43
|
+
/** Print the answer block? Always outside CI; in CI only with `--show-output`. */
|
|
44
|
+
export declare function shouldPrintFinalOutput(showOutput: boolean, env: Record<string, string | undefined>): boolean;
|
|
45
|
+
/** The one line printed in CI instead of the answer. */
|
|
46
|
+
export declare function formatSavedOutputLine(savedTo: string | null): string;
|
|
27
47
|
/**
|
|
28
48
|
* Terminal text: the first `maxLines` lines, then
|
|
29
49
|
* `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
|
|
@@ -37,5 +57,5 @@ export declare function formatFinalOutput(out: FinalOutput, opts?: {
|
|
|
37
57
|
export declare function formatFinalMarkdown(workflow: Workflow, out: FinalOutput): string;
|
|
38
58
|
/** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
|
|
39
59
|
export declare function outputRelPath(runId: string): string;
|
|
40
|
-
/** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
|
|
60
|
+
/** Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws. */
|
|
41
61
|
export declare function writeFinalOutput(runId: string, markdown: string, cwd?: string): string | null;
|
package/dist/cli/final-output.js
CHANGED
|
@@ -4,10 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Unlike run-history and the receipt, this DOES read node output. It is
|
|
6
6
|
* local-only: printed to the terminal and saved to
|
|
7
|
-
* `.sweny/runs/<run-id>/output.md
|
|
7
|
+
* `.sweny/runs/<run-id>/output.md` (mode 0600). Nothing here is sent to the cloud.
|
|
8
|
+
*
|
|
9
|
+
* Secrets: node data is redacted with the run journal's redactor before
|
|
10
|
+
* anything renders (secret env and skill credential values, secret-named
|
|
11
|
+
* fields, known token shapes). In CI the answer is not printed unless asked
|
|
12
|
+
* (`--show-output`), since CI logs are often visible to more people than the
|
|
13
|
+
* repo's secrets are.
|
|
8
14
|
*/
|
|
9
15
|
import fs from "node:fs";
|
|
10
16
|
import path from "node:path";
|
|
17
|
+
import { collectSecretValues, redact } from "../journal.js";
|
|
11
18
|
import { RUN_HISTORY_DIR } from "./run-history.js";
|
|
12
19
|
/** Lines of the answer printed to the terminal before the rest is left in output.md. */
|
|
13
20
|
export const FINAL_OUTPUT_MAX_LINES = 60;
|
|
@@ -83,30 +90,52 @@ function trimBlock(text) {
|
|
|
83
90
|
return lines.map((l) => l.replace(/\s+$/, ""));
|
|
84
91
|
}
|
|
85
92
|
/**
|
|
86
|
-
* Pick what the user should see: the
|
|
87
|
-
*
|
|
93
|
+
* Pick what the user should see: the result of the last successful visit
|
|
94
|
+
* (from the trace, so a loop that ends on an earlier node shows that node) on
|
|
95
|
+
* success, the first failed node's error on failure. Without a trace, terminal
|
|
96
|
+
* nodes in result order. Everything is redacted first. Null when there is
|
|
88
97
|
* nothing to show (no schema fields and no summary text).
|
|
89
98
|
*/
|
|
90
|
-
export function resolveFinalOutput(workflow, results) {
|
|
99
|
+
export function resolveFinalOutput(workflow, results, opts = {}) {
|
|
100
|
+
const secrets = opts.secrets ?? collectSecretValues(process.env);
|
|
101
|
+
const clean = (v) => redact(v, secrets).value;
|
|
91
102
|
for (const [id, r] of results) {
|
|
92
103
|
if (r.status !== "failed")
|
|
93
104
|
continue;
|
|
94
105
|
const err = r.data?.error;
|
|
95
|
-
const text = typeof err === "string" && err.trim() !== "" ? err : "node failed";
|
|
106
|
+
const text = typeof err === "string" && err.trim() !== "" ? clean(err) : "node failed";
|
|
96
107
|
return { kind: "error", node: id, lines: trimBlock(text) };
|
|
97
108
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
109
|
+
let candidates;
|
|
110
|
+
if (opts.trace && opts.trace.steps.length > 0) {
|
|
111
|
+
// Last successful visit first: walk the trace backwards, each node once.
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
candidates = [];
|
|
114
|
+
for (let i = opts.trace.steps.length - 1; i >= 0; i--) {
|
|
115
|
+
const step = opts.trace.steps[i];
|
|
116
|
+
if (step.status !== "success" || seen.has(step.node))
|
|
117
|
+
continue;
|
|
118
|
+
seen.add(step.node);
|
|
119
|
+
const r = results.get(step.node);
|
|
120
|
+
if (r?.status === "success")
|
|
121
|
+
candidates.push([step.node, r]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const hasOutgoing = new Set(workflow.edges.map((e) => e.from));
|
|
126
|
+
const succeeded = [...results].filter(([, r]) => r.status === "success");
|
|
127
|
+
const terminal = succeeded.filter(([id]) => !hasOutgoing.has(id));
|
|
128
|
+
// Walk back from the end: the last node that actually yields something wins.
|
|
129
|
+
candidates = [...(terminal.length > 0 ? terminal : succeeded)].reverse();
|
|
130
|
+
}
|
|
103
131
|
for (const [id, r] of candidates) {
|
|
132
|
+
const data = clean(r.data ?? {});
|
|
104
133
|
const schema = workflow.nodes[id]?.output;
|
|
105
134
|
let lines = [];
|
|
106
135
|
if (schema)
|
|
107
|
-
lines = renderSchemaOutput(
|
|
136
|
+
lines = renderSchemaOutput(data, schema);
|
|
108
137
|
if (lines.length === 0) {
|
|
109
|
-
const summary =
|
|
138
|
+
const summary = data.summary;
|
|
110
139
|
if (typeof summary === "string" && summary.trim() !== "")
|
|
111
140
|
lines = trimBlock(summary);
|
|
112
141
|
}
|
|
@@ -115,6 +144,23 @@ export function resolveFinalOutput(workflow, results) {
|
|
|
115
144
|
}
|
|
116
145
|
return null;
|
|
117
146
|
}
|
|
147
|
+
/** True when the environment is CI (GitHub Actions, or a truthy `CI`). */
|
|
148
|
+
export function isCiEnv(env) {
|
|
149
|
+
if (env.GITHUB_ACTIONS === "true")
|
|
150
|
+
return true;
|
|
151
|
+
const ci = env.CI?.trim().toLowerCase();
|
|
152
|
+
return !!ci && ci !== "false" && ci !== "0";
|
|
153
|
+
}
|
|
154
|
+
/** Print the answer block? Always outside CI; in CI only with `--show-output`. */
|
|
155
|
+
export function shouldPrintFinalOutput(showOutput, env) {
|
|
156
|
+
return showOutput || !isCiEnv(env);
|
|
157
|
+
}
|
|
158
|
+
/** The one line printed in CI instead of the answer. */
|
|
159
|
+
export function formatSavedOutputLine(savedTo) {
|
|
160
|
+
return savedTo
|
|
161
|
+
? ` answer saved to ${savedTo} (pass --show-output to print it in CI)`
|
|
162
|
+
: " answer not printed in CI (pass --show-output to print it)";
|
|
163
|
+
}
|
|
118
164
|
/**
|
|
119
165
|
* Terminal text: the first `maxLines` lines, then
|
|
120
166
|
* `... (N more lines, see .sweny/runs/<id>/output.md)`. When no file was
|
|
@@ -140,13 +186,16 @@ export function formatFinalMarkdown(workflow, out) {
|
|
|
140
186
|
export function outputRelPath(runId) {
|
|
141
187
|
return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "output.md");
|
|
142
188
|
}
|
|
143
|
-
/** Write output.md. Local only. Returns the relative path, or null on failure. Never throws. */
|
|
189
|
+
/** Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws. */
|
|
144
190
|
export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
|
|
145
191
|
try {
|
|
146
192
|
const rel = outputRelPath(runId);
|
|
147
193
|
const file = path.join(cwd, rel);
|
|
148
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
149
|
-
fs.
|
|
194
|
+
fs.mkdirSync(path.dirname(path.dirname(file)), { recursive: true });
|
|
195
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
196
|
+
fs.writeFileSync(file, markdown, { mode: 0o600 });
|
|
197
|
+
// mode only applies on create: a resumed run overwrites an existing file.
|
|
198
|
+
fs.chmodSync(file, 0o600);
|
|
150
199
|
return rel;
|
|
151
200
|
}
|
|
152
201
|
catch {
|
package/dist/cli/main.js
CHANGED
|
@@ -31,11 +31,11 @@ import * as readline from "node:readline";
|
|
|
31
31
|
import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
|
|
32
32
|
import { buildCredentialMap } from "./credentials.js";
|
|
33
33
|
import { nonInteractiveUsage, runNew } from "./new.js";
|
|
34
|
-
import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
|
|
34
|
+
import { formatFinalMarkdown, formatFinalOutput, formatSavedOutputLine, resolveFinalOutput, shouldPrintFinalOutput, 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
37
|
import { registerTryCommand } from "./try.js";
|
|
38
|
-
import { JournalLockedError, JournalMismatchError, RunJournal } from "../journal.js";
|
|
38
|
+
import { JournalLockedError, JournalMismatchError, RunJournal, runSecretValues } from "../journal.js";
|
|
39
39
|
import { journalDisabled, prepareResume, RESUME_SHARED_RUN_FLAGS, WORKFLOW_RESUME_OPTIONS, } from "./resume.js";
|
|
40
40
|
import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
|
|
41
41
|
import { createVerboseToolObserver } from "./verbose-observer.js";
|
|
@@ -1102,12 +1102,18 @@ resume) {
|
|
|
1102
1102
|
writeStepSummary(workflow, results, receipt, trace);
|
|
1103
1103
|
// The answer, above the receipt: the terminal node's result, or the failed node's error.
|
|
1104
1104
|
// Also saved to .sweny/runs/<id>/output.md (local only; --no-history skips the file).
|
|
1105
|
-
|
|
1105
|
+
// Redacted with the journal's redactor; in CI printed only with --show-output.
|
|
1106
|
+
const finalOutput = resolveFinalOutput(workflow, results, {
|
|
1107
|
+
trace,
|
|
1108
|
+
secrets: runSecretValues([...skills.values(), ...builtinSkills], process.env),
|
|
1109
|
+
});
|
|
1106
1110
|
if (finalOutput) {
|
|
1107
1111
|
const savedTo = historyDisabled(options.history, fileConfig["history"])
|
|
1108
1112
|
? null
|
|
1109
1113
|
: writeFinalOutput(runId, formatFinalMarkdown(workflow, finalOutput));
|
|
1110
|
-
const block =
|
|
1114
|
+
const block = shouldPrintFinalOutput(options.showOutput === true, process.env)
|
|
1115
|
+
? formatFinalOutput(finalOutput, { outputPath: savedTo })
|
|
1116
|
+
: formatSavedOutputLine(savedTo);
|
|
1111
1117
|
if (wfHasFailed)
|
|
1112
1118
|
console.error(`${block}\n`);
|
|
1113
1119
|
else
|
|
@@ -1245,6 +1251,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
|
1245
1251
|
workflowRunCmd.option(flags, description);
|
|
1246
1252
|
}
|
|
1247
1253
|
workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
|
|
1254
|
+
workflowRunCmd.option("--show-output", "Print the final answer in CI too (off by default in CI: only the path to output.md is printed)");
|
|
1248
1255
|
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");
|
|
1249
1256
|
const workflowResumeCmd = workflowCmd
|
|
1250
1257
|
.command("resume <run-id>")
|
|
@@ -1283,6 +1290,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
|
1283
1290
|
workflowResumeCmd.option(flags, description);
|
|
1284
1291
|
}
|
|
1285
1292
|
workflowResumeCmd.option("--no-history", "Do not update this run's record in .sweny/runs/");
|
|
1293
|
+
workflowResumeCmd.option("--show-output", "Print the final answer in CI too (off by default in CI)");
|
|
1286
1294
|
registerRunsCommand(program);
|
|
1287
1295
|
workflowCmd
|
|
1288
1296
|
.command("diagram <file>")
|
package/dist/cli/resume.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path";
|
|
9
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";
|
|
10
|
+
import { JOURNAL_DIR, JOURNAL_FILE, JournalKeyError, JournalLockedError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, journalDir, mayRepeatWrites, readJournal, workflowHashOf, } from "../journal.js";
|
|
11
11
|
import { parseInputFlag } from "./workflow-input.js";
|
|
12
12
|
/** Resume-only flags; the rest are shared with `workflow run`. */
|
|
13
13
|
export const WORKFLOW_RESUME_OPTIONS = [
|
|
@@ -83,10 +83,14 @@ export function prepareResume(ref, opts, deps) {
|
|
|
83
83
|
read = readJournal(file, { repair: !opts.plan });
|
|
84
84
|
}
|
|
85
85
|
catch (err) {
|
|
86
|
-
if (err instanceof JournalVersionError)
|
|
86
|
+
if (err instanceof JournalVersionError || err instanceof JournalKeyError)
|
|
87
87
|
return fail(err.message);
|
|
88
88
|
return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
89
89
|
}
|
|
90
|
+
if (read.forgedAtLine !== undefined) {
|
|
91
|
+
return fail(`run journal ${file} line ${read.forgedAtLine} fails authentication: it was edited or written by something ` +
|
|
92
|
+
`other than this run. Refusing to resume. Start a new run.`);
|
|
93
|
+
}
|
|
90
94
|
if (read.corruptAtLine !== undefined) {
|
|
91
95
|
return fail(`run journal ${file} is damaged at line ${read.corruptAtLine}, with valid records after it. ` +
|
|
92
96
|
`That is not a torn last write, so it cannot be repaired safely. Start a new run.`);
|
|
@@ -122,6 +126,10 @@ export function prepareResume(ref, opts, deps) {
|
|
|
122
126
|
}
|
|
123
127
|
lines.push(`warning: --force: workflow ${workflowFile} changed since the run started`);
|
|
124
128
|
}
|
|
129
|
+
// Replayed control flow must exist in the workflow (not overridable by --force).
|
|
130
|
+
const flaw = checkJournalAgainstWorkflow(read.records, workflow);
|
|
131
|
+
if (flaw)
|
|
132
|
+
return fail(`cannot resume run ${runId}: ${flaw}. Start a new run.`);
|
|
125
133
|
let input;
|
|
126
134
|
if (opts.input !== undefined) {
|
|
127
135
|
const parsed = parseInputFlag(opts.input);
|
package/dist/executor.js
CHANGED
|
@@ -28,7 +28,7 @@ import "./skills/git-push.js";
|
|
|
28
28
|
import { fenceUntrusted } from "./untrusted.js";
|
|
29
29
|
import { asClaude } from "./harness/compat.js";
|
|
30
30
|
import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
|
|
31
|
-
import { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
|
|
31
|
+
import { BudgetGuard, describeOverrun, minLimits, spendOf, toLimits } from "./budget.js";
|
|
32
32
|
import { createShadowDecider, finishShadowDecision, startShadowDecision } from "./decider.js";
|
|
33
33
|
import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
|
|
34
34
|
import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
|
|
@@ -161,6 +161,11 @@ export async function execute(workflow, input, options) {
|
|
|
161
161
|
writeState,
|
|
162
162
|
harnessId: options.harness?.id,
|
|
163
163
|
});
|
|
164
|
+
// Resume (#363): the run budget is the logical run's, so it starts from what
|
|
165
|
+
// earlier attempts already spent, not from zero.
|
|
166
|
+
const priorSpend = journal?.priorSpend?.();
|
|
167
|
+
if (priorSpend)
|
|
168
|
+
budgetGuard.seed(priorSpend);
|
|
164
169
|
safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
|
|
165
170
|
safeObserve(observer, { type: "sources:resolved", sources: resolvedSources }, logger);
|
|
166
171
|
let currentId = workflow.entry;
|
|
@@ -205,8 +210,14 @@ export async function execute(workflow, input, options) {
|
|
|
205
210
|
else {
|
|
206
211
|
if (replay.next !== null) {
|
|
207
212
|
const key = `${from}→${replay.next}`;
|
|
208
|
-
|
|
209
|
-
const
|
|
213
|
+
const edge = workflow.edges.find((e) => e.from === from && e.to === replay.next);
|
|
214
|
+
const taken = (edgeCounts.get(key) ?? 0) + 1;
|
|
215
|
+
// A replayed route must be a real edge its max_iterations still allows.
|
|
216
|
+
if (!edge || (edge.max_iterations !== undefined && taken > edge.max_iterations)) {
|
|
217
|
+
throw new Error(`run journal routes ${from} -> ${replay.next}, which ${edge ? "exceeds the edge's max_iterations" : "is not an edge of this workflow"}; refusing to replay it`);
|
|
218
|
+
}
|
|
219
|
+
edgeCounts.set(key, taken);
|
|
220
|
+
const reason = whenLabel(edge.when) ?? "only path";
|
|
210
221
|
trace.edges.push({ from, to: replay.next, reason });
|
|
211
222
|
}
|
|
212
223
|
safeObserve(observer, { type: "route", from, to: replay.next ?? "(end)", reason: "replayed" }, logger);
|
|
@@ -431,6 +442,23 @@ export async function execute(workflow, input, options) {
|
|
|
431
442
|
// Only the final attempt's intents may be applied.
|
|
432
443
|
intents.length = 0;
|
|
433
444
|
const attemptBudget = budgetOn ? nodeBudget.attempt(signal) : undefined;
|
|
445
|
+
// Resume (#363): spend is journaled as it is reported, so a crash mid-node
|
|
446
|
+
// does not hand the resumed run a fresh budget.
|
|
447
|
+
const usageNode = currentId;
|
|
448
|
+
const usageAttempt = attempt;
|
|
449
|
+
let liveUsage;
|
|
450
|
+
const journalUsage = journal?.usage
|
|
451
|
+
? (u) => {
|
|
452
|
+
liveUsage = { ...liveUsage, ...u };
|
|
453
|
+
journal.usage(usageNode, iteration, usageAttempt, liveUsage, false);
|
|
454
|
+
}
|
|
455
|
+
: undefined;
|
|
456
|
+
const onUsage = attemptBudget || journalUsage
|
|
457
|
+
? (u) => {
|
|
458
|
+
attemptBudget?.onUsage(u);
|
|
459
|
+
journalUsage?.(u);
|
|
460
|
+
}
|
|
461
|
+
: undefined;
|
|
434
462
|
try {
|
|
435
463
|
result = await claude.run({
|
|
436
464
|
nodeId: currentId,
|
|
@@ -448,7 +476,7 @@ export async function execute(workflow, input, options) {
|
|
|
448
476
|
...(nodePolicy ? { policy: nodePolicy } : {}),
|
|
449
477
|
...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
|
|
450
478
|
...(readOnlyNode ? { readOnly: true } : {}),
|
|
451
|
-
...(
|
|
479
|
+
...(onUsage ? { onUsage } : {}),
|
|
452
480
|
onProgress: (message) => {
|
|
453
481
|
safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
|
|
454
482
|
},
|
|
@@ -458,6 +486,16 @@ export async function execute(workflow, input, options) {
|
|
|
458
486
|
attemptBudget?.dispose();
|
|
459
487
|
}
|
|
460
488
|
previous = result;
|
|
489
|
+
// The attempt's final spend: the larger of the result's usage and the last live report, per unit.
|
|
490
|
+
if (journal?.usage) {
|
|
491
|
+
const finalSpend = spendOf(result.usage);
|
|
492
|
+
const seen = spendOf(liveUsage);
|
|
493
|
+
const tokens = Math.max(finalSpend.tokens ?? 0, seen.tokens ?? 0);
|
|
494
|
+
const costUsd = Math.max(finalSpend.costUsd ?? 0, seen.costUsd ?? 0);
|
|
495
|
+
if (finalSpend.tokens !== undefined || finalSpend.costUsd !== undefined || liveUsage) {
|
|
496
|
+
journal.usage(usageNode, iteration, usageAttempt, { inputTokens: tokens, outputTokens: 0, costUsd }, true);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
461
499
|
if (budgetCheck && budgetCheck.degraded.length > 0) {
|
|
462
500
|
result = { ...result, degraded: [...new Set([...(result.degraded ?? []), ...budgetCheck.degraded])] };
|
|
463
501
|
}
|
|
@@ -48,6 +48,7 @@ import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises
|
|
|
48
48
|
import { homedir, tmpdir } from "node:os";
|
|
49
49
|
import * as path from "node:path";
|
|
50
50
|
import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
|
|
51
|
+
import { JOURNAL_DIR, runKeyDir } from "../journal.js";
|
|
51
52
|
import { policyGate } from "./policy.js";
|
|
52
53
|
// ─── srt settings ────────────────────────────────────────────────
|
|
53
54
|
/**
|
|
@@ -131,10 +132,17 @@ export function buildSrtSettings(req, opts) {
|
|
|
131
132
|
// cannot rename one out from under its read deny.
|
|
132
133
|
const masked = [...new Set((req.denyRead ?? []).map(real))].filter((p) => exists(p));
|
|
133
134
|
denyRead.push(...masked.filter((p) => !denyRead.includes(p)));
|
|
135
|
+
// Run journal keys (outside the workspace) are never readable by the agent,
|
|
136
|
+
// and the journals themselves are not writable.
|
|
137
|
+
const keyDir = runKeyDir();
|
|
138
|
+
if (exists(keyDir) && !denyRead.includes(keyDir))
|
|
139
|
+
denyRead.push(keyDir);
|
|
140
|
+
const journals = path.join(real(req.cwd), JOURNAL_DIR);
|
|
141
|
+
const denyWrite = !req.readOnly && exists(journals) ? [...masked, journals] : masked;
|
|
134
142
|
const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
|
|
135
143
|
return {
|
|
136
144
|
network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
|
|
137
|
-
filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite
|
|
145
|
+
filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite },
|
|
138
146
|
};
|
|
139
147
|
}
|
|
140
148
|
/** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
|