@llblab/pi-actors 0.39.0 → 0.40.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.
- package/AGENTS.md +10 -3
- package/BACKLOG.md +4 -11
- package/CHANGELOG.md +51 -0
- package/README.md +5 -5
- package/dist/index.js +6 -4
- package/dist/lib/async-runs.d.ts +4 -0
- package/dist/lib/async-runs.js +112 -17
- package/dist/lib/command-templates.d.ts +9 -0
- package/dist/lib/command-templates.js +92 -11
- package/dist/lib/config.js +0 -5
- package/dist/lib/execution.d.ts +31 -0
- package/dist/lib/execution.js +145 -12
- package/dist/lib/file-state.d.ts +1 -0
- package/dist/lib/file-state.js +91 -3
- package/dist/lib/observability.js +5 -2
- package/dist/lib/prompts.d.ts +1 -2
- package/dist/lib/prompts.js +3 -4
- package/dist/lib/recipes-context.js +17 -9
- package/dist/lib/recipes-discovery.js +11 -5
- package/dist/lib/recipes-references.d.ts +1 -1
- package/dist/lib/recipes-references.js +4 -5
- package/dist/lib/recipes-usage.d.ts +2 -0
- package/dist/lib/recipes-usage.js +35 -21
- package/dist/lib/registry.d.ts +0 -2
- package/dist/lib/registry.js +33 -10
- package/dist/lib/runs-ownership.d.ts +7 -0
- package/dist/lib/runs-ownership.js +82 -0
- package/dist/lib/runs-process.d.ts +17 -2
- package/dist/lib/runs-process.js +99 -11
- package/dist/lib/runs-retention.d.ts +3 -0
- package/dist/lib/runs-retention.js +18 -3
- package/dist/lib/runs-start.d.ts +2 -2
- package/dist/lib/runs-start.js +51 -17
- package/dist/lib/runs-status.d.ts +1 -1
- package/dist/lib/runs-status.js +8 -6
- package/dist/lib/runtime.js +69 -13
- package/dist/lib/tools-inspect.d.ts +2 -0
- package/dist/lib/tools-inspect.js +39 -2
- package/dist/lib/tools-register.js +0 -1
- package/dist/lib/tools-spawn.js +3 -2
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +3 -0
- package/dist/pi-actors/index.js +1 -0
- package/dist/recipes/subagent-judge.json +2 -1
- package/dist/recipes/subagent-merge.json +2 -1
- package/dist/recipes/subagent-normalize.json +2 -1
- package/dist/recipes/subagent-review-coordinator.json +1 -1
- package/dist/recipes/subagent-review.json +2 -1
- package/dist/recipes/subagent-verify.json +2 -1
- package/dist/scripts/async-runner.mjs +274 -6
- package/dist/scripts/build-dist.mjs +14 -1
- package/dist/skills/actors/SKILL.md +15 -8
- package/dist/skills/swarm/SKILL.md +4 -2
- package/docs/actor-messages.md +1 -1
- package/docs/async-runs.md +14 -5
- package/docs/command-templates.md +4 -2
- package/docs/recipe-library.md +1 -0
- package/docs/template-recipes.md +5 -7
- package/docs/tool-registry.md +4 -2
- package/index.ts +18 -7
- package/lib/async-runs.ts +138 -19
- package/lib/command-templates.ts +132 -13
- package/lib/config.ts +0 -4
- package/lib/execution.ts +198 -13
- package/lib/file-state.ts +106 -3
- package/lib/observability.ts +8 -2
- package/lib/prompts.ts +3 -5
- package/lib/recipes-context.ts +17 -9
- package/lib/recipes-discovery.ts +10 -5
- package/lib/recipes-references.ts +5 -6
- package/lib/recipes-usage.ts +36 -20
- package/lib/registry.ts +43 -13
- package/lib/runs-ownership.ts +117 -0
- package/lib/runs-process.ts +138 -16
- package/lib/runs-retention.ts +22 -2
- package/lib/runs-start.ts +89 -31
- package/lib/runs-status.ts +15 -6
- package/lib/runtime.ts +64 -12
- package/lib/tools-inspect.ts +46 -4
- package/lib/tools-register.ts +0 -3
- package/lib/tools-spawn.ts +5 -5
- package/lib/tools.ts +8 -0
- package/package.json +2 -2
- package/recipes/subagent-judge.json +2 -1
- package/recipes/subagent-merge.json +2 -1
- package/recipes/subagent-normalize.json +2 -1
- package/recipes/subagent-review-coordinator.json +1 -1
- package/recipes/subagent-review.json +2 -1
- package/recipes/subagent-verify.json +2 -1
- package/scripts/async-runner.mjs +274 -6
- package/scripts/build-dist.mjs +14 -1
- package/skills/actors/SKILL.md +15 -8
- package/skills/swarm/SKILL.md +4 -2
|
@@ -57,6 +57,7 @@ Common object fields:
|
|
|
57
57
|
- `defaults`: Placeholder default values by name.
|
|
58
58
|
- `timeout`: Optional execution timeout in milliseconds. Omit it, or set `0`, to leave the command unbounded. Set an explicit positive timeout when a tool must fail closed instead of waiting indefinitely. Numeric control fields may be literal numbers or placeholders such as `"{timeout_ms}"`.
|
|
59
59
|
- `delay`: Optional wait in milliseconds before starting this node. Default is no delay. It may be a literal number or placeholder.
|
|
60
|
+
- `accept_output`: Optional fail-closed semantic output contract. `review_evidence` requires successful stdout to begin with `ACTOR_REVIEW_RESULT`; missing markers become code-65 failures while rejected stdout remains visible in branch diagnostics.
|
|
60
61
|
- `output`: Optional result selector. Default is `"stdout"`; runtime values such as `"ogg"` are valid.
|
|
61
62
|
- `retry`: Optional max attempts including the first. Default is `1`.
|
|
62
63
|
- `failure`: Optional failure propagation scope: `continue`, `branch`, or `root`. Default is `continue`.
|
|
@@ -185,7 +186,8 @@ Composition rules:
|
|
|
185
186
|
- Top-level `args` and `defaults` apply to every leaf unless the leaf defines private values
|
|
186
187
|
- Leaf `args` replace inherited `args`; leaf `defaults` merge over inherited defaults; `timeout` and `output` are not inherited into leaves
|
|
187
188
|
- Timeout is disabled by default; configure a positive `timeout` for bounded commands that should fail closed
|
|
188
|
-
-
|
|
189
|
+
- Child stdout and stderr are captured as raw bytes with independent bounded in-memory tails; streams that exceed the capture limit spill completely to byte-exact diagnostic files and return byte counts, truncation flags, and spill paths. Text tails align their start to a UTF-8 code-point boundary so chunking or truncation does not corrupt valid multibyte output. Async runs persist complete streams even below the capture limit under command- and retry-specific run-state paths
|
|
190
|
+
- Each sequence leaf receives the previous leaf's complete stdout on stdin by default, reading the byte-complete spill when the model-facing capture is truncated; the final leaf stdout remains the bounded default composition result. Parallel joins likewise use complete branch spills for downstream stdin while branch details and returned output stay bounded. If a declared spill is unavailable, the pipeline fails closed instead of forwarding a partial tail
|
|
189
191
|
- Skipped nodes preserve current stdin/stdout flow and do not execute commands
|
|
190
192
|
- Each parallel child receives the same stdin, and child stdout values are joined in stable array order before flowing to the next sequence leaf
|
|
191
193
|
- Parallel branch joins include branch label and status, and tool details include branch metadata plus coverage summary
|
|
@@ -401,7 +403,7 @@ string → leaf command
|
|
|
401
403
|
string[] → sequential composition
|
|
402
404
|
{ template } → leaf command object
|
|
403
405
|
{ parallel, template } → sequence or parallel subtree
|
|
404
|
-
{ parallel, concurrency, min_successful, when, args, defaults, delay, retry, failure, recover, output, template } → full node
|
|
406
|
+
{ parallel, concurrency, min_successful, when, args, defaults, delay, retry, failure, recover, accept_output, output, template } → full node
|
|
405
407
|
```
|
|
406
408
|
|
|
407
409
|
Start with a string. Add composition when needed. Add `parallel: true` when independent work can run concurrently. Add `when` when a node is conditional. Add delay when launch pacing matters. Add retry when flaky. Add `failure` when propagation scope matters. Add `recover` when a retried node needs cleanup before another attempt. Same contract, growing capability, no dead weight.
|
package/docs/recipe-library.md
CHANGED
|
@@ -30,6 +30,7 @@ Core subagent recipes:
|
|
|
30
30
|
- `recipes/subagent-tools.json`: Start a subagent with an explicit tool allowlist.
|
|
31
31
|
- `recipes/subagents-prompts.json`: Run prompt fanout with one imported subagent component.
|
|
32
32
|
- `recipes/subagent-preflight.json`: Tiny model/thinking/tool-policy smoke check before expensive fanout; failures surface `ACTOR_PREFLIGHT_FAILED` with stage, selected policy, provider error class, prompt file, and override args.
|
|
33
|
+
- Packaged reviewer, verifier, merger, judge, and normalizer stages use `accept_output: review_evidence` and require `ACTOR_REVIEW_RESULT` as the exact first non-whitespace output line. Marker prefixes, format acknowledgements, and input requests therefore remain rejected branch diagnostics rather than usable quorum evidence.
|
|
33
34
|
- `recipes/subagent-review.json`: Evidence-grounded review lens.
|
|
34
35
|
- `recipes/subagent-critic.json`: Assumption and failure-mode critique.
|
|
35
36
|
- `recipes/subagent-plan.json`: Bounded plan slices and validation gates.
|
package/docs/template-recipes.md
CHANGED
|
@@ -19,7 +19,7 @@ async: true = run through detached lifecycle
|
|
|
19
19
|
|
|
20
20
|
A recipe wraps one command-template tree. The wrapped `template` keeps the normal command-template semantics: argv splitting, placeholders, defaults, typed args, sequence, `parallel: true`, `when`, delay, retry, failure propagation, recover cleanup, and output selection.
|
|
21
21
|
|
|
22
|
-
Layer boundary: `imports`, `{ "name": "alias" }` imported-recipe nodes, `{alias.defaults.key}` references, fallback expressions, and recipe-local ternaries are recipe-loading features. They resolve before the command-template graph runs and do not extend the portable Command Template Standard. Typed imports are recipe definitions: they expose the imported recipe's command-template-shaped metadata (`template`, `args`, `defaults`, flags, and `values`), while async-run launch fields such as `async
|
|
22
|
+
Layer boundary: `imports`, `{ "name": "alias" }` imported-recipe nodes, `{alias.defaults.key}` references, fallback expressions, and recipe-local ternaries are recipe-loading features. They resolve before the command-template graph runs and do not extend the portable Command Template Standard. Typed imports are recipe definitions: they expose the imported recipe's command-template-shaped metadata (`template`, `args`, `defaults`, flags, and `values`), while async-run launch fields such as `async` and `retire_when` remain lifecycle configuration for starting a run, not part of the imported execution graph. Run state directories are runtime-owned and are not recipe or `register_tool` configuration; `{state_dir}` remains an injected run-local value for commands and artifact paths.
|
|
23
23
|
|
|
24
24
|
Packaged recipes are the pi-actors recipe standard library: declarative actor config components that can be imported, launched, inspected, overridden, or composed by user recipes. Treat them as stable building blocks rather than user-local policy.
|
|
25
25
|
|
|
@@ -109,18 +109,16 @@ Higher-priority files shadow lower-priority files with the same basename. Within
|
|
|
109
109
|
|
|
110
110
|
## Usage Metadata
|
|
111
111
|
|
|
112
|
-
User-owned
|
|
112
|
+
User-owned recipe launches may accumulate extension-maintained usage metadata in `.usage/<recipe-filename>.json` sidecars:
|
|
113
113
|
|
|
114
114
|
```json
|
|
115
115
|
{
|
|
116
|
-
"
|
|
117
|
-
|
|
118
|
-
"last_called": "2026-05-22T10:30:00.000Z"
|
|
119
|
-
}
|
|
116
|
+
"calls": 12,
|
|
117
|
+
"last_called": "2026-05-22T10:30:00.000Z"
|
|
120
118
|
}
|
|
121
119
|
```
|
|
122
120
|
|
|
123
|
-
The extension increments `
|
|
121
|
+
The extension increments `calls` and updates `last_called` when it starts that concrete recipe, either through a recipe-backed tool call or a direct async recipe-file run. The sidecar also stores a content `fingerprint`; if authored recipe content changes, the next launch resets `calls` before counting the new launch and records `reset_at`. Keeping telemetry outside the recipe prevents usage writes from replacing concurrent operator edits; discovery merges sidecar usage into inspection. Agents should treat these fields as cleanup evidence, not as authored recipe contract. Packaged standard-library recipes do not receive usage metadata.
|
|
124
122
|
|
|
125
123
|
There is intentionally no failure counter in the recipe contract. A failed launch can reflect caller misuse, missing runtime values, or an environmental problem rather than recipe uselessness. Cleanup decisions should be explicit operator work: keep as a tool, move out of the agent recipe root to retain recipe-only memory, merge, delete, or archive.
|
|
126
124
|
|
package/docs/tool-registry.md
CHANGED
|
@@ -16,9 +16,9 @@ The registry source is location-discovered recipes, not a live tool-only JSON fi
|
|
|
16
16
|
- Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` and `docs_review.md` both have id/tool name `docs_review`.
|
|
17
17
|
- Same-id JSON shadows Markdown in the same priority layer.
|
|
18
18
|
|
|
19
|
-
Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint`
|
|
19
|
+
Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` in `.usage/<recipe-filename>.json` sidecars rather than rewriting authored recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning without racing operator edits. Discovery and file-watcher refresh merge sidecar usage into inspect summaries. `inspect target=recipes view=summary verbose=true` includes usage metadata and operator-gated cleanup recommendations for invalid, shadowed, disabled, component-only, unused, or overriding recipes. Recommended actions stay explicit: keep as a tool/component, enable, merge, fix, delete, or archive. The extension does not maintain a failure counter and agents should not silently clean tools during unrelated work.
|
|
20
20
|
|
|
21
|
-
`register_tool` is the preferred agent-facing mutation API. It creates, updates, and deletes recipe files in `~/.pi/agent/recipes`; agents do not need to edit the files directly for normal registration. Direct file edits are still valid for operators and advanced agents. Runtime behavior is reactive: file creation, deletion, or edits in the user recipe root trigger validation and tool-set refresh, with invalid recipes surfaced as diagnostics rather than silently ignored.
|
|
21
|
+
`register_tool` is the preferred agent-facing mutation API. It creates, updates, and deletes recipe files in `~/.pi/agent/recipes`; agents do not need to edit the files directly for normal registration. Extension-authored register, update, delete, draft-promotion, and usage-metadata mutations hold a cross-process lock keyed by filesystem-canonical recipe identity across the complete check/read/write/runtime-update window. Existing targets or the nearest existing parent are resolved through `realpath`, so real and symlink aliases serialize while unrelated recipes remain independent; stale locks are reclaimed only after their owner is proven dead. Direct file edits are still valid for operators and advanced agents. Runtime behavior is reactive: file creation, deletion, or edits in the user recipe root trigger validation and tool-set refresh, with invalid recipes surfaced as diagnostics rather than silently ignored. If the recipe root does not exist at session start, an advisory parent watcher detects its creation and switches to the normal root watcher; deletion or rename rearms the parent watcher without polling.
|
|
22
22
|
|
|
23
23
|
Inspect the loaded pi-actors runtime and discovered registry with:
|
|
24
24
|
|
|
@@ -36,6 +36,8 @@ The recipe summary reports active, shadowed, invalid, disabled, and diagnostic e
|
|
|
36
36
|
|
|
37
37
|
Routine shadowing is quiet. If a bare `spawn` recipe launch already fails because an invalid or `disabled: true` user recipe blocks a lower-priority fallback, the launch error adds compact tokens such as `reason=shadowed_invalid` or `reason=shadowed_disabled`, `active_path`, `blocked_fallback`, and `hint=inspect_recipes_doctor`.
|
|
38
38
|
|
|
39
|
+
Pi cannot currently unregister an already published dynamic tool definition from the complete host registry. The extension therefore gates its own `message to=tool:<name>` and `inspect tool:<name>` lookup through the current recipe registry: deleting or externally removing a recipe immediately makes those routes inactive, and recipe updates replace the extension-local executable definition even if stale host metadata remains visible until reload.
|
|
40
|
+
|
|
39
41
|
## Registering Tools
|
|
40
42
|
|
|
41
43
|
`register_tool` is the interactive API for listing, creating, updating, or deleting persistent tools. Call it without arguments to list registered tools.
|
package/index.ts
CHANGED
|
@@ -36,7 +36,11 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
|
|
|
36
36
|
AsyncRuns.sendRunMessage(candidate.stateDir, "stop"),
|
|
37
37
|
});
|
|
38
38
|
};
|
|
39
|
-
const updateRunUi = (
|
|
39
|
+
const updateRunUi = (
|
|
40
|
+
ctx: Pi.ExtensionContext,
|
|
41
|
+
notify = false,
|
|
42
|
+
terminalOnly = false,
|
|
43
|
+
): void => {
|
|
40
44
|
const ownerId = getRunOwnerId(ctx);
|
|
41
45
|
const snapshot = Observability.readRunUiSnapshot(runUi, ownerId);
|
|
42
46
|
ctx.ui.setStatus(
|
|
@@ -78,10 +82,12 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
|
|
|
78
82
|
notificationSink,
|
|
79
83
|
);
|
|
80
84
|
Observability.pruneRunUiObservationState(runUi, snapshot);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
if (!terminalOnly) {
|
|
86
|
+
Observability.deliverRunOutboxNotifications(
|
|
87
|
+
snapshot.outboxEvents,
|
|
88
|
+
notificationSink,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
85
91
|
};
|
|
86
92
|
const closeRunWatchers = (): void => {
|
|
87
93
|
runWatcher.close();
|
|
@@ -144,7 +150,7 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
|
|
|
144
150
|
activeRunContext = ctx;
|
|
145
151
|
await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
|
|
146
152
|
runtime.loadTools(ctx);
|
|
147
|
-
updateRunUi(ctx);
|
|
153
|
+
updateRunUi(ctx, true, true);
|
|
148
154
|
closeRunWatchers();
|
|
149
155
|
recipeReload.close();
|
|
150
156
|
runWatcher.refresh();
|
|
@@ -204,7 +210,12 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
|
|
|
204
210
|
Tools.createCoreActorToolDefinitions<Pi.ExtensionContext>({
|
|
205
211
|
configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
|
|
206
212
|
getActiveTools: () => pi.getActiveTools(),
|
|
207
|
-
getRuntimeTool: (name) =>
|
|
213
|
+
getRuntimeTool: (name) =>
|
|
214
|
+
Tools.resolveActiveRuntimeTool(
|
|
215
|
+
name,
|
|
216
|
+
runtime.getTools(),
|
|
217
|
+
(activeName) => actorToolDefinitions.get(activeName),
|
|
218
|
+
),
|
|
208
219
|
registryRuntime: runtime,
|
|
209
220
|
setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
|
|
210
221
|
}).map(withCurrentThinkingContext),
|
package/lib/async-runs.ts
CHANGED
|
@@ -10,11 +10,12 @@ import {
|
|
|
10
10
|
mkdirSync,
|
|
11
11
|
openSync,
|
|
12
12
|
readFileSync,
|
|
13
|
+
readdirSync,
|
|
13
14
|
rmSync,
|
|
14
15
|
statSync,
|
|
15
16
|
writeFileSync,
|
|
16
17
|
} from "node:fs";
|
|
17
|
-
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
18
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
20
|
|
|
20
21
|
import type {
|
|
@@ -50,11 +51,12 @@ import {
|
|
|
50
51
|
parseRunOutboxEventLine,
|
|
51
52
|
type RunOutboxEvent,
|
|
52
53
|
} from "./runs-outbox.ts";
|
|
54
|
+
import { claimRunStateDirectory } from "./runs-ownership.ts";
|
|
53
55
|
import { archiveTerminalRun, pruneTerminalRun } from "./runs-retention.ts";
|
|
54
56
|
import {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
captureRunProcessIdentity,
|
|
58
|
+
verifyRunProcessIdentity,
|
|
59
|
+
type RunProcessIdentity,
|
|
58
60
|
} from "./runs-process.ts";
|
|
59
61
|
import * as RunsStart from "./runs-start.ts";
|
|
60
62
|
import * as RunsIndex from "./runs-index.ts";
|
|
@@ -105,6 +107,7 @@ export interface AsyncRunStartParams {
|
|
|
105
107
|
when?: boolean | string;
|
|
106
108
|
timeout?: number | string;
|
|
107
109
|
delay?: number | string;
|
|
110
|
+
accept_output?: "review_evidence";
|
|
108
111
|
output?: string;
|
|
109
112
|
artifacts?: Record<string, RunArtifactDeclaration>;
|
|
110
113
|
mailbox?: RecipesReferences.TemplateRecipeMailbox;
|
|
@@ -146,6 +149,7 @@ export interface AsyncRunMeta {
|
|
|
146
149
|
control?: AsyncRunControlEndpoint;
|
|
147
150
|
mailbox?: RecipesReferences.TemplateRecipeMailbox;
|
|
148
151
|
model_policy?: CurrentPolicyProvenance;
|
|
152
|
+
process_identity?: RunProcessIdentity;
|
|
149
153
|
recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
|
|
150
154
|
retire_when?: "children_terminal";
|
|
151
155
|
}
|
|
@@ -196,6 +200,7 @@ function resolveRunTemplate(params: AsyncRunStartParams): {
|
|
|
196
200
|
"when",
|
|
197
201
|
"timeout",
|
|
198
202
|
"delay",
|
|
203
|
+
"accept_output",
|
|
199
204
|
"output",
|
|
200
205
|
"retry",
|
|
201
206
|
"failure",
|
|
@@ -445,6 +450,7 @@ export function startRun(
|
|
|
445
450
|
mkdirSync(stateDir, { recursive: true });
|
|
446
451
|
const releaseStartLock = acquireStateStartLock(stateDir);
|
|
447
452
|
try {
|
|
453
|
+
claimRunStateDirectory(stateDir, run);
|
|
448
454
|
assertNoActiveRunState(stateDir);
|
|
449
455
|
prepareStateDirForStart(stateDir);
|
|
450
456
|
const stdout = join(stateDir, "stdout.log");
|
|
@@ -512,6 +518,13 @@ export function startRun(
|
|
|
512
518
|
closeSync(outFd);
|
|
513
519
|
closeSync(errFd);
|
|
514
520
|
meta.pid = child.pid ?? 0;
|
|
521
|
+
const processIdentity = captureRunProcessIdentity(
|
|
522
|
+
meta.pid,
|
|
523
|
+
cwd,
|
|
524
|
+
stateDir,
|
|
525
|
+
RUNNER_PATH,
|
|
526
|
+
);
|
|
527
|
+
if (processIdentity) meta.process_identity = processIdentity;
|
|
515
528
|
writeJsonAtomic(join(stateDir, "run.json"), meta);
|
|
516
529
|
writeJsonAtomic(join(stateDir, "progress.json"), {
|
|
517
530
|
completed: 0,
|
|
@@ -704,15 +717,23 @@ export async function sendRunMessage(
|
|
|
704
717
|
const status = getRunStatus(runOrDir);
|
|
705
718
|
const stateDir = String(status.state_dir);
|
|
706
719
|
const run = String(status.run ?? runOrDir);
|
|
707
|
-
if (status.status !== "running")
|
|
708
|
-
throw new Error(`Run is not running: ${run}`);
|
|
709
720
|
const pid = Number(status.pid || 0);
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
)
|
|
715
|
-
|
|
721
|
+
const identity = verifyRunProcessIdentity(
|
|
722
|
+
pid,
|
|
723
|
+
status.process_identity as RunProcessIdentity | undefined,
|
|
724
|
+
);
|
|
725
|
+
if (status.status !== "running") {
|
|
726
|
+
if (
|
|
727
|
+
identity.status === "owner_mismatch" ||
|
|
728
|
+
identity.status === "unsupported_proof"
|
|
729
|
+
) {
|
|
730
|
+
throw new Error(`Run process identity ${identity.status}: ${run}`);
|
|
731
|
+
}
|
|
732
|
+
throw new Error(`Run is not running: ${run}`);
|
|
733
|
+
}
|
|
734
|
+
if (!identity.valid) {
|
|
735
|
+
throw new Error(`Run process identity ${identity.status}: ${run}`);
|
|
736
|
+
}
|
|
716
737
|
return deliverRunMessage(status, run, stateDir, message, options);
|
|
717
738
|
}
|
|
718
739
|
|
|
@@ -734,6 +755,66 @@ function markTerminalProgress(
|
|
|
734
755
|
);
|
|
735
756
|
}
|
|
736
757
|
|
|
758
|
+
function finalizeInterruptedReviewEvidence(
|
|
759
|
+
stateDir: string,
|
|
760
|
+
phase: "cancelled" | "killed",
|
|
761
|
+
signal: NodeJS.Signals,
|
|
762
|
+
): void {
|
|
763
|
+
const evidencePath = join(stateDir, "review-evidence.json");
|
|
764
|
+
const manifest = readJson(evidencePath);
|
|
765
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return;
|
|
766
|
+
const record = manifest as Record<string, unknown>;
|
|
767
|
+
if (!Array.isArray(record.commands)) return;
|
|
768
|
+
const completedAt = new Date().toISOString();
|
|
769
|
+
const effectiveExitCode = signal === "SIGKILL" ? 137 : 143;
|
|
770
|
+
const commands = record.commands.map((command) => {
|
|
771
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
772
|
+
return command;
|
|
773
|
+
}
|
|
774
|
+
const entry = command as Record<string, unknown>;
|
|
775
|
+
if (entry.status !== "running" || typeof entry.id !== "string") return entry;
|
|
776
|
+
const captureDir = join(stateDir, "captures", entry.id);
|
|
777
|
+
const attempts = existsSync(captureDir)
|
|
778
|
+
? readdirSync(captureDir)
|
|
779
|
+
.filter((name) => /^attempt-\d+$/.test(name))
|
|
780
|
+
.sort()
|
|
781
|
+
.map((name, index) => {
|
|
782
|
+
const attemptDir = join(captureDir, name);
|
|
783
|
+
const stdoutFile = join(attemptDir, "stdout.log");
|
|
784
|
+
const stderrFile = join(attemptDir, "stderr.log");
|
|
785
|
+
return {
|
|
786
|
+
attempt: index + 1,
|
|
787
|
+
stdout: {
|
|
788
|
+
path: relative(stateDir, stdoutFile),
|
|
789
|
+
bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
|
|
790
|
+
},
|
|
791
|
+
stderr: {
|
|
792
|
+
path: relative(stateDir, stderrFile),
|
|
793
|
+
bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
})
|
|
797
|
+
: [];
|
|
798
|
+
return {
|
|
799
|
+
...entry,
|
|
800
|
+
status: phase,
|
|
801
|
+
completed_at: completedAt,
|
|
802
|
+
attempts,
|
|
803
|
+
effective_exit_code: effectiveExitCode,
|
|
804
|
+
killed: true,
|
|
805
|
+
...(entry.semantic_acceptance === "pending"
|
|
806
|
+
? { semantic_acceptance: "interrupted" }
|
|
807
|
+
: {}),
|
|
808
|
+
};
|
|
809
|
+
});
|
|
810
|
+
writeJsonAtomic(evidencePath, {
|
|
811
|
+
...record,
|
|
812
|
+
status: phase,
|
|
813
|
+
commands,
|
|
814
|
+
updated_at: completedAt,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
737
818
|
function stopRun(
|
|
738
819
|
runOrDir: string,
|
|
739
820
|
signal: NodeJS.Signals,
|
|
@@ -742,12 +823,34 @@ function stopRun(
|
|
|
742
823
|
const status = getRunStatus(runOrDir);
|
|
743
824
|
const pid = Number(status.pid || 0);
|
|
744
825
|
const stateDir = String(status.state_dir);
|
|
745
|
-
if (status.status !== "running")
|
|
826
|
+
if (status.status !== "running" && status.status !== "exited") {
|
|
827
|
+
return { stopped: false, reason: "not running", status };
|
|
828
|
+
}
|
|
829
|
+
const identity = verifyRunProcessIdentity(
|
|
830
|
+
pid,
|
|
831
|
+
status.process_identity as RunProcessIdentity | undefined,
|
|
832
|
+
);
|
|
833
|
+
if (status.status === "exited") {
|
|
834
|
+
if (
|
|
835
|
+
identity.status === "owner_mismatch" ||
|
|
836
|
+
identity.status === "unsupported_proof"
|
|
837
|
+
) {
|
|
838
|
+
return {
|
|
839
|
+
stopped: false,
|
|
840
|
+
reason: identity.status.replaceAll("_", " "),
|
|
841
|
+
process_identity_status: identity.status,
|
|
842
|
+
status,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
746
845
|
return { stopped: false, reason: "not running", status };
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
846
|
+
}
|
|
847
|
+
if (!identity.valid) {
|
|
848
|
+
return {
|
|
849
|
+
stopped: false,
|
|
850
|
+
reason: identity.status.replaceAll("_", " "),
|
|
851
|
+
process_identity_status: identity.status,
|
|
852
|
+
status,
|
|
853
|
+
};
|
|
751
854
|
}
|
|
752
855
|
const signalResult = signalOwnedRunProcess(pid, signal);
|
|
753
856
|
writeFileSync(
|
|
@@ -756,11 +859,27 @@ function stopRun(
|
|
|
756
859
|
{ flag: "a" },
|
|
757
860
|
);
|
|
758
861
|
markTerminalHandled(stateDir, { event, signal });
|
|
759
|
-
if (event === "run.kill")
|
|
760
|
-
|
|
862
|
+
if (event === "run.kill") {
|
|
863
|
+
finalizeInterruptedReviewEvidence(stateDir, "killed", signal);
|
|
864
|
+
markTerminalProgress(stateDir, "killed");
|
|
865
|
+
}
|
|
866
|
+
if (event === "run.cancel") {
|
|
867
|
+
finalizeInterruptedReviewEvidence(stateDir, "cancelled", signal);
|
|
868
|
+
markTerminalProgress(stateDir, "cancelled");
|
|
869
|
+
}
|
|
761
870
|
return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
|
|
762
871
|
}
|
|
763
872
|
|
|
873
|
+
export function markRunTerminalNotificationHandled(
|
|
874
|
+
stateDir: string,
|
|
875
|
+
status: string,
|
|
876
|
+
): void {
|
|
877
|
+
markTerminalHandled(stateDir, {
|
|
878
|
+
event: "run.notification",
|
|
879
|
+
status,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
764
883
|
export function cancelRun(runOrDir: string): Record<string, unknown> {
|
|
765
884
|
const result = stopRun(runOrDir, "SIGTERM", "run.cancel");
|
|
766
885
|
return Object.hasOwn(result, "stopped")
|
package/lib/command-templates.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
10
11
|
|
|
11
12
|
export type CommandTemplateFailureScope = "continue" | "branch" | "root";
|
|
12
13
|
|
|
@@ -30,6 +31,7 @@ export interface CommandTemplateObjectConfig {
|
|
|
30
31
|
defaults?: Record<string, unknown>;
|
|
31
32
|
timeout?: number | string;
|
|
32
33
|
delay?: number | string;
|
|
34
|
+
accept_output?: "review_evidence";
|
|
33
35
|
output?: string;
|
|
34
36
|
retry?: number | string;
|
|
35
37
|
failure?: CommandTemplateFailureScope;
|
|
@@ -60,6 +62,8 @@ export interface CommandTemplateExecOptions {
|
|
|
60
62
|
stdin?: string;
|
|
61
63
|
killGrace?: number;
|
|
62
64
|
retry?: number;
|
|
65
|
+
captureDir?: string;
|
|
66
|
+
captureLimitBytes?: number;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
export interface CommandTemplateExecResult {
|
|
@@ -67,6 +71,12 @@ export interface CommandTemplateExecResult {
|
|
|
67
71
|
stderr: string;
|
|
68
72
|
code: number;
|
|
69
73
|
killed: boolean;
|
|
74
|
+
stdoutBytes?: number;
|
|
75
|
+
stderrBytes?: number;
|
|
76
|
+
stdoutFile?: string;
|
|
77
|
+
stderrFile?: string;
|
|
78
|
+
stdoutTruncated?: boolean;
|
|
79
|
+
stderrTruncated?: boolean;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
export type CommandTemplateRiskLabel =
|
|
@@ -80,6 +90,8 @@ export type CommandTemplateRiskLabel =
|
|
|
80
90
|
| "risk.platform_specific"
|
|
81
91
|
| "risk.secret_touching";
|
|
82
92
|
|
|
93
|
+
const DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES = 1024 * 1024;
|
|
94
|
+
|
|
83
95
|
const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
|
|
84
96
|
"risk.shell",
|
|
85
97
|
"risk.eval",
|
|
@@ -584,9 +596,9 @@ export function expandCommandTemplateExecutable(
|
|
|
584
596
|
cwd: string,
|
|
585
597
|
): string {
|
|
586
598
|
if (command === "~") return homedir();
|
|
587
|
-
if (command.startsWith("~/")) return
|
|
599
|
+
if (command.startsWith("~/")) return resolvePath(homedir(), command.slice(2));
|
|
588
600
|
if (command.includes("/") && !isAbsolute(command))
|
|
589
|
-
return
|
|
601
|
+
return resolvePath(cwd, command);
|
|
590
602
|
return command;
|
|
591
603
|
}
|
|
592
604
|
|
|
@@ -840,13 +852,83 @@ export async function execCommandTemplate(
|
|
|
840
852
|
killed: false,
|
|
841
853
|
};
|
|
842
854
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
843
|
-
const
|
|
855
|
+
const attemptOptions = options.captureDir
|
|
856
|
+
? {
|
|
857
|
+
...options,
|
|
858
|
+
captureDir: join(
|
|
859
|
+
options.captureDir,
|
|
860
|
+
`attempt-${String(attempt).padStart(3, "0")}`,
|
|
861
|
+
),
|
|
862
|
+
}
|
|
863
|
+
: options;
|
|
864
|
+
const result = await execCommandTemplateOnce(command, args, attemptOptions);
|
|
844
865
|
if (result.code === 0) return result;
|
|
845
866
|
lastResult = result;
|
|
846
867
|
}
|
|
847
868
|
return lastResult;
|
|
848
869
|
}
|
|
849
870
|
|
|
871
|
+
interface BoundedCommandCapture {
|
|
872
|
+
append(value: Buffer | string): void;
|
|
873
|
+
result(): {
|
|
874
|
+
bytes: number;
|
|
875
|
+
content: string;
|
|
876
|
+
file?: string;
|
|
877
|
+
truncated: boolean;
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function trimCaptureTail(value: Buffer, limit: number): Buffer {
|
|
882
|
+
if (value.length <= limit) return value;
|
|
883
|
+
let start = value.length - limit;
|
|
884
|
+
while (start < value.length && (value[start]! & 0xc0) === 0x80) start += 1;
|
|
885
|
+
return value.subarray(start);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function createBoundedCommandCapture(
|
|
889
|
+
stream: "stdout" | "stderr",
|
|
890
|
+
limit: number,
|
|
891
|
+
getCaptureDir: () => string,
|
|
892
|
+
persistCompleteStream: boolean,
|
|
893
|
+
): BoundedCommandCapture {
|
|
894
|
+
let bytes = 0;
|
|
895
|
+
let content: Buffer = Buffer.alloc(0);
|
|
896
|
+
let file = persistCompleteStream
|
|
897
|
+
? join(getCaptureDir(), `${stream}.log`)
|
|
898
|
+
: undefined;
|
|
899
|
+
if (file) writeFileSync(file, Buffer.alloc(0));
|
|
900
|
+
let truncated = false;
|
|
901
|
+
return {
|
|
902
|
+
append(value) {
|
|
903
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
904
|
+
bytes += chunk.length;
|
|
905
|
+
if (bytes > limit) truncated = true;
|
|
906
|
+
if (!file && bytes <= limit) {
|
|
907
|
+
content = Buffer.concat([content, chunk]);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (!file) {
|
|
911
|
+
file = join(getCaptureDir(), `${stream}.log`);
|
|
912
|
+
writeFileSync(file, content);
|
|
913
|
+
}
|
|
914
|
+
appendFileSync(file, chunk);
|
|
915
|
+
content = trimCaptureTail(Buffer.concat([content, chunk]), limit);
|
|
916
|
+
},
|
|
917
|
+
result() {
|
|
918
|
+
if (!file && persistCompleteStream) {
|
|
919
|
+
file = join(getCaptureDir(), `${stream}.log`);
|
|
920
|
+
writeFileSync(file, content);
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
bytes,
|
|
924
|
+
content: content.toString("utf8"),
|
|
925
|
+
...(file ? { file } : {}),
|
|
926
|
+
truncated,
|
|
927
|
+
};
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
850
932
|
function execCommandTemplateOnce(
|
|
851
933
|
command: string,
|
|
852
934
|
args: string[],
|
|
@@ -858,8 +940,32 @@ function execCommandTemplateOnce(
|
|
|
858
940
|
shell: false,
|
|
859
941
|
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
860
942
|
});
|
|
861
|
-
|
|
862
|
-
|
|
943
|
+
const captureLimit = Math.max(
|
|
944
|
+
1,
|
|
945
|
+
options.captureLimitBytes ?? DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES,
|
|
946
|
+
);
|
|
947
|
+
let captureDir: string | undefined;
|
|
948
|
+
const getCaptureDir = (): string => {
|
|
949
|
+
if (captureDir) return captureDir;
|
|
950
|
+
captureDir = options.captureDir
|
|
951
|
+
? resolvePath(options.captureDir)
|
|
952
|
+
: mkdtempSync(join(tmpdir(), "pi-actors-command-"));
|
|
953
|
+
mkdirSync(captureDir, { recursive: true });
|
|
954
|
+
return captureDir;
|
|
955
|
+
};
|
|
956
|
+
const persistCompleteStreams = options.captureDir !== undefined;
|
|
957
|
+
const stdoutCapture = createBoundedCommandCapture(
|
|
958
|
+
"stdout",
|
|
959
|
+
captureLimit,
|
|
960
|
+
getCaptureDir,
|
|
961
|
+
persistCompleteStreams,
|
|
962
|
+
);
|
|
963
|
+
const stderrCapture = createBoundedCommandCapture(
|
|
964
|
+
"stderr",
|
|
965
|
+
captureLimit,
|
|
966
|
+
getCaptureDir,
|
|
967
|
+
persistCompleteStreams,
|
|
968
|
+
);
|
|
863
969
|
let killed = false;
|
|
864
970
|
let settled = false;
|
|
865
971
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
@@ -879,7 +985,20 @@ function execCommandTemplateOnce(
|
|
|
879
985
|
if (killTimeoutId) clearTimeout(killTimeoutId);
|
|
880
986
|
if (options.signal)
|
|
881
987
|
options.signal.removeEventListener("abort", killProcess);
|
|
882
|
-
|
|
988
|
+
const stdout = stdoutCapture.result();
|
|
989
|
+
const stderr = stderrCapture.result();
|
|
990
|
+
resolve({
|
|
991
|
+
stdout: stdout.content,
|
|
992
|
+
stderr: stderr.content,
|
|
993
|
+
code,
|
|
994
|
+
killed,
|
|
995
|
+
stdoutBytes: stdout.bytes,
|
|
996
|
+
stderrBytes: stderr.bytes,
|
|
997
|
+
...(stdout.file ? { stdoutFile: stdout.file } : {}),
|
|
998
|
+
...(stderr.file ? { stderrFile: stderr.file } : {}),
|
|
999
|
+
...(stdout.truncated ? { stdoutTruncated: true } : {}),
|
|
1000
|
+
...(stderr.truncated ? { stderrTruncated: true } : {}),
|
|
1001
|
+
});
|
|
883
1002
|
};
|
|
884
1003
|
if (options.signal) {
|
|
885
1004
|
if (options.signal.aborted) killProcess();
|
|
@@ -888,16 +1007,16 @@ function execCommandTemplateOnce(
|
|
|
888
1007
|
}
|
|
889
1008
|
if (options.timeout !== undefined && options.timeout > 0)
|
|
890
1009
|
timeoutId = setTimeout(killProcess, options.timeout);
|
|
891
|
-
proc.stdout?.on("data", (data) => {
|
|
892
|
-
|
|
1010
|
+
proc.stdout?.on("data", (data: Buffer) => {
|
|
1011
|
+
stdoutCapture.append(data);
|
|
893
1012
|
});
|
|
894
|
-
proc.stderr?.on("data", (data) => {
|
|
895
|
-
|
|
1013
|
+
proc.stderr?.on("data", (data: Buffer) => {
|
|
1014
|
+
stderrCapture.append(data);
|
|
896
1015
|
});
|
|
897
1016
|
proc.stdin?.on("error", () => {});
|
|
898
1017
|
if (options.stdin !== undefined) proc.stdin?.end(options.stdin);
|
|
899
1018
|
proc.on("error", (error) => {
|
|
900
|
-
|
|
1019
|
+
stderrCapture.append(error instanceof Error ? error.message : String(error));
|
|
901
1020
|
settle(1);
|
|
902
1021
|
});
|
|
903
1022
|
proc.on("close", (code) => {
|
package/lib/config.ts
CHANGED
|
@@ -47,7 +47,6 @@ export function serializeTools(
|
|
|
47
47
|
entry.defaults = cfg.storedDefaults;
|
|
48
48
|
if (cfg.recipe?.name) entry.name = cfg.recipe.name;
|
|
49
49
|
if (cfg.recipe?.async !== undefined) entry.async = cfg.recipe.async;
|
|
50
|
-
if (cfg.recipe?.state_dir) entry.state_dir = cfg.recipe.state_dir;
|
|
51
50
|
if (cfg.recipe?.values) entry.values = cfg.recipe.values;
|
|
52
51
|
if (cfg.template) entry.template = cfg.template;
|
|
53
52
|
result[name] = entry;
|
|
@@ -145,9 +144,6 @@ export function normalizeStoredTool(
|
|
|
145
144
|
? {
|
|
146
145
|
name: recipeName,
|
|
147
146
|
...(typeof record.async === "boolean" ? { async: record.async } : {}),
|
|
148
|
-
...(typeof record.state_dir === "string" && record.state_dir.trim()
|
|
149
|
-
? { state_dir: record.state_dir.trim() }
|
|
150
|
-
: {}),
|
|
151
147
|
template,
|
|
152
148
|
...(record.values &&
|
|
153
149
|
typeof record.values === "object" &&
|