@llblab/pi-actors 0.37.1 → 0.38.1

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.
Files changed (75) hide show
  1. package/BACKLOG.md +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +6 -5
  4. package/dist/index.js +23 -3
  5. package/dist/lib/async-runs.d.ts +5 -0
  6. package/dist/lib/async-runs.js +87 -8
  7. package/dist/lib/command-templates.d.ts +2 -0
  8. package/dist/lib/execution.d.ts +4 -0
  9. package/dist/lib/execution.js +154 -13
  10. package/dist/lib/model-context.d.ts +56 -0
  11. package/dist/lib/model-context.js +220 -0
  12. package/dist/lib/observability.d.ts +2 -0
  13. package/dist/lib/observability.js +32 -6
  14. package/dist/lib/preflight-diagnostics.d.ts +27 -0
  15. package/dist/lib/preflight-diagnostics.js +86 -0
  16. package/dist/lib/prompts.d.ts +1 -1
  17. package/dist/lib/prompts.js +2 -2
  18. package/dist/lib/recipes-context.d.ts +7 -0
  19. package/dist/lib/recipes-context.js +20 -2
  20. package/dist/lib/recipes-discovery.d.ts +1 -0
  21. package/dist/lib/recipes-discovery.js +63 -7
  22. package/dist/lib/recipes-references.d.ts +2 -0
  23. package/dist/lib/recipes-references.js +10 -0
  24. package/dist/lib/tools-local.d.ts +2 -1
  25. package/dist/lib/tools-local.js +6 -4
  26. package/dist/lib/tools-response.js +22 -1
  27. package/dist/lib/tools-spawn.d.ts +2 -1
  28. package/dist/lib/tools-spawn.js +2 -1
  29. package/dist/recipes/lens-swarm.json +15 -2
  30. package/dist/recipes/pipeline-release-readiness.json +22 -3
  31. package/dist/recipes/pipeline-review-readiness.json +22 -3
  32. package/dist/recipes/subagent-judge.json +2 -1
  33. package/dist/recipes/subagent-merge.json +5 -2
  34. package/dist/recipes/subagent-normalize.json +2 -1
  35. package/dist/recipes/subagent-preflight.json +29 -0
  36. package/dist/recipes/subagent-review-coordinator.json +79 -7
  37. package/dist/recipes/subagent-review.json +2 -1
  38. package/dist/recipes/subagent-verify.json +2 -1
  39. package/dist/scripts/async-runner.mjs +84 -10
  40. package/dist/scripts/conformance.mjs +1 -0
  41. package/dist/skills/actors/SKILL.md +4 -4
  42. package/dist/skills/swarm/SKILL.md +2 -2
  43. package/docs/async-runs.md +3 -1
  44. package/docs/command-templates.md +4 -1
  45. package/docs/recipe-library.md +3 -2
  46. package/docs/template-recipes.md +2 -2
  47. package/index.ts +24 -3
  48. package/lib/async-runs.ts +157 -8
  49. package/lib/command-templates.ts +2 -0
  50. package/lib/execution.ts +218 -24
  51. package/lib/model-context.ts +359 -0
  52. package/lib/observability.ts +35 -6
  53. package/lib/preflight-diagnostics.ts +132 -0
  54. package/lib/prompts.ts +2 -2
  55. package/lib/recipes-context.ts +29 -2
  56. package/lib/recipes-discovery.ts +76 -11
  57. package/lib/recipes-references.ts +12 -0
  58. package/lib/tools-local.ts +22 -8
  59. package/lib/tools-response.ts +26 -1
  60. package/lib/tools-spawn.ts +6 -2
  61. package/package.json +1 -1
  62. package/recipes/lens-swarm.json +15 -2
  63. package/recipes/pipeline-release-readiness.json +22 -3
  64. package/recipes/pipeline-review-readiness.json +22 -3
  65. package/recipes/subagent-judge.json +2 -1
  66. package/recipes/subagent-merge.json +5 -2
  67. package/recipes/subagent-normalize.json +2 -1
  68. package/recipes/subagent-preflight.json +29 -0
  69. package/recipes/subagent-review-coordinator.json +79 -7
  70. package/recipes/subagent-review.json +2 -1
  71. package/recipes/subagent-verify.json +2 -1
  72. package/scripts/async-runner.mjs +84 -10
  73. package/scripts/conformance.mjs +1 -0
  74. package/skills/actors/SKILL.md +4 -4
  75. package/skills/swarm/SKILL.md +2 -2
package/BACKLOG.md CHANGED
@@ -71,4 +71,4 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
71
71
 
72
72
  ## Suggested Milestone Order
73
73
 
74
- No current milestone. Reassess runtime evidence before adding the next reliability slice.
74
+ 1. Re-curate after the next real packaged review-swarm dogfood run.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.38.1: Windows Recipe ACL Hotfix
6
+
7
+ - `[Registry]` Replaced POSIX mode-bit recipe-root writability checks on Windows with ACL-aware diagnostics, avoiding false `world-writable` and `group-writable` startup warnings from Node's NTFS mode emulation while still flagging broad Windows write grants.
8
+ - `[Tests]` Added portable coverage for broad Windows ACL parsing and skipped the POSIX chmod diagnostic regression on Windows.
9
+
10
+ ## 0.38.0: Review Swarm Pipeline Hardening
11
+
12
+ - `[Recipes]` Added current model/thinking inheritance for packaged review/lens-swarm recipes through `{current_model}` and `{current_thinking}`, so normal subagent review swarms use the selected Pi session policy by default while explicit args still override it.
13
+ - `[Runtime]` Injected the current Pi model and thinking level into spawn/runtime tool recipe values and fail fast before async fanout when a current placeholder cannot be resolved.
14
+ - `[Runtime]` Persisted `model_policy` provenance in async run status, progress, terminal results, compact status text, and terminal follow-ups so operators can distinguish inherited, explicit, mixed, and unresolved model/thinking policy.
15
+ - `[Runtime]` Materialized child `pi -p` prompts into run-local `prompts/command-NNN.md` files and invoked Pi with `@file` arguments, preserving long quoted prompts and recipe context without fragile inline argv transport.
16
+ - `[Runtime]` Expanded parallel branch diagnostics with stdout/stderr byte counts, tail previews, failure reasons, prompt-file paths, soft-quorum usability, and failed-run result/progress details; branch-failure fanouts now fail when every branch fails or emits empty output.
17
+ - `[Recipes]` Added `subagent-preflight` and wired the review coordinator to run stage model/thinking/tool smoke checks before reviewer fanout, so unavailable providers fail before expensive reviewer branches launch.
18
+ - `[Recipes]` Made review pipelines quorum-aware with `subagent_ttl_ms`, `reviewer_concurrency`, `min_successful_reviewers`, and `merge_policy` knobs; partial reviewer evidence is preserved, verifier/merger stages run only after the evidence threshold is met, and normalized reports must mark `complete`, `degraded`, or `insufficient_data`.
19
+ - `[Diagnostics]` Preflight command failures now emit `ACTOR_PREFLIGHT_FAILED` diagnostics with stage, selected model/thinking, provider error class, prompt file, and suggested override args; fake-`pi` coverage proves reviewer fanout starts only after preflight passes.
20
+ - `[Tests]` Added a deterministic packaged review-readiness dogfood fixture to `npm run conformance`, using a fake local `pi` executable to verify preflight, degraded reviewer fanout, prompt-file artifacts, branch failure capture, merge gating, and terminal status without external model APIs.
21
+ - `[Guidance]` Updated README, actor/swarm skills, recipe docs, async-run docs, and onboarding copy to steer review swarms away from `models.json` rediscovery and toward maintained recipes with current model/thinking inheritance, policy provenance, prompt-file transport, preflight, quorum controls, and inspectable branch diagnostics.
22
+ - `[Backlog]` Captured the remaining `pi-telegram` review-swarm dogfood lessons as concrete `pi-actors` hardening work: review-pipeline preflight/quorum resilience, model-provenance visibility, and a deterministic review-swarm fixture.
23
+
5
24
  ## 0.37.1: Subagent Recipe Prompt Injection Hotfix
6
25
 
7
26
  - `[Recipes]` Fixed actor recipe context injection for child `pi -p` launches with options after the print flag, so packaged subagent recipes such as `subagent-review` and `pipeline-review-readiness` append context to the actual prompt instead of corrupting `--model` or other option values.
package/README.md CHANGED
@@ -111,22 +111,23 @@ cat > ~/.pi/agent/recipes/docs_review.json <<'JSON'
111
111
  {
112
112
  "description": "Start an async docs review actor",
113
113
  "async": true,
114
- "args": ["scope:path", "model:string"],
114
+ "args": ["scope:path", "model:string", "thinking:string"],
115
+ "defaults": { "model": "{current_model}", "thinking": "{current_thinking}" },
115
116
  "mailbox": {
116
117
  "accepts": ["control.kill", "control.continue"],
117
118
  "emits": ["review.completed", "run.failed"]
118
119
  },
119
- "template": "pi -p --model {model} --no-tools \"Review {scope} for unclear actor-runtime onboarding. Return concise findings.\""
120
+ "template": "pi -p --model {model} --thinking {thinking} --no-tools \"Review {scope} for unclear actor-runtime onboarding. Return concise findings.\""
120
121
  }
121
122
  JSON
122
123
  ```
123
124
 
124
- Because it lives under `~/.pi/agent/recipes/`, the file becomes a persistent agent tool by location. The filename is the tool id.
125
+ Because it lives under `~/.pi/agent/recipes/`, the file becomes a persistent agent tool by location. The filename is the tool id. `{current_model}` and `{current_thinking}` inherit the selected Pi session model/thinking level; pass `model=...` or `thinking=...` only when a run should intentionally diverge. Run status/progress and terminal follow-ups include `model_policy` provenance so inherited, explicit, and unresolved policy choices stay inspectable.
125
126
 
126
127
  Start it:
127
128
 
128
129
  ```text
129
- docs_review scope="README.md" model="current-review-model" run_id=docs_review
130
+ docs_review scope="README.md" run_id=docs_review
130
131
  ```
131
132
 
132
133
  Inspect only when there is a reason:
@@ -258,7 +259,7 @@ Templates support:
258
259
  - Retries, recovery, failure policy, delays, and guarded execution;
259
260
  - Async run values such as `{run_id}`, `{state_dir}`, `{actor_address}`, `{default_room}`, and `{communication_file}`.
260
261
 
261
- The template owns execution shape. The recipe owns saved metadata, defaults, imports, mailbox, and artifacts. JSON is the canonical precise recipe format; Markdown recipes use frontmatter plus fenced `template`/`json recipe` blocks for literate authoring and compile into the same model. The run actor owns detached lifecycle, state, messages, cancellation, and inspection. File-backed async recipes also provide child `pi -p` actors with a bounded JSONL recipe context bundle by default, including raw entry/import recipe records and a `"you_are_here": true` marker for the recipe node that launched the child. Set `"actor_context": false` or `"off"` in a recipe to suppress that context for minimal prompts.
262
+ The template owns execution shape. The recipe owns saved metadata, defaults, imports, mailbox, and artifacts. JSON is the canonical precise recipe format; Markdown recipes use frontmatter plus fenced `template`/`json recipe` blocks for literate authoring and compile into the same model. The run actor owns detached lifecycle, state, messages, cancellation, and inspection. File-backed async recipes also provide child `pi -p` actors with a bounded JSONL recipe context bundle by default, including raw entry/import recipe records and a `"you_are_here": true` marker for the recipe node that launched the child; the runner materializes child prompts under `prompts/` and invokes Pi with `@file` arguments. Set `"actor_context": false` or `"off"` in a recipe to suppress that context for minimal prompts.
262
263
 
263
264
  ## Recipe Library
264
265
 
package/dist/index.js CHANGED
@@ -84,13 +84,33 @@ export default function toolRegistryExtension(pi) {
84
84
  onChange: () => activeRunContext && scheduleRunEventUpdate(activeRunContext),
85
85
  });
86
86
  const actorToolDefinitions = new Map();
87
+ const withCurrentThinkingContext = (definition) => {
88
+ if (typeof definition.execute !== "function")
89
+ return definition;
90
+ const execute = definition.execute;
91
+ return {
92
+ ...definition,
93
+ execute: (...args) => {
94
+ const nextArgs = [...args];
95
+ const ctx = nextArgs[4];
96
+ if (ctx && typeof ctx === "object") {
97
+ nextArgs[4] = {
98
+ ...ctx,
99
+ getThinkingLevel: () => pi.getThinkingLevel(),
100
+ };
101
+ }
102
+ return execute(...nextArgs);
103
+ },
104
+ };
105
+ };
87
106
  const runtime = Runtime.createAutoToolsRuntime({
88
107
  configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
89
108
  exec: CommandTemplates.execCommandTemplate,
90
109
  getActiveTools: () => pi.getActiveTools(),
91
110
  registerTool: (definition) => {
92
- actorToolDefinitions.set(definition.name, definition);
93
- pi.registerTool(definition);
111
+ const wrapped = withCurrentThinkingContext(definition);
112
+ actorToolDefinitions.set(wrapped.name, wrapped);
113
+ pi.registerTool(wrapped);
94
114
  },
95
115
  reservedToolNames: Tools.RESERVED_TOOL_NAMES,
96
116
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
@@ -161,5 +181,5 @@ export default function toolRegistryExtension(pi) {
161
181
  getRuntimeTool: (name) => actorToolDefinitions.get(name),
162
182
  registryRuntime: runtime,
163
183
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
164
- }));
184
+ }).map(withCurrentThinkingContext));
165
185
  }
@@ -3,6 +3,7 @@
3
3
  * Owns launch, state observation, listing, message/control facade methods, and retention while runs-* subdomains own narrower run internals.
4
4
  */
5
5
  import type { CommandTemplateFailureScope, CommandTemplateValue } from "./command-templates.ts";
6
+ import { type CurrentPolicyProvenance } from "./model-context.ts";
6
7
  import * as RecipesReferences from "./recipes-references.ts";
7
8
  import { type RunArtifactDeclaration } from "./runs-artifacts.ts";
8
9
  import { type RunOutboxEvent } from "./runs-outbox.ts";
@@ -28,6 +29,8 @@ export interface AsyncRunStartParams {
28
29
  args?: string[];
29
30
  defaults?: Record<string, unknown>;
30
31
  parallel?: boolean;
32
+ concurrency?: number | string;
33
+ min_successful?: number | string;
31
34
  label?: string;
32
35
  when?: boolean | string;
33
36
  timeout?: number | string;
@@ -41,6 +44,7 @@ export interface AsyncRunStartParams {
41
44
  recover?: CommandTemplateValue;
42
45
  repeat?: number;
43
46
  values?: Record<string, unknown>;
47
+ policy_values?: Record<string, unknown>;
44
48
  actor_context?: boolean | string;
45
49
  cwd?: string;
46
50
  }
@@ -63,6 +67,7 @@ export interface AsyncRunMeta {
63
67
  artifacts?: Record<string, RunArtifactDeclaration>;
64
68
  control?: AsyncRunControlEndpoint;
65
69
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
70
+ model_policy?: CurrentPolicyProvenance;
66
71
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
67
72
  retire_when?: "children_terminal";
68
73
  }
@@ -7,6 +7,7 @@ import { closeSync, existsSync, mkdirSync, openSync, writeFileSync, } from "node
7
7
  import { basename, dirname, extname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { writeJsonAtomic } from "./file-state.js";
10
+ import { CURRENT_MODEL_VALUE_KEY, CURRENT_THINKING_VALUE_KEY, describeCurrentPolicyProvenance, } from "./model-context.js";
10
11
  import * as Paths from "./paths.js";
11
12
  import * as RecipesReferences from "./recipes-references.js";
12
13
  import * as RecipesUsage from "./recipes-usage.js";
@@ -50,6 +51,8 @@ function resolveRunTemplate(params) {
50
51
  "args",
51
52
  "defaults",
52
53
  "parallel",
54
+ "concurrency",
55
+ "min_successful",
53
56
  "label",
54
57
  "when",
55
58
  "timeout",
@@ -128,6 +131,74 @@ function resolveStartParams(params) {
128
131
  function readJson(path) {
129
132
  return readJsonFileResilient(path, undefined).value;
130
133
  }
134
+ function isRecord(value) {
135
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
136
+ }
137
+ function isFalsyValue(value) {
138
+ if (value === undefined || value === null || value === false)
139
+ return true;
140
+ const normalized = String(value).trim().toLowerCase();
141
+ return (normalized === "" ||
142
+ normalized === "0" ||
143
+ normalized === "false" ||
144
+ normalized === "no");
145
+ }
146
+ function stringReferencesPlaceholder(value, placeholder) {
147
+ return new RegExp(`\\{\\s*${placeholder}\\s*\\}`).test(value);
148
+ }
149
+ function collectUnresolvedCurrentPlaceholderReferences(value, values, placeholder, valueKey, path = "template", refs = []) {
150
+ if (typeof value === "string") {
151
+ if (stringReferencesPlaceholder(value, placeholder) &&
152
+ isFalsyValue(values[valueKey])) {
153
+ refs.push(path);
154
+ }
155
+ return refs;
156
+ }
157
+ if (Array.isArray(value)) {
158
+ value.forEach((item, index) => collectUnresolvedCurrentPlaceholderReferences(item, values, placeholder, valueKey, `${path}[${index}]`, refs));
159
+ return refs;
160
+ }
161
+ if (!isRecord(value))
162
+ return refs;
163
+ for (const [key, child] of Object.entries(value)) {
164
+ if (key === "defaults" && isRecord(child)) {
165
+ for (const [defaultKey, defaultValue] of Object.entries(child)) {
166
+ if (typeof defaultValue === "string" &&
167
+ stringReferencesPlaceholder(defaultValue, placeholder) &&
168
+ isFalsyValue(values[defaultKey]) &&
169
+ isFalsyValue(values[valueKey])) {
170
+ refs.push(`${path}.defaults.${defaultKey}`);
171
+ }
172
+ }
173
+ continue;
174
+ }
175
+ collectUnresolvedCurrentPlaceholderReferences(child, values, placeholder, valueKey, `${path}.${key}`, refs);
176
+ }
177
+ return refs;
178
+ }
179
+ function assertCurrentPlaceholderReferencesResolved(template, defaults, values, modelPolicy) {
180
+ const checks = [
181
+ {
182
+ label: "model",
183
+ placeholder: "current_model",
184
+ valueKey: CURRENT_MODEL_VALUE_KEY,
185
+ },
186
+ {
187
+ label: "thinking level",
188
+ placeholder: "current_thinking",
189
+ valueKey: CURRENT_THINKING_VALUE_KEY,
190
+ },
191
+ ];
192
+ for (const check of checks) {
193
+ const refs = collectUnresolvedCurrentPlaceholderReferences(template, values, check.placeholder, check.valueKey);
194
+ if (defaults) {
195
+ collectUnresolvedCurrentPlaceholderReferences({ defaults }, values, check.placeholder, check.valueKey, "recipe", refs);
196
+ }
197
+ if (refs.length === 0)
198
+ continue;
199
+ throw Object.assign(new Error(`Template recipe requires the current Pi ${check.label} for inheritance, but no current ${check.label} was available. Pass explicit values or launch from a Pi session with a selected ${check.label}. unresolved=${refs.slice(0, 4).join(",")}`), { model_policy: modelPolicy });
200
+ }
201
+ }
131
202
  function acquireStateStartLock(stateDir) {
132
203
  return RunsStart.acquireStateStartLock(stateDir);
133
204
  }
@@ -139,6 +210,20 @@ export function startRun(params, cwd) {
139
210
  const resolved = resolveRunTemplate(startParams);
140
211
  const run = safeRunId(startParams.run_id);
141
212
  const stateDir = resolveStateDir(startParams, run);
213
+ const values = {
214
+ ...(startParams.values || {}),
215
+ actor_address: `run:${run}`,
216
+ communication_file: join(stateDir, "communication.json"),
217
+ default_room: `room:${run}`,
218
+ run_id: run,
219
+ state_dir: stateDir,
220
+ };
221
+ const modelPolicy = describeCurrentPolicyProvenance({
222
+ defaults: startParams.defaults,
223
+ template: resolved.template,
224
+ values: startParams.policy_values ?? startParams.values ?? {},
225
+ });
226
+ assertCurrentPlaceholderReferencesResolved(resolved.template, startParams.defaults, values, modelPolicy);
142
227
  assertNoActiveRunState(stateDir);
143
228
  mkdirSync(stateDir, { recursive: true });
144
229
  const releaseStartLock = acquireStateStartLock(stateDir);
@@ -162,14 +247,6 @@ export function startRun(params, cwd) {
162
247
  const outFd = openSync(stdout, "a");
163
248
  const errFd = openSync(stderr, "a");
164
249
  const argv = asyncRunnerArgv(stateDir);
165
- const values = {
166
- ...(startParams.values || {}),
167
- actor_address: `run:${run}`,
168
- communication_file: join(stateDir, "communication.json"),
169
- default_room: `room:${run}`,
170
- run_id: run,
171
- state_dir: stateDir,
172
- };
173
250
  const outputValues = {
174
251
  ...(startParams.defaults || {}),
175
252
  ...values,
@@ -192,6 +269,7 @@ export function startRun(params, cwd) {
192
269
  ...(startParams.tool ? { tool: startParams.tool } : {}),
193
270
  template: resolved.template,
194
271
  values,
272
+ model_policy: modelPolicy,
195
273
  ...(artifacts ? { artifacts } : {}),
196
274
  ...(startParams.control ? { control: startParams.control } : {}),
197
275
  ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
@@ -215,6 +293,7 @@ export function startRun(params, cwd) {
215
293
  writeJsonAtomic(join(stateDir, "progress.json"), {
216
294
  completed: 0,
217
295
  failures: [],
296
+ model_policy: modelPolicy,
218
297
  phase: "starting",
219
298
  updatedAt: new Date().toISOString(),
220
299
  });
@@ -15,6 +15,8 @@ export interface CommandTemplateObjectConfig {
15
15
  actorRecipeContext?: CommandTemplateActorRecipeContext;
16
16
  label?: string;
17
17
  parallel?: boolean;
18
+ concurrency?: number | string;
19
+ min_successful?: number | string;
18
20
  when?: boolean | string;
19
21
  template?: CommandTemplateValue;
20
22
  args?: string[];
@@ -22,10 +22,13 @@ export interface ToolExecResult {
22
22
  export interface BranchReport {
23
23
  code: number;
24
24
  command: string;
25
+ failureReason?: string;
25
26
  killed: boolean;
26
27
  label: string;
27
28
  status: "done" | "failed" | "timeout";
28
29
  stderr?: string;
30
+ stderrBytes: number;
31
+ stdout?: string;
29
32
  stdoutBytes: number;
30
33
  }
31
34
  export interface SoftQuorumReport {
@@ -53,6 +56,7 @@ export interface RegisteredToolExecutionResult {
53
56
  killed: boolean;
54
57
  }>;
55
58
  softQuorum?: SoftQuorumReport;
59
+ failureReason?: string;
56
60
  template: CommandTemplates.CommandTemplateValue;
57
61
  templateWarnings?: string[];
58
62
  tool: string;
@@ -37,6 +37,15 @@ function formatInvocationDetail(invocation) {
37
37
  function formatCommandDetail(commands) {
38
38
  return commands.length === 1 ? commands[0] : commands.join(" && ");
39
39
  }
40
+ function getExecutionFailureReason(branches, softQuorum, stderr) {
41
+ if (/parallel branch quorum not met/.test(stderr)) {
42
+ return "parallel_quorum_not_met";
43
+ }
44
+ if (branches.length > 0 && softQuorum?.usable === false) {
45
+ return "all_branches_unusable";
46
+ }
47
+ return "command_failed";
48
+ }
40
49
  function mergeDefaults(inherited, own) {
41
50
  if (!inherited && !own)
42
51
  return undefined;
@@ -53,17 +62,32 @@ function getBranchStatus(result) {
53
62
  return "done";
54
63
  return result.killed ? "timeout" : "failed";
55
64
  }
65
+ function getBranchFailureReason(result) {
66
+ if (result.code === 0) {
67
+ return result.stdout.trim() ? undefined : "empty_output";
68
+ }
69
+ if (result.killed)
70
+ return "timeout_or_killed";
71
+ return result.stderr.trim() ? "nonzero_exit" : "nonzero_exit_empty_stderr";
72
+ }
56
73
  function createBranchReport(label, command, result) {
74
+ const failureReason = getBranchFailureReason(result);
57
75
  return {
58
76
  code: result.code,
59
77
  command,
78
+ ...(failureReason ? { failureReason } : {}),
60
79
  killed: result.killed,
61
80
  label,
62
81
  status: getBranchStatus(result),
63
- ...(result.stderr ? { stderr: result.stderr.slice(0, 1000) } : {}),
82
+ ...(result.stderr ? { stderr: result.stderr.slice(-1000) } : {}),
83
+ stderrBytes: Buffer.byteLength(result.stderr),
84
+ ...(result.stdout ? { stdout: result.stdout.slice(-1000) } : {}),
64
85
  stdoutBytes: Buffer.byteLength(result.stdout),
65
86
  };
66
87
  }
88
+ function isUsableBranch(branch) {
89
+ return branch.status === "done" && branch.stdoutBytes > 0;
90
+ }
67
91
  function createSoftQuorum(branches) {
68
92
  if (branches.length === 0)
69
93
  return undefined;
@@ -75,9 +99,12 @@ function createSoftQuorum(branches) {
75
99
  done,
76
100
  expected: branches.length,
77
101
  failed,
78
- usable: done > 0,
102
+ usable: branches.some(isUsableBranch),
79
103
  };
80
104
  }
105
+ function countUsableBranches(branches) {
106
+ return branches.filter(isUsableBranch).length;
107
+ }
81
108
  function getMaxParallelBranches() {
82
109
  const raw = Number(process.env.PI_ACTORS_MAX_PARALLEL_BRANCHES ?? "");
83
110
  return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_MAX_PARALLEL_BRANCHES;
@@ -111,6 +138,24 @@ function normalizeRetry(value, values) {
111
138
  throw new Error("Command template retry must be a positive integer.");
112
139
  return resolved;
113
140
  }
141
+ function normalizeConcurrency(value, values, branchCount) {
142
+ const resolved = resolveNumericControlField(value, values, "concurrency");
143
+ if (resolved === undefined)
144
+ return branchCount;
145
+ if (!Number.isInteger(resolved) || resolved < 1)
146
+ throw new Error("Command template concurrency must be a positive integer.");
147
+ return Math.min(resolved, branchCount);
148
+ }
149
+ function normalizeMinSuccessful(value, values, branchCount) {
150
+ const resolved = resolveNumericControlField(value, values, "min_successful");
151
+ if (resolved === undefined)
152
+ return undefined;
153
+ if (!Number.isInteger(resolved) || resolved < 0)
154
+ throw new Error("Command template min_successful must be a non-negative integer.");
155
+ if (resolved > branchCount)
156
+ throw new Error(`Command template min_successful ${resolved} exceeds branch count ${branchCount}.`);
157
+ return resolved;
158
+ }
114
159
  function getRecoverConfig(config) {
115
160
  const recovered = Array.isArray(config) ? { template: config } : config;
116
161
  const normalized = CommandTemplates.normalizeCommandTemplateConfig(recovered);
@@ -174,8 +219,20 @@ async function applyDelay(delay, values, signal) {
174
219
  return;
175
220
  await sleep(resolved, signal);
176
221
  }
177
- function joinParallelStdout(branches, results) {
178
- return results
222
+ function getParallelStatus(branches, minSuccessful) {
223
+ const usable = countUsableBranches(branches);
224
+ if (minSuccessful !== undefined && usable < minSuccessful) {
225
+ return "insufficient_data";
226
+ }
227
+ return branches.every(isUsableBranch) ? "complete" : "degraded";
228
+ }
229
+ function formatParallelStatusHeader(branches, minSuccessful) {
230
+ if (minSuccessful === undefined)
231
+ return undefined;
232
+ return `--- parallel_status: ${getParallelStatus(branches, minSuccessful)} usable: ${countUsableBranches(branches)} expected: ${branches.length} minimum: ${minSuccessful} ---`;
233
+ }
234
+ function joinParallelStdout(branches, results, minSuccessful) {
235
+ const body = results
179
236
  .map((result, index) => {
180
237
  const branch = branches[index];
181
238
  const header = `--- branch: ${branch.label} status: ${branch.status} ---`;
@@ -185,6 +242,24 @@ function joinParallelStdout(branches, results) {
185
242
  return `${header}\nexit: ${branch.code}${stderr}`;
186
243
  })
187
244
  .join("\n");
245
+ return [formatParallelStatusHeader(branches, minSuccessful), body]
246
+ .filter(Boolean)
247
+ .join("\n");
248
+ }
249
+ async function mapConcurrent(items, concurrency, fn) {
250
+ const results = new Array(items.length);
251
+ let nextIndex = 0;
252
+ async function worker() {
253
+ for (;;) {
254
+ const index = nextIndex;
255
+ nextIndex += 1;
256
+ if (index >= items.length)
257
+ return;
258
+ results[index] = await fn(items[index], index);
259
+ }
260
+ }
261
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
262
+ return results;
188
263
  }
189
264
  async function executeRetriableTemplateConfig(normalized, inherited, params, exec, cwd, signal, stdin, isRoot, actorRecipeContext) {
190
265
  const maxAttempts = normalizeRetry(normalized.retry, {
@@ -270,7 +345,17 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
270
345
  },
271
346
  };
272
347
  });
273
- return executeTemplateConfig({ parallel: normalized.parallel === true, template: repeatedSteps }, context, params, exec, cwd, signal, stdin, isRoot, actorRecipeContext);
348
+ return executeTemplateConfig({
349
+ parallel: normalized.parallel === true,
350
+ ...(normalized.concurrency !== undefined
351
+ ? { concurrency: normalized.concurrency }
352
+ : {}),
353
+ ...(normalized.min_successful !== undefined
354
+ ? { min_successful: normalized.min_successful }
355
+ : {}),
356
+ ...(normalized.failure !== undefined ? { failure: normalized.failure } : {}),
357
+ template: repeatedSteps,
358
+ }, context, params, exec, cwd, signal, stdin, isRoot, actorRecipeContext);
274
359
  }
275
360
  if (normalized.retry !== undefined &&
276
361
  (Array.isArray(normalized.template) || normalized.recover !== undefined)) {
@@ -310,10 +395,17 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
310
395
  throw new Error(formatToolText("Tool template produced no command steps."));
311
396
  if (normalized.parallel === true) {
312
397
  assertParallelBranchLimit(steps.length);
313
- const branchResults = await Promise.all(steps.map((step) => executeTemplateConfig(step, context, params, exec, cwd, signal, stdin, false, actorRecipeContext)));
398
+ const concurrency = normalizeConcurrency(normalized.concurrency, controlValues, steps.length);
399
+ const minSuccessful = normalizeMinSuccessful(normalized.min_successful, controlValues, steps.length);
400
+ const branchResults = await mapConcurrent(steps, concurrency, (step) => executeTemplateConfig(step, context, params, exec, cwd, signal, stdin, false, actorRecipeContext));
314
401
  const commands = branchResults.flatMap((item) => item.commands);
315
402
  const failures = branchResults.flatMap((item) => item.failures);
316
403
  const branches = branchResults.map((item, index) => createBranchReport(getNodeLabel(steps[index], index), item.commands.at(-1) ?? "<template>", item.result));
404
+ const usableBranches = countUsableBranches(branches);
405
+ const quorumUnmet = minSuccessful !== undefined && usableBranches < minSuccessful;
406
+ const quorumFailureMessage = quorumUnmet
407
+ ? `parallel branch quorum not met: usable ${usableBranches}/${branches.length}, minimum ${minSuccessful}`
408
+ : "";
317
409
  const nodeFailure = getFailureScope(normalized);
318
410
  const rootFailure = branchResults.find((item, index) => {
319
411
  if (item.result.code === 0)
@@ -335,6 +427,7 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
335
427
  };
336
428
  }
337
429
  const firstFailedBranch = branchResults.find((item) => item.result.code !== 0);
430
+ const allBranchesUnusable = branches.every((branch) => !isUsableBranch(branch));
338
431
  const successful = branchResults.map((item) => {
339
432
  if (item.result.code === 0)
340
433
  return item.result;
@@ -348,9 +441,31 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
348
441
  .map((item) => item.stderr)
349
442
  .filter(Boolean)
350
443
  .join("\n"),
351
- stdout: joinParallelStdout(branches, successful),
444
+ stdout: joinParallelStdout(branches, successful, minSuccessful),
352
445
  };
353
- if (firstFailedBranch && nodeFailure === "branch") {
446
+ if (quorumUnmet && nodeFailure === "root") {
447
+ return {
448
+ commands,
449
+ branches: [
450
+ ...branchResults.flatMap((item) => item.branches),
451
+ ...branches,
452
+ ],
453
+ criticalFailure: true,
454
+ failureScope: "root",
455
+ failures,
456
+ result: {
457
+ ...result,
458
+ code: firstFailedBranch?.result.code || 1,
459
+ stderr: [result.stderr, quorumFailureMessage]
460
+ .filter(Boolean)
461
+ .join("\n"),
462
+ },
463
+ };
464
+ }
465
+ const branchFailureTriggered = minSuccessful === undefined
466
+ ? firstFailedBranch || allBranchesUnusable
467
+ : quorumUnmet;
468
+ if (branchFailureTriggered && nodeFailure === "branch") {
354
469
  return {
355
470
  commands,
356
471
  branches: [
@@ -359,7 +474,19 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
359
474
  ],
360
475
  failureScope: "branch",
361
476
  failures,
362
- result: { ...result, code: firstFailedBranch.result.code || 1 },
477
+ result: {
478
+ ...result,
479
+ code: firstFailedBranch?.result.code || 1,
480
+ stderr: [
481
+ result.stderr,
482
+ allBranchesUnusable
483
+ ? "all parallel branches failed or produced empty output"
484
+ : "",
485
+ quorumFailureMessage,
486
+ ]
487
+ .filter(Boolean)
488
+ .join("\n"),
489
+ },
363
490
  };
364
491
  }
365
492
  return {
@@ -421,9 +548,25 @@ export async function executeRegisteredTool(cfg, params, exec, cwd, signal) {
421
548
  const executed = await executeTemplateSteps(cfg, Schema.normalizeRuntimeValues(params, cfg.argTypes), exec, cwd, signal);
422
549
  const command = formatCommandDetail(executed.commands);
423
550
  const result = executed.result;
551
+ const softQuorum = createSoftQuorum(executed.branches);
424
552
  if (result.code !== 0) {
425
553
  const formatted = formatFailureOutput(cfg.name, result.code, result.killed, result.stdout, result.stderr);
426
- throw new Error(formatted.text);
554
+ throw Object.assign(new Error(formatted.text), {
555
+ details: {
556
+ branches: executed.branches,
557
+ code: result.code,
558
+ command,
559
+ killed: result.killed,
560
+ ...(executed.failures.length > 0
561
+ ? { nonCriticalFailures: executed.failures }
562
+ : {}),
563
+ ...(softQuorum ? { softQuorum } : {}),
564
+ failureReason: getExecutionFailureReason(executed.branches, softQuorum, result.stderr),
565
+ template: cfg.template,
566
+ tool: cfg.name,
567
+ truncated: formatted.truncated,
568
+ },
569
+ });
427
570
  }
428
571
  const formatted = formatOutput(cfg.name, "stdout", result.stdout);
429
572
  const templateWarnings = CommandTemplates.getCommandTemplateWarnings(createTemplateConfig(cfg));
@@ -438,9 +581,7 @@ export async function executeRegisteredTool(cfg, params, exec, cwd, signal) {
438
581
  ...(executed.failures.length > 0
439
582
  ? { nonCriticalFailures: executed.failures }
440
583
  : {}),
441
- ...(createSoftQuorum(executed.branches)
442
- ? { softQuorum: createSoftQuorum(executed.branches) }
443
- : {}),
584
+ ...(softQuorum ? { softQuorum } : {}),
444
585
  template: cfg.template,
445
586
  ...(templateWarnings.length > 0 ? { templateWarnings } : {}),
446
587
  tool: cfg.name,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Current model/thinking propagation helpers
3
+ * Zones: pi session context, recipe inheritance
4
+ * Owns current model/thinking extraction from Pi tool contexts and implicit command-template values.
5
+ */
6
+ export interface CurrentModelContext {
7
+ currentThinking?: unknown;
8
+ getThinkingLevel?: () => unknown;
9
+ model?: {
10
+ id?: unknown;
11
+ modelId?: unknown;
12
+ provider?: unknown;
13
+ };
14
+ sessionManager?: {
15
+ getBranch?: () => unknown[];
16
+ getSessionId?: () => string;
17
+ };
18
+ thinkingLevel?: unknown;
19
+ }
20
+ export declare const CURRENT_MODEL_VALUE_KEY = "current_model";
21
+ export declare const CURRENT_THINKING_VALUE_KEY = "current_thinking";
22
+ export type CurrentPolicySource = "explicit" | "inherited" | "mixed" | "unresolved" | "unused";
23
+ export interface CurrentPolicyAxisProvenance {
24
+ explicit_keys?: string[];
25
+ inherited_keys?: string[];
26
+ source: CurrentPolicySource;
27
+ unresolved_keys?: string[];
28
+ value?: string;
29
+ }
30
+ export interface CurrentPolicyProvenance {
31
+ model: CurrentPolicyAxisProvenance;
32
+ thinking: CurrentPolicyAxisProvenance;
33
+ }
34
+ export interface CurrentPolicyRecipeSummary {
35
+ model?: {
36
+ inherited_defaults?: string[];
37
+ public_args?: string[];
38
+ };
39
+ thinking?: {
40
+ inherited_defaults?: string[];
41
+ public_args?: string[];
42
+ };
43
+ }
44
+ export declare function getCurrentModelPattern(ctx: CurrentModelContext | undefined): string | undefined;
45
+ export declare function getCurrentThinkingLevel(ctx: CurrentModelContext | undefined): string | undefined;
46
+ export declare function withCurrentModelValues<T extends Record<string, unknown>>(values: T, ctx: CurrentModelContext | undefined): T & Record<string, unknown>;
47
+ export declare function describeCurrentPolicyProvenance(input: {
48
+ defaults?: Record<string, unknown>;
49
+ template: unknown;
50
+ values?: Record<string, unknown>;
51
+ }): CurrentPolicyProvenance;
52
+ export declare function describeRecipeCurrentPolicy(input: {
53
+ args?: unknown;
54
+ defaults?: Record<string, unknown>;
55
+ template: unknown;
56
+ }): CurrentPolicyRecipeSummary | undefined;