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.
@@ -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
- if (deps.mockedOutputs?.has(stepId)) {
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
- (liveModelGuardActive() && process.env.ANTHROPIC_API_KEY
2992
- ? () => refuseLiveModel("defaultClaudeFn (Anthropic API)")
2993
- : defaultClaudeFn),
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
- (liveModelGuardActive()
2996
- ? () => refuseLiveModel("defaultClaudeCodeFn (claude CLI subprocess)")
2997
- : defaultClaudeCodeFn),
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
- (liveModelGuardActive()
3000
- ? () => refuseLiveModel("defaultLocalFn (LOCAL_ENDPOINT)")
3001
- : defaultLocalFn),
3002
- providerDriverFn: deps.providerDriverFn ?? makeProviderDriverFn(),
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,