patchwork-os 1.2.0-beta.2.canary.837 → 1.2.0-beta.2.canary.839
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/commands/recipe.d.ts +5 -0
- package/dist/commands/recipe.js +11 -0
- package/dist/commands/recipe.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +11 -0
- package/dist/recipes/chainedRunner.js +14 -1
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/replayBoundary.d.ts +36 -0
- package/dist/recipes/replayBoundary.js +36 -0
- package/dist/recipes/replayBoundary.js.map +1 -1
- package/dist/recipes/replayRun.d.ts +3 -0
- package/dist/recipes/replayRun.js +53 -7
- package/dist/recipes/replayRun.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +21 -1
- package/dist/recipes/yamlRunner.js +78 -18
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/package.json +1 -1
|
@@ -67,7 +67,7 @@ import { resolveLocalEndpoint, resolveLocalModel } from "./localSettings.js";
|
|
|
67
67
|
import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./migrations/index.js";
|
|
68
68
|
import { costRouter } from "./pricing/costRouter.js";
|
|
69
69
|
import { loadPriceTable, costUsd as priceCostUsd, } from "./pricing/priceTable.js";
|
|
70
|
-
import { ReplayIncompleteError } from "./replayBoundary.js";
|
|
70
|
+
import { ReplayIncompleteError, TestModeRefusalError, } from "./replayBoundary.js";
|
|
71
71
|
import { resolveRecipePath } from "./resolveRecipePath.js";
|
|
72
72
|
import { RunBudget } from "./runBudget.js";
|
|
73
73
|
import { recordAttemptRun } from "./runLedgers.js";
|
|
@@ -961,6 +961,9 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
961
961
|
// agent step would. It returns `{ value, ok }`; `ok: false` signals a
|
|
962
962
|
// failed / silent-fail / empty agent response — the caller stops the loop
|
|
963
963
|
// and treats it as exhausted (we don't re-judge a non-result).
|
|
964
|
+
// The agent step currently executing, for replay refusals raised from the
|
|
965
|
+
// refine-loop / nested-agent helpers below (they do not take a step id).
|
|
966
|
+
let currentAgentStepId;
|
|
964
967
|
const runAgentText = async (prompt, driver, model, mcpAccess, downshift, providerOptions,
|
|
965
968
|
// P0-5: carry the reviewed/judge step's opt-in tool sandbox into refine-loop
|
|
966
969
|
// re-runs so a sandboxed step STAYS sandboxed across revisions/re-judges.
|
|
@@ -995,7 +998,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
995
998
|
...(deps.agentDisallowedTools?.length && { enforceSandbox: true }),
|
|
996
999
|
...(providerOptions && { providerOptions }),
|
|
997
1000
|
...(dataPolicy !== undefined && { boundary: { dataPolicy } }),
|
|
998
|
-
}, buildAgentExecutorDeps(stepDeps, deps, undefined, runTaskId));
|
|
1001
|
+
}, buildAgentExecutorDeps(stepDeps, deps, undefined, runTaskId, currentAgentStepId));
|
|
999
1002
|
runBudget.reconcile(
|
|
1000
1003
|
// Prefer the driver executeAgent actually resolved+ran; fall back to
|
|
1001
1004
|
// the routed value only when servedBy is absent (non-executeAgent
|
|
@@ -1076,7 +1079,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
1076
1079
|
...(input.dataPolicy !== undefined && {
|
|
1077
1080
|
boundary: { dataPolicy: input.dataPolicy },
|
|
1078
1081
|
}),
|
|
1079
|
-
}, buildAgentExecutorDeps(stepDeps, deps, undefined, runTaskId));
|
|
1082
|
+
}, buildAgentExecutorDeps(stepDeps, deps, undefined, runTaskId, currentAgentStepId));
|
|
1080
1083
|
runBudget.reconcile(agentReturn.servedBy?.driver ?? input.driver ?? "auto", agentReturn.usage, agentReturn.servedBy?.model, {
|
|
1081
1084
|
inputChars: input.prompt.length,
|
|
1082
1085
|
outputChars: agentReturn.text.length,
|
|
@@ -1317,6 +1320,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
1317
1320
|
// cap is set; never disturbs injected (unit-test) price tables.
|
|
1318
1321
|
runBudget.refreshPrices();
|
|
1319
1322
|
const stepIdForEmit = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
|
|
1323
|
+
currentAgentStepId = stepIdForEmit;
|
|
1320
1324
|
const stepTs = Date.now();
|
|
1321
1325
|
stepStartTs.set(stepIdForEmit, stepTs);
|
|
1322
1326
|
emit("recipe_step_start", {
|
|
@@ -1714,6 +1718,12 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
1714
1718
|
// capture no `output`, so under replay there is never a mock for
|
|
1715
1719
|
// one — refuse here rather than let the catch below be the first
|
|
1716
1720
|
// to hear about it from the seam.
|
|
1721
|
+
// A recorded agent failure replays as that failure (thrown so the
|
|
1722
|
+
// catch below records it exactly as the original halt did).
|
|
1723
|
+
const replayedAgentFailure = deps.mockedFailures?.get(stepId);
|
|
1724
|
+
if (replayedAgentFailure !== undefined) {
|
|
1725
|
+
throw new Error(replayedAgentFailure.error);
|
|
1726
|
+
}
|
|
1717
1727
|
if (deps.replayOnly === true) {
|
|
1718
1728
|
throw new ReplayIncompleteError([stepId], "agent step");
|
|
1719
1729
|
}
|
|
@@ -1764,7 +1774,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
1764
1774
|
...(agentCfg.data_policy !== undefined && {
|
|
1765
1775
|
boundary: { dataPolicy: agentCfg.data_policy },
|
|
1766
1776
|
}),
|
|
1767
|
-
}, buildAgentExecutorDeps(stepDeps, deps, undefined, runTaskId));
|
|
1777
|
+
}, buildAgentExecutorDeps(stepDeps, deps, undefined, runTaskId, stepId));
|
|
1768
1778
|
agentResult = agentReturn.text;
|
|
1769
1779
|
runBudget.reconcile(
|
|
1770
1780
|
// Prefer the driver executeAgent actually resolved+ran; the routed
|
|
@@ -2007,7 +2017,16 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
2007
2017
|
// below (driven by `result`), so a replay shows how the recipe's
|
|
2008
2018
|
// wiring behaves against captured evidence — only the tool call
|
|
2009
2019
|
// itself is skipped. See RunnerDeps.mockedOutputs's doc comment.
|
|
2010
|
-
|
|
2020
|
+
const replayedFailure = deps.mockedFailures?.get(stepId);
|
|
2021
|
+
if (replayedFailure !== undefined) {
|
|
2022
|
+
// The original run recorded this step as FAILED with no output. That
|
|
2023
|
+
// record is the evidence: fail the same way, dispatch nothing, and do
|
|
2024
|
+
// not retry — there is nothing to retry against.
|
|
2025
|
+
thrownError = replayedFailure.error;
|
|
2026
|
+
thrownErrorCode = replayedFailure.errorCode;
|
|
2027
|
+
result = null;
|
|
2028
|
+
}
|
|
2029
|
+
else if (deps.mockedOutputs?.has(stepId)) {
|
|
2011
2030
|
result = deps.mockedOutputs.get(stepId) ?? null;
|
|
2012
2031
|
// A replayed http.post response is interpreted exactly as a live one,
|
|
2013
2032
|
// including a response captured before httpOutcome existed: it reads
|
|
@@ -2535,6 +2554,17 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
2535
2554
|
})(),
|
|
2536
2555
|
};
|
|
2537
2556
|
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Tools `recipe test` may run without a fixture: reads whose only effect goes
|
|
2559
|
+
* through a dep `runTest` stubs (`readFile`, `gitLogSince`,
|
|
2560
|
+
* `gitStaleBranches`, `getDiagnostics`). Everything else needs a mock.
|
|
2561
|
+
*/
|
|
2562
|
+
const TEST_STUBBED_TOOLS = new Set([
|
|
2563
|
+
"file.read",
|
|
2564
|
+
"git.log_since",
|
|
2565
|
+
"git.stale_branches",
|
|
2566
|
+
"diagnostics.get",
|
|
2567
|
+
]);
|
|
2538
2568
|
export async function executeStep(step, ctx, deps, approvalEvidence, approvalDispatch,
|
|
2539
2569
|
/** Rollbackability the authority decision was made on (INV-1). */
|
|
2540
2570
|
rollbackAtDecision) {
|
|
@@ -2551,6 +2581,14 @@ rollbackAtDecision) {
|
|
|
2551
2581
|
if (deps.replayOnly === true) {
|
|
2552
2582
|
throw new ReplayIncompleteError([step.into ?? toolId], `tool ${toolId}`);
|
|
2553
2583
|
}
|
|
2584
|
+
// `recipe test` boundary: only a mocked tool, or a read tool served wholly
|
|
2585
|
+
// by injected stub deps, may run. Checked before the registry lookup for the
|
|
2586
|
+
// same reason as replay: a missing fixture must never become a live call.
|
|
2587
|
+
if (deps.testOnly === true &&
|
|
2588
|
+
!deps.mockConnectors?.[toolId] &&
|
|
2589
|
+
!TEST_STUBBED_TOOLS.has(toolId)) {
|
|
2590
|
+
throw new TestModeRefusalError([step.into ?? toolId], `tool ${toolId}`);
|
|
2591
|
+
}
|
|
2554
2592
|
// Check if tool is registered in the new registry
|
|
2555
2593
|
if (hasTool(toolId)) {
|
|
2556
2594
|
const tool = getTool(toolId);
|
|
@@ -2947,6 +2985,12 @@ function refuseLiveModel(which) {
|
|
|
2947
2985
|
"(claudeFn / localFn / claudeCodeFn) in the runner deps. " +
|
|
2948
2986
|
"If the live call is the point, set PATCHWORK_TEST_ALLOW_LIVE=1 for that test.");
|
|
2949
2987
|
}
|
|
2988
|
+
/** An agent driver fn that refuses: `recipe test` never reaches a live model. */
|
|
2989
|
+
function refuseInTest(which) {
|
|
2990
|
+
return async () => {
|
|
2991
|
+
throw new TestModeRefusalError(["agent"], `agent driver ${which}`);
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2950
2994
|
function resolveStepDeps(deps, scope) {
|
|
2951
2995
|
const workdir = deps.workdir ?? process.cwd();
|
|
2952
2996
|
// Defense-in-depth: even if a file.* tool somehow forgets to call
|
|
@@ -2988,18 +3032,27 @@ function resolveStepDeps(deps, scope) {
|
|
|
2988
3032
|
})),
|
|
2989
3033
|
fetchFn: deps.fetchFn ?? globalThis.fetch,
|
|
2990
3034
|
claudeFn: deps.claudeFn ??
|
|
2991
|
-
(
|
|
2992
|
-
? (
|
|
2993
|
-
:
|
|
3035
|
+
(deps.testOnly === true
|
|
3036
|
+
? refuseInTest("claude (Anthropic API)")
|
|
3037
|
+
: liveModelGuardActive() && process.env.ANTHROPIC_API_KEY
|
|
3038
|
+
? () => refuseLiveModel("defaultClaudeFn (Anthropic API)")
|
|
3039
|
+
: defaultClaudeFn),
|
|
2994
3040
|
claudeCodeFn: deps.claudeCodeFn ??
|
|
2995
|
-
(
|
|
2996
|
-
? (
|
|
2997
|
-
:
|
|
3041
|
+
(deps.testOnly === true
|
|
3042
|
+
? refuseInTest("claude-code (CLI subprocess)")
|
|
3043
|
+
: liveModelGuardActive()
|
|
3044
|
+
? () => refuseLiveModel("defaultClaudeCodeFn (claude CLI subprocess)")
|
|
3045
|
+
: defaultClaudeCodeFn),
|
|
2998
3046
|
localFn: deps.localFn ??
|
|
2999
|
-
(
|
|
3000
|
-
? (
|
|
3001
|
-
:
|
|
3002
|
-
|
|
3047
|
+
(deps.testOnly === true
|
|
3048
|
+
? refuseInTest("local (LOCAL_ENDPOINT)")
|
|
3049
|
+
: liveModelGuardActive()
|
|
3050
|
+
? () => refuseLiveModel("defaultLocalFn (LOCAL_ENDPOINT)")
|
|
3051
|
+
: defaultLocalFn),
|
|
3052
|
+
providerDriverFn: deps.providerDriverFn ??
|
|
3053
|
+
(deps.testOnly === true
|
|
3054
|
+
? refuseInTest("provider driver")
|
|
3055
|
+
: makeProviderDriverFn()),
|
|
3003
3056
|
mockConnectors: deps.mockConnectors ?? {},
|
|
3004
3057
|
recordFixturesDir: deps.recordFixturesDir,
|
|
3005
3058
|
allowWrites: deps.allowWrites ?? [],
|
|
@@ -3035,6 +3088,7 @@ function resolveStepDeps(deps, scope) {
|
|
|
3035
3088
|
// (`executeStep`, `buildAgentExecutorDeps`) can refuse without the run
|
|
3036
3089
|
// loop's help. Only ever set by the replay entrypoints.
|
|
3037
3090
|
...(deps.replayOnly === true && { replayOnly: true }),
|
|
3091
|
+
...(deps.testOnly === true && { testOnly: true }),
|
|
3038
3092
|
// Ephemeral rollback — same disk-availability gating as writeEffectLedger
|
|
3039
3093
|
// above (deliberately: both share the operator's --ledger-dir/--attempt
|
|
3040
3094
|
// inputs). No in-memory fallback: rollback only makes sense as a
|
|
@@ -3121,12 +3175,17 @@ function buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride,
|
|
|
3121
3175
|
* the `runTaskIdRef` cell on its own deps at dispatch time, by which point
|
|
3122
3176
|
* the run has started; the flat caller passes its local const directly.
|
|
3123
3177
|
*/
|
|
3124
|
-
runTaskId
|
|
3178
|
+
runTaskId,
|
|
3179
|
+
/**
|
|
3180
|
+
* The step this dispatch belongs to, so a replay refusal names it. Falls
|
|
3181
|
+
* back to "agent" only when a caller genuinely does not know.
|
|
3182
|
+
*/
|
|
3183
|
+
stepId) {
|
|
3125
3184
|
// Replay boundary (replayBoundary.ts, layer 3) — the single place agent
|
|
3126
3185
|
// executor deps are built for every dispatch site in this runner, so a
|
|
3127
3186
|
// replay's StepDeps can never hand a driver to an agent step.
|
|
3128
3187
|
if (stepDeps.replayOnly === true) {
|
|
3129
|
-
throw new ReplayIncompleteError(["agent"], "agent step");
|
|
3188
|
+
throw new ReplayIncompleteError([stepId ?? "agent"], "agent step");
|
|
3130
3189
|
}
|
|
3131
3190
|
const claudeCliFn = claudeCodeFnOverride ?? stepDeps.claudeCodeFn;
|
|
3132
3191
|
return {
|
|
@@ -3820,7 +3879,7 @@ recipeName) {
|
|
|
3820
3879
|
}, buildAgentExecutorDeps(stepDeps, runnerDeps, claudeCodeFnOverride,
|
|
3821
3880
|
// Read at DISPATCH time, not build time — by now `runChainedRecipe` has
|
|
3822
3881
|
// computed and published the run's `taskId`.
|
|
3823
|
-
runTaskIdRef.current));
|
|
3882
|
+
runTaskIdRef.current, opts?.stepId));
|
|
3824
3883
|
};
|
|
3825
3884
|
// ---------------------------------------------------------------------
|
|
3826
3885
|
// BEGIN A-PR2 EDIT BLOCK — `loadNestedRecipe` jail (dogfood F-04).
|
|
@@ -4004,6 +4063,7 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
|
|
|
4004
4063
|
runLog: deps.chainedOptions?.runLog,
|
|
4005
4064
|
activityLog: deps.chainedOptions?.activityLog,
|
|
4006
4065
|
mockedOutputs: deps.chainedOptions?.mockedOutputs,
|
|
4066
|
+
mockedFailures: deps.chainedOptions?.mockedFailures,
|
|
4007
4067
|
...((deps.replayOnly === true ||
|
|
4008
4068
|
deps.chainedOptions?.replayOnly === true) && { replayOnly: true }),
|
|
4009
4069
|
taskIdPrefix: deps.chainedOptions?.taskIdPrefix,
|