harnery 0.10.0 → 0.12.0
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/README.md +1 -1
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +2 -0
- package/dist/commands/policy.d.ts +6 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +42 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +62 -2
- package/dist/core/policy/evaluate.d.ts +14 -0
- package/dist/core/policy/evaluate.d.ts.map +1 -0
- package/dist/core/policy/evaluate.js +238 -0
- package/dist/core/policy/file.d.ts +3 -0
- package/dist/core/policy/file.d.ts.map +1 -0
- package/dist/core/policy/file.js +20 -0
- package/dist/core/policy/index.d.ts +5 -0
- package/dist/core/policy/index.d.ts.map +1 -0
- package/dist/core/policy/index.js +3 -0
- package/dist/core/policy/types.d.ts +91 -0
- package/dist/core/policy/types.d.ts.map +1 -0
- package/dist/core/policy/types.js +1 -0
- package/dist/core/workflow/engine.d.ts +5 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +412 -47
- package/dist/core/workflow/index.d.ts +5 -0
- package/dist/core/workflow/index.d.ts.map +1 -0
- package/dist/core/workflow/index.js +3 -0
- package/dist/core/workflow/proof.d.ts +49 -0
- package/dist/core/workflow/proof.d.ts.map +1 -0
- package/dist/core/workflow/proof.js +375 -0
- package/dist/core/workflow/types.d.ts +180 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +1 -1
- package/package.json +16 -6
- package/src/commander.ts +2 -0
- package/src/commands/policy.ts +50 -0
- package/src/commands/workflow.ts +82 -2
- package/src/core/policy/evaluate.ts +311 -0
- package/src/core/policy/file.ts +20 -0
- package/src/core/policy/index.ts +26 -0
- package/src/core/policy/types.ts +107 -0
- package/src/core/workflow/engine.ts +452 -53
- package/src/core/workflow/index.ts +44 -0
- package/src/core/workflow/proof.ts +485 -0
- package/src/core/workflow/types.ts +210 -0
|
@@ -21,16 +21,36 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
21
21
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
22
22
|
import { isAbsolute, join, resolve } from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { snapshotRepo } from "../context/index.ts";
|
|
25
|
+
import type { ExternalMutationRequest, PolicyDecision, PolicyRequest } from "../policy/index.ts";
|
|
26
|
+
import {
|
|
27
|
+
evaluatePolicy,
|
|
28
|
+
normalizePolicy,
|
|
29
|
+
PolicyDeniedError,
|
|
30
|
+
policyDigest,
|
|
31
|
+
summarizePolicyRequest,
|
|
32
|
+
} from "../policy/index.ts";
|
|
24
33
|
import { type BillingProbe, probeBilling } from "./billing.ts";
|
|
34
|
+
import {
|
|
35
|
+
buildWorkflowProof,
|
|
36
|
+
createEvidenceRecord,
|
|
37
|
+
digestResult,
|
|
38
|
+
normalizeWorkflowMeta,
|
|
39
|
+
writeWorkflowProof,
|
|
40
|
+
} from "./proof.ts";
|
|
25
41
|
import type {
|
|
26
42
|
AgentOpts,
|
|
27
43
|
EngineOpts,
|
|
28
44
|
HarnessName,
|
|
29
45
|
RunReport,
|
|
30
46
|
SpawnResult,
|
|
47
|
+
WorkflowAgentProof,
|
|
31
48
|
WorkflowContext,
|
|
49
|
+
WorkflowEvidenceRecord,
|
|
32
50
|
WorkflowModule,
|
|
51
|
+
WorkflowProof,
|
|
33
52
|
} from "./types.ts";
|
|
53
|
+
import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
|
|
34
54
|
import { parseStageOutput, validateAgainstSchema } from "./validate.ts";
|
|
35
55
|
|
|
36
56
|
const DEFAULT_MAX_AGENTS = 50;
|
|
@@ -38,6 +58,20 @@ const DEFAULT_CONCURRENCY = 4;
|
|
|
38
58
|
const DEFAULT_MAX_ATTEMPTS = 2;
|
|
39
59
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
40
60
|
const DEFAULT_MAX_TURNS = 25;
|
|
61
|
+
const DEFAULT_POLICY_ASK_TIMEOUT_MS = 60_000;
|
|
62
|
+
const MAX_POLICY_DECISIONS = 50;
|
|
63
|
+
|
|
64
|
+
export class WorkflowRunError extends Error {
|
|
65
|
+
readonly runId: string;
|
|
66
|
+
readonly proofPath: string;
|
|
67
|
+
|
|
68
|
+
constructor(message: string, runId: string, proofPath: string, cause?: unknown) {
|
|
69
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
70
|
+
this.name = "WorkflowRunError";
|
|
71
|
+
this.runId = runId;
|
|
72
|
+
this.proofPath = proofPath;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
41
75
|
|
|
42
76
|
export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise<RunReport> {
|
|
43
77
|
const absScript = isAbsolute(scriptPath) ? scriptPath : resolve(process.cwd(), scriptPath);
|
|
@@ -45,18 +79,25 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
45
79
|
if (typeof mod.default !== "function") {
|
|
46
80
|
throw new Error(`${scriptPath}: workflow script must \`export default async (ctx) => …\``);
|
|
47
81
|
}
|
|
48
|
-
const
|
|
82
|
+
const fallbackName = scriptPath.replace(/^.*\//, "").replace(/\.[cm]?js$/, "");
|
|
83
|
+
const meta = normalizeWorkflowMeta(mod.meta, fallbackName);
|
|
84
|
+
const name = meta.name;
|
|
49
85
|
|
|
50
86
|
const runId = `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
|
|
51
87
|
const runDir = join(opts.coordRoot, ".harnery", "workflows", runId);
|
|
52
88
|
mkdirSync(runDir, { recursive: true });
|
|
53
89
|
const journalPath = join(runDir, "journal.jsonl");
|
|
90
|
+
const proofPath = join(runDir, "proof.json");
|
|
54
91
|
|
|
55
92
|
const maxAgents = opts.maxAgents ?? DEFAULT_MAX_AGENTS;
|
|
56
93
|
const concurrency = opts.concurrency ?? DEFAULT_CONCURRENCY;
|
|
57
94
|
const cwd = opts.cwd ?? opts.coordRoot;
|
|
58
95
|
const log = opts.onLog ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
59
96
|
const defaultHarness: HarnessName = opts.defaultHarness ?? "claude-code";
|
|
97
|
+
const isolation = opts.isolation ?? "shared";
|
|
98
|
+
const networkAccess = opts.networkAccess ?? "unknown";
|
|
99
|
+
const policy = opts.policy ? normalizePolicy(opts.policy, { baseDir: cwd }) : undefined;
|
|
100
|
+
const repoBefore = snapshotRepo(cwd);
|
|
60
101
|
|
|
61
102
|
// Per-child fixed context overhead: children spawn in `cwd` and load its
|
|
62
103
|
// repo-instructions file into their system prompt, cache-writing it once
|
|
@@ -77,12 +118,21 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
77
118
|
let agentsSpawned = 0;
|
|
78
119
|
let agentsCached = 0;
|
|
79
120
|
let costUsd = 0;
|
|
121
|
+
let reservedCostUsd = 0;
|
|
80
122
|
let currentStage = "";
|
|
81
123
|
let agentSeq = 0;
|
|
124
|
+
let evidenceSeq = 0;
|
|
125
|
+
let policySeq = 0;
|
|
82
126
|
const billingProbed = new Map<HarnessName, BillingProbe>();
|
|
127
|
+
const agentProofs = new Map<string, WorkflowAgentProof>();
|
|
128
|
+
const evidenceRecords: WorkflowEvidenceRecord[] = [];
|
|
129
|
+
const policyDecisions: PolicyDecision[] = [];
|
|
130
|
+
const acceptanceIds = new Set(meta.acceptance.map((criterion) => criterion.id));
|
|
83
131
|
|
|
84
132
|
const journal = (event: string, data: Record<string, unknown>): void => {
|
|
85
133
|
const line = JSON.stringify({
|
|
134
|
+
schema_version: WORKFLOW_PROOF_SCHEMA_VERSION,
|
|
135
|
+
run_id: runId,
|
|
86
136
|
ts: new Date().toISOString(),
|
|
87
137
|
event,
|
|
88
138
|
stage: currentStage,
|
|
@@ -109,7 +159,98 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
109
159
|
waiters.shift()?.();
|
|
110
160
|
};
|
|
111
161
|
|
|
162
|
+
// Policy checks that affect the shared cost reservation ledger serialize.
|
|
163
|
+
// Spawns still run concurrently after authorization; only the last-moment
|
|
164
|
+
// check-and-reserve section is exclusive.
|
|
165
|
+
let policyGate = Promise.resolve();
|
|
166
|
+
const withDispatchPolicyGate = async <T>(work: () => Promise<T>): Promise<T> => {
|
|
167
|
+
const previous = policyGate;
|
|
168
|
+
let open!: () => void;
|
|
169
|
+
policyGate = new Promise<void>((resolveGate) => {
|
|
170
|
+
open = resolveGate;
|
|
171
|
+
});
|
|
172
|
+
await previous;
|
|
173
|
+
try {
|
|
174
|
+
return await work();
|
|
175
|
+
} finally {
|
|
176
|
+
open();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const authorizePolicyRequest = async (rawRequest: PolicyRequest): Promise<PolicyDecision> => {
|
|
181
|
+
if (!policy) {
|
|
182
|
+
throw new Error("external mutation authorization requires a host policy");
|
|
183
|
+
}
|
|
184
|
+
if (policyDecisions.length >= MAX_POLICY_DECISIONS) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`workflow policy decision cap reached (${MAX_POLICY_DECISIONS}); split the workflow or reduce protected actions`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const request = summarizePolicyRequest(rawRequest);
|
|
190
|
+
const evaluation = evaluatePolicy(policy, request);
|
|
191
|
+
const id = `p${++policySeq}`;
|
|
192
|
+
const checkedAt = new Date().toISOString();
|
|
193
|
+
journal("policy.check", {
|
|
194
|
+
id,
|
|
195
|
+
policy: policy.name,
|
|
196
|
+
policy_sha256: policyDigest(policy),
|
|
197
|
+
request,
|
|
198
|
+
verdict: evaluation.verdict,
|
|
199
|
+
rules: evaluation.rules,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let verdict: "allow" | "deny" = evaluation.verdict === "allow" ? "allow" : "deny";
|
|
203
|
+
let resolvedBy: PolicyDecision["resolved_by"] = "policy";
|
|
204
|
+
let reason = evaluation.reason;
|
|
205
|
+
if (evaluation.verdict === "ask") {
|
|
206
|
+
resolvedBy = "fail_closed";
|
|
207
|
+
if (!opts.resolvePolicyAsk) {
|
|
208
|
+
reason = `${evaluation.reason}; no host approval resolver is configured`;
|
|
209
|
+
} else {
|
|
210
|
+
try {
|
|
211
|
+
const resolution = await withTimeout(
|
|
212
|
+
Promise.resolve(opts.resolvePolicyAsk(request, evaluation)),
|
|
213
|
+
opts.policyAskTimeoutMs ?? DEFAULT_POLICY_ASK_TIMEOUT_MS,
|
|
214
|
+
);
|
|
215
|
+
if (!resolution || (resolution.verdict !== "allow" && resolution.verdict !== "deny")) {
|
|
216
|
+
reason = `${evaluation.reason}; host returned an invalid approval resolution`;
|
|
217
|
+
} else {
|
|
218
|
+
verdict = resolution.verdict;
|
|
219
|
+
resolvedBy = "host";
|
|
220
|
+
reason = boundedPolicyReason(resolution.reason ?? evaluation.reason);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
reason = `${evaluation.reason}; approval failed closed: ${(error as Error).message}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const decision: PolicyDecision = {
|
|
229
|
+
id,
|
|
230
|
+
checked_at: checkedAt,
|
|
231
|
+
policy: policy.name,
|
|
232
|
+
phase: request.phase,
|
|
233
|
+
initial_verdict: evaluation.verdict,
|
|
234
|
+
verdict,
|
|
235
|
+
resolved_by: resolvedBy,
|
|
236
|
+
reason: boundedPolicyReason(reason),
|
|
237
|
+
rule_codes: evaluation.rules.map((rule) => rule.code),
|
|
238
|
+
request,
|
|
239
|
+
};
|
|
240
|
+
policyDecisions.push(decision);
|
|
241
|
+
journal("policy.resolve", { ...decision });
|
|
242
|
+
if (decision.verdict === "deny") {
|
|
243
|
+
throw new PolicyDeniedError(
|
|
244
|
+
`policy ${JSON.stringify(policy.name)} denied ${request.phase} ${JSON.stringify(request.action)}: ${decision.reason}`,
|
|
245
|
+
decision.id,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return decision;
|
|
249
|
+
};
|
|
250
|
+
|
|
112
251
|
const agent = async (prompt: string, agentOpts: AgentOpts = {}): Promise<unknown> => {
|
|
252
|
+
let reservedForDispatch = 0;
|
|
253
|
+
let spawnCountClaimed = false;
|
|
113
254
|
const harness = agentOpts.harness ?? defaultHarness;
|
|
114
255
|
const spawner = opts.spawners[harness];
|
|
115
256
|
if (!spawner) {
|
|
@@ -119,7 +260,19 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
119
260
|
}
|
|
120
261
|
const id = `a${++agentSeq}`;
|
|
121
262
|
const label = agentOpts.label ?? `${prompt.slice(0, 60).replace(/\s+/g, " ")}…`;
|
|
263
|
+
const proofLabel = agentOpts.label ?? id;
|
|
122
264
|
const maxAttempts = agentOpts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
265
|
+
const agentProof: WorkflowAgentProof = {
|
|
266
|
+
id,
|
|
267
|
+
label: proofLabel,
|
|
268
|
+
stage: currentStage || undefined,
|
|
269
|
+
harness,
|
|
270
|
+
model: agentOpts.model,
|
|
271
|
+
status: "failed",
|
|
272
|
+
attempts: 0,
|
|
273
|
+
duration_ms: 0,
|
|
274
|
+
};
|
|
275
|
+
agentProofs.set(id, agentProof);
|
|
123
276
|
|
|
124
277
|
// Call identity for resume: same stage + harness + model + effort + turns + schema
|
|
125
278
|
// + ORIGINAL prompt → same key. Retry-mutated prompts never enter the key.
|
|
@@ -127,63 +280,141 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
127
280
|
const cached = resumeCache.get(key);
|
|
128
281
|
if (cached) {
|
|
129
282
|
agentsCached++;
|
|
130
|
-
|
|
283
|
+
agentProof.status = "cached";
|
|
284
|
+
agentProof.result = digestResult(cached.value, cached.kind);
|
|
285
|
+
journal("agent.cached", {
|
|
286
|
+
id,
|
|
287
|
+
label,
|
|
288
|
+
key,
|
|
289
|
+
harness,
|
|
290
|
+
model: agentOpts.model ?? null,
|
|
291
|
+
kind: cached.kind,
|
|
292
|
+
});
|
|
131
293
|
log(
|
|
132
294
|
`[${name}] ${currentStage || "(no stage)"} → ${id} ${label} (cached from ${opts.resumeFrom})`,
|
|
133
295
|
);
|
|
134
296
|
return cached.value;
|
|
135
297
|
}
|
|
136
298
|
|
|
299
|
+
if (policy) {
|
|
300
|
+
const estimateRequest = summarizePolicyRequest({
|
|
301
|
+
phase: "dispatch",
|
|
302
|
+
action: "spawn agent",
|
|
303
|
+
path: cwd,
|
|
304
|
+
harness,
|
|
305
|
+
model: agentOpts.model,
|
|
306
|
+
effort: agentOpts.effort,
|
|
307
|
+
max_attempts: maxAttempts,
|
|
308
|
+
max_turns: agentOpts.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
309
|
+
timeout_ms: agentOpts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
310
|
+
prompt_bytes: Buffer.byteLength(prompt),
|
|
311
|
+
isolation,
|
|
312
|
+
network_access: networkAccess,
|
|
313
|
+
current_cost_usd: round4(costUsd + reservedCostUsd),
|
|
314
|
+
projected_cost_usd: null,
|
|
315
|
+
});
|
|
316
|
+
let projectedCost: number | null = null;
|
|
317
|
+
if (policy.max_cost_usd !== undefined && opts.estimateDispatchCost) {
|
|
318
|
+
try {
|
|
319
|
+
const candidate = await opts.estimateDispatchCost(estimateRequest);
|
|
320
|
+
if (candidate !== null && (!Number.isFinite(candidate) || candidate < 0)) {
|
|
321
|
+
throw new Error("cost estimator returned a non-finite or negative value");
|
|
322
|
+
}
|
|
323
|
+
projectedCost = candidate;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
journal("policy.cost_estimate_failed", {
|
|
326
|
+
harness,
|
|
327
|
+
model: agentOpts.model ?? null,
|
|
328
|
+
error: boundedPolicyReason((error as Error).message),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await withDispatchPolicyGate(async () => {
|
|
334
|
+
assertAgentCapacity(agentsSpawned, maxAgents);
|
|
335
|
+
const dispatchRequest = {
|
|
336
|
+
...estimateRequest,
|
|
337
|
+
current_cost_usd: round4(costUsd + reservedCostUsd),
|
|
338
|
+
projected_cost_usd: projectedCost,
|
|
339
|
+
};
|
|
340
|
+
await authorizePolicyRequest(dispatchRequest);
|
|
341
|
+
if (policy.max_cost_usd !== undefined) {
|
|
342
|
+
reservedForDispatch =
|
|
343
|
+
projectedCost ?? Math.max(0, policy.max_cost_usd - dispatchRequest.current_cost_usd);
|
|
344
|
+
reservedCostUsd += reservedForDispatch;
|
|
345
|
+
}
|
|
346
|
+
agentsSpawned++;
|
|
347
|
+
spawnCountClaimed = true;
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
agentProof.error = (error as Error).message;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
try {
|
|
355
|
+
assertAgentCapacity(agentsSpawned, maxAgents);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
agentProof.error = (error as Error).message;
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
137
362
|
// Billing safeguard: on a harness's FIRST spawn this run, classify which
|
|
138
363
|
// auth its children will use and refuse the silent-override state (an
|
|
139
364
|
// exported API key shadowing a stored subscription login) unless the
|
|
140
365
|
// caller explicitly opted into API billing. Cached agents never reach
|
|
141
366
|
// this — no spawn, no billing.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
367
|
+
try {
|
|
368
|
+
if (!billingProbed.has(harness)) {
|
|
369
|
+
const probe = (opts.probeBilling ?? probeBilling)(harness);
|
|
370
|
+
billingProbed.set(harness, probe);
|
|
371
|
+
journal("billing.probe", {
|
|
372
|
+
harness,
|
|
373
|
+
mode: opts.subscriptionOnly ? "subscription" : probe.mode,
|
|
374
|
+
api_key_source: probe.apiKeySource,
|
|
375
|
+
login: probe.login,
|
|
376
|
+
subscription_only: Boolean(opts.subscriptionOnly),
|
|
377
|
+
});
|
|
378
|
+
if (opts.subscriptionOnly) {
|
|
379
|
+
if (probe.login === "absent") {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`subscription-only: no stored login detected for ${harness}; ` +
|
|
382
|
+
`log the harness CLI in (or drop --subscription-only for a key-only host)`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
|
|
386
|
+
} else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
|
|
154
387
|
throw new Error(
|
|
155
|
-
|
|
156
|
-
`
|
|
388
|
+
`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
|
|
389
|
+
`overrides your subscription auth, so children would bill per-token API rates. ` +
|
|
390
|
+
`Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
|
|
391
|
+
`child envs, or pass --allow-api-billing if API billing is intended`,
|
|
392
|
+
);
|
|
393
|
+
} else if (probe.mode === "api-key") {
|
|
394
|
+
log(
|
|
395
|
+
`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
|
|
396
|
+
`children bill per-token rates`,
|
|
397
|
+
);
|
|
398
|
+
} else if (probe.mode === "api-key-override") {
|
|
399
|
+
log(
|
|
400
|
+
`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
|
|
157
401
|
);
|
|
402
|
+
} else {
|
|
403
|
+
log(`[billing] ${harness}: subscription login`);
|
|
158
404
|
}
|
|
159
|
-
log(`[billing] ${harness}: subscription-only (API-key vars scrubbed from child env)`);
|
|
160
|
-
} else if (probe.mode === "api-key-override" && !opts.allowApiBilling) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
`${probe.apiKeySource} is set AND a stored ${harness} login exists — the key silently ` +
|
|
163
|
-
`overrides your subscription auth, so children would bill per-token API rates. ` +
|
|
164
|
-
`Either unset ${probe.apiKeySource}, run with --subscription-only to scrub it from ` +
|
|
165
|
-
`child envs, or pass --allow-api-billing if API billing is intended`,
|
|
166
|
-
);
|
|
167
|
-
} else if (probe.mode === "api-key") {
|
|
168
|
-
log(
|
|
169
|
-
`[billing] ${harness}: API-key billing (${probe.apiKeySource}; no stored login detected) — ` +
|
|
170
|
-
`children bill per-token rates`,
|
|
171
|
-
);
|
|
172
|
-
} else if (probe.mode === "api-key-override") {
|
|
173
|
-
log(
|
|
174
|
-
`[billing] ${harness}: API-key billing (--allow-api-billing; key overrides stored login)`,
|
|
175
|
-
);
|
|
176
|
-
} else {
|
|
177
|
-
log(`[billing] ${harness}: subscription login`);
|
|
178
405
|
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
|
|
408
|
+
reservedForDispatch = 0;
|
|
409
|
+
if (spawnCountClaimed) {
|
|
410
|
+
agentsSpawned--;
|
|
411
|
+
spawnCountClaimed = false;
|
|
412
|
+
}
|
|
413
|
+
agentProof.error = (error as Error).message;
|
|
414
|
+
throw error;
|
|
179
415
|
}
|
|
180
416
|
|
|
181
|
-
if (
|
|
182
|
-
throw new Error(
|
|
183
|
-
`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
agentsSpawned++;
|
|
417
|
+
if (!spawnCountClaimed) agentsSpawned++;
|
|
187
418
|
|
|
188
419
|
await acquire();
|
|
189
420
|
try {
|
|
@@ -199,6 +430,7 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
199
430
|
|
|
200
431
|
let attemptPrompt = prompt;
|
|
201
432
|
let last: SpawnResult | null = null;
|
|
433
|
+
let agentCostUsd = 0;
|
|
202
434
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
203
435
|
last = await spawner({
|
|
204
436
|
prompt: attemptPrompt,
|
|
@@ -210,6 +442,9 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
210
442
|
runId,
|
|
211
443
|
subscriptionOnly: opts.subscriptionOnly,
|
|
212
444
|
});
|
|
445
|
+
agentProof.attempts = attempt;
|
|
446
|
+
agentProof.duration_ms += last.durationMs;
|
|
447
|
+
agentCostUsd += last.costUsd ?? 0;
|
|
213
448
|
costUsd += last.costUsd ?? 0;
|
|
214
449
|
|
|
215
450
|
if (!last.ok) {
|
|
@@ -217,6 +452,11 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
217
452
|
continue; // spawn-level failure: retry with the original prompt
|
|
218
453
|
}
|
|
219
454
|
if (!agentOpts.schema) {
|
|
455
|
+
agentProof.status = "succeeded";
|
|
456
|
+
agentProof.cost_usd =
|
|
457
|
+
agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
|
|
458
|
+
agentProof.session_id = last.sessionId;
|
|
459
|
+
agentProof.result = digestResult(last.text, "text");
|
|
220
460
|
journal("agent.end", {
|
|
221
461
|
id,
|
|
222
462
|
key,
|
|
@@ -236,6 +476,11 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
236
476
|
? [parsed.error]
|
|
237
477
|
: validateAgainstSchema(parsed.value, agentOpts.schema);
|
|
238
478
|
if (problems.length === 0) {
|
|
479
|
+
agentProof.status = "succeeded";
|
|
480
|
+
agentProof.cost_usd =
|
|
481
|
+
agentCostUsd > 0 || last.costUsd !== undefined ? agentCostUsd : undefined;
|
|
482
|
+
agentProof.session_id = last.sessionId;
|
|
483
|
+
agentProof.result = digestResult(parsed.value, "json");
|
|
239
484
|
journal("agent.end", {
|
|
240
485
|
id,
|
|
241
486
|
key,
|
|
@@ -261,9 +506,17 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
261
506
|
const reason = last?.ok
|
|
262
507
|
? `schema validation failed after ${maxAttempts} attempt(s)`
|
|
263
508
|
: (last?.error ?? "spawn failed");
|
|
509
|
+
agentProof.cost_usd =
|
|
510
|
+
agentCostUsd > 0 || last?.costUsd !== undefined ? agentCostUsd : undefined;
|
|
511
|
+
agentProof.session_id = last?.sessionId;
|
|
512
|
+
agentProof.error = reason;
|
|
264
513
|
journal("agent.failed", { id, error: reason });
|
|
265
|
-
throw new Error(`agent ${
|
|
514
|
+
throw new Error(`agent ${proofLabel}: ${reason}`);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
agentProof.error ??= (error as Error).message;
|
|
517
|
+
throw error;
|
|
266
518
|
} finally {
|
|
519
|
+
reservedCostUsd = Math.max(0, reservedCostUsd - reservedForDispatch);
|
|
267
520
|
release();
|
|
268
521
|
}
|
|
269
522
|
};
|
|
@@ -288,12 +541,91 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
288
541
|
log(`[${name}] ── stage: ${title}`);
|
|
289
542
|
};
|
|
290
543
|
|
|
291
|
-
const
|
|
544
|
+
const evidence = (input: Parameters<WorkflowContext["evidence"]>[0]): string => {
|
|
545
|
+
const record = createEvidenceRecord({
|
|
546
|
+
value: input,
|
|
547
|
+
sequence: evidenceSeq + 1,
|
|
548
|
+
acceptanceIds,
|
|
549
|
+
stage: currentStage || undefined,
|
|
550
|
+
});
|
|
551
|
+
evidenceSeq++;
|
|
552
|
+
evidenceRecords.push(record);
|
|
553
|
+
journal("evidence.recorded", { ...record });
|
|
554
|
+
return record.id;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const authorize = async (input: ExternalMutationRequest): Promise<PolicyDecision> => {
|
|
558
|
+
const network =
|
|
559
|
+
input.network === true || input.service !== undefined || input.target !== undefined
|
|
560
|
+
? "enabled"
|
|
561
|
+
: "disabled";
|
|
562
|
+
return authorizePolicyRequest({
|
|
563
|
+
phase: "external_mutation",
|
|
564
|
+
action: input.action,
|
|
565
|
+
path: input.path ? resolve(cwd, input.path) : undefined,
|
|
566
|
+
isolation,
|
|
567
|
+
network_access: network,
|
|
568
|
+
service: input.service,
|
|
569
|
+
target: input.target,
|
|
570
|
+
current_cost_usd: round4(costUsd),
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const ctx: WorkflowContext = { agent, parallel, stage, log, evidence, authorize };
|
|
292
575
|
|
|
293
576
|
const t0 = Date.now();
|
|
294
|
-
|
|
577
|
+
const startedAt = new Date(t0).toISOString();
|
|
578
|
+
journal("run.start", {
|
|
579
|
+
name,
|
|
580
|
+
script: absScript,
|
|
581
|
+
objective: meta.objective ?? null,
|
|
582
|
+
acceptance: meta.acceptance,
|
|
583
|
+
max_agents: maxAgents,
|
|
584
|
+
concurrency,
|
|
585
|
+
policy: policy ? { name: policy.name, sha256: policyDigest(policy) } : null,
|
|
586
|
+
isolation,
|
|
587
|
+
network_access: networkAccess,
|
|
588
|
+
});
|
|
295
589
|
try {
|
|
296
590
|
const result = await mod.default(ctx);
|
|
591
|
+
const endedAt = new Date().toISOString();
|
|
592
|
+
const durationMs = Date.now() - t0;
|
|
593
|
+
journal("run.end", {
|
|
594
|
+
ok: true,
|
|
595
|
+
agents: agentsSpawned,
|
|
596
|
+
cached: agentsCached,
|
|
597
|
+
cost_usd: round4(costUsd),
|
|
598
|
+
duration_ms: durationMs,
|
|
599
|
+
});
|
|
600
|
+
let proof: WorkflowProof;
|
|
601
|
+
try {
|
|
602
|
+
proof = buildWorkflowProof({
|
|
603
|
+
runId,
|
|
604
|
+
meta,
|
|
605
|
+
status: "succeeded",
|
|
606
|
+
startedAt,
|
|
607
|
+
endedAt,
|
|
608
|
+
durationMs,
|
|
609
|
+
journalPath,
|
|
610
|
+
before: repoBefore,
|
|
611
|
+
after: snapshotRepo(cwd),
|
|
612
|
+
agents: Array.from(agentProofs.values()),
|
|
613
|
+
evidence: evidenceRecords,
|
|
614
|
+
harnessEvidence: opts.harnessEvidence,
|
|
615
|
+
policy: policy
|
|
616
|
+
? { config: policy, decisions: policyDecisions, isolation, networkAccess }
|
|
617
|
+
: undefined,
|
|
618
|
+
result,
|
|
619
|
+
});
|
|
620
|
+
writeWorkflowProof(proofPath, proof);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
throw new WorkflowRunError(
|
|
623
|
+
`workflow completed but its proof packet could not be written: ${(error as Error).message}`,
|
|
624
|
+
runId,
|
|
625
|
+
proofPath,
|
|
626
|
+
error,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
297
629
|
const report: RunReport = {
|
|
298
630
|
runId,
|
|
299
631
|
name,
|
|
@@ -301,31 +633,59 @@ export async function runWorkflow(scriptPath: string, opts: EngineOpts): Promise
|
|
|
301
633
|
agentsSpawned,
|
|
302
634
|
agentsCached,
|
|
303
635
|
costUsd: round4(costUsd),
|
|
304
|
-
durationMs
|
|
636
|
+
durationMs,
|
|
305
637
|
journalPath,
|
|
638
|
+
proofPath,
|
|
639
|
+
acceptance: proof.acceptance.summary,
|
|
306
640
|
contextTokensPerChildEstimate,
|
|
307
641
|
billing: Array.from(billingProbed.values()).map((p) => ({
|
|
308
642
|
harness: p.harness,
|
|
309
643
|
mode: opts.subscriptionOnly ? "subscription" : p.mode,
|
|
310
644
|
})),
|
|
645
|
+
policy: proof.policy?.summary,
|
|
311
646
|
};
|
|
312
|
-
journal("run.end", {
|
|
313
|
-
ok: true,
|
|
314
|
-
agents: agentsSpawned,
|
|
315
|
-
cached: agentsCached,
|
|
316
|
-
cost_usd: report.costUsd,
|
|
317
|
-
duration_ms: report.durationMs,
|
|
318
|
-
});
|
|
319
647
|
return report;
|
|
320
648
|
} catch (err) {
|
|
649
|
+
if (err instanceof WorkflowRunError) throw err;
|
|
650
|
+
const endedAt = new Date().toISOString();
|
|
651
|
+
const durationMs = Date.now() - t0;
|
|
321
652
|
journal("run.end", {
|
|
322
653
|
ok: false,
|
|
323
654
|
error: (err as Error).message,
|
|
324
655
|
agents: agentsSpawned,
|
|
325
656
|
cached: agentsCached,
|
|
326
657
|
cost_usd: round4(costUsd),
|
|
658
|
+
duration_ms: durationMs,
|
|
327
659
|
});
|
|
328
|
-
|
|
660
|
+
try {
|
|
661
|
+
const proof = buildWorkflowProof({
|
|
662
|
+
runId,
|
|
663
|
+
meta,
|
|
664
|
+
status: "failed",
|
|
665
|
+
startedAt,
|
|
666
|
+
endedAt,
|
|
667
|
+
durationMs,
|
|
668
|
+
journalPath,
|
|
669
|
+
before: repoBefore,
|
|
670
|
+
after: snapshotRepo(cwd),
|
|
671
|
+
agents: Array.from(agentProofs.values()),
|
|
672
|
+
evidence: evidenceRecords,
|
|
673
|
+
harnessEvidence: opts.harnessEvidence,
|
|
674
|
+
policy: policy
|
|
675
|
+
? { config: policy, decisions: policyDecisions, isolation, networkAccess }
|
|
676
|
+
: undefined,
|
|
677
|
+
error: (err as Error).message,
|
|
678
|
+
});
|
|
679
|
+
writeWorkflowProof(proofPath, proof);
|
|
680
|
+
} catch (proofError) {
|
|
681
|
+
throw new WorkflowRunError(
|
|
682
|
+
`${(err as Error).message}; proof packet write also failed: ${(proofError as Error).message}`,
|
|
683
|
+
runId,
|
|
684
|
+
proofPath,
|
|
685
|
+
err,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
throw new WorkflowRunError((err as Error).message, runId, proofPath, err);
|
|
329
689
|
}
|
|
330
690
|
}
|
|
331
691
|
|
|
@@ -401,3 +761,42 @@ function loadResumeCache(
|
|
|
401
761
|
function round4(n: number): number {
|
|
402
762
|
return Math.round(n * 10_000) / 10_000;
|
|
403
763
|
}
|
|
764
|
+
|
|
765
|
+
function assertAgentCapacity(agentsSpawned: number, maxAgents: number): void {
|
|
766
|
+
if (agentsSpawned >= maxAgents) {
|
|
767
|
+
throw new Error(
|
|
768
|
+
`workflow agent cap reached (${maxAgents}); raise --max-agents deliberately if the fan-out is intended`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function boundedPolicyReason(value: string): string {
|
|
774
|
+
const normalized = Array.from(value, (character) => {
|
|
775
|
+
const code = character.charCodeAt(0);
|
|
776
|
+
return code < 32 || code === 127 ? " " : character;
|
|
777
|
+
})
|
|
778
|
+
.join("")
|
|
779
|
+
.replace(/\s+/g, " ")
|
|
780
|
+
.trim();
|
|
781
|
+
return normalized.length > 2_000 ? `${normalized.slice(0, 1_999)}…` : normalized;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
785
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
786
|
+
throw new Error("policyAskTimeoutMs must be a positive finite number");
|
|
787
|
+
}
|
|
788
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
789
|
+
try {
|
|
790
|
+
return await Promise.race([
|
|
791
|
+
promise,
|
|
792
|
+
new Promise<never>((_, reject) => {
|
|
793
|
+
timer = setTimeout(
|
|
794
|
+
() => reject(new Error(`approval timed out after ${timeoutMs}ms`)),
|
|
795
|
+
timeoutMs,
|
|
796
|
+
);
|
|
797
|
+
}),
|
|
798
|
+
]);
|
|
799
|
+
} finally {
|
|
800
|
+
if (timer) clearTimeout(timer);
|
|
801
|
+
}
|
|
802
|
+
}
|