pi-subagents 0.68.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/docs/agents.md +4 -0
- package/docs/tool-reference.md +23 -2
- package/docs/workflows.md +48 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +3 -1
- package/src/agents/agents.ts +1 -1
- package/src/agents/skills.ts +1 -1
- package/src/extension/schemas.ts +7 -1
- package/src/inspectors/ghostty/plugin.ts +13 -1
- package/src/runs/background/subagent-runner.ts +17 -1
- package/src/runs/foreground/execution.ts +24 -1
- package/src/runs/foreground/subagent-executor.ts +16 -1
- package/src/runs/shared/acceptance.ts +160 -27
- package/src/runs/shared/worktree.ts +27 -5
- package/src/shared/types.ts +11 -0
- package/src/workflows/scripted-workflow.ts +26 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.69.0] - 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Highlights
|
|
8
|
+
- Gates can now return a JSON verdict. Point `gate` at a script that prints JSON, and its output becomes the child's structured output, so workflows can branch on a post-run check without the parent reading the child's report.
|
|
9
|
+
- Ghostty detection no longer misfires inside terminals like cmux that embed Ghostty, so you stop seeing AppleScript `-1728`/`-2741` errors or the wrong window being targeted.
|
|
10
|
+
- Hosts without `npm` start up quietly instead of printing `npm: command not found`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Typed gates: `gate` accepts `{ command, output: "json", schema?, timeoutMs? }` alongside the plain string form. When the command passes, its JSON stdout becomes the child's `structuredOutput` (validated against `schema` when given). Empty, truncated, or invalid output fails the gate rather than silently dropping the verdict. Typed gates always run (they are never cached), and a run cannot combine one with an `outputSchema`. See `docs/` for using command-runner agents as typed workflow steps and `examples/typed-gate` for a runnable example.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The Ghostty inspector only activates when the macOS host bundle id identifies the standalone Ghostty app. Terminals that embed Ghostty (such as cmux) set `TERM_PROGRAM=ghostty` too, which previously targeted an unrelated Ghostty window or emitted `-1728`/`-2741` AppleScript errors; those hosts now fall back to the `inspector.command` hint. Thanks to [@wangpi26](https://github.com/wangpi26) for #2281.
|
|
19
|
+
- Hosts without `npm` no longer print `/bin/sh: npm: command not found` during startup; global package-root discovery is optional and now stays silent when the package manager is missing. Thanks to [@PhrZer](https://github.com/PhrZer) for #2287.
|
|
20
|
+
- Fixed `docs/tool-reference.md`, which claimed a default `maxOutput` cap of 200 KB / 5,000 lines. The cap applies only when `maxOutput` is set.
|
|
21
|
+
|
|
5
22
|
## [0.68.0] - 2026-09-15
|
|
6
23
|
|
|
7
24
|
### Highlights
|
|
@@ -9,7 +26,7 @@
|
|
|
9
26
|
- Reuse workflow scripts with different JSON inputs, including scheduled runs.
|
|
10
27
|
- Start npm-installed children much faster and let slow local models use Pi's configured HTTP timeout.
|
|
11
28
|
- Keep local foreground children on the same extension-provided models as their parent without sharing provider state between sessions.
|
|
12
|
-
-
|
|
29
|
+
- Ask async agents to checkpoint before a hard deadline, giving long-running work a chance to return useful progress instead of being killed.
|
|
13
30
|
|
|
14
31
|
### Added
|
|
15
32
|
|
|
@@ -34,6 +51,9 @@
|
|
|
34
51
|
|
|
35
52
|
### Fixed
|
|
36
53
|
|
|
54
|
+
- Fail managed worktree setup before child launch when a required shared `node_modules` link cannot be created and verified, while preserving absent sources and preexisting destinations (#2283).
|
|
55
|
+
- Allow checked writers to explicitly preserve a host-bound staged index while still rejecting child-created index changes (#2280).
|
|
56
|
+
|
|
37
57
|
- Preserve the main watchdog's user scope across session compaction while clearing temporary activity state. Thanks to [@nimeetshah0](https://github.com/nimeetshah0) for #2263.
|
|
38
58
|
- Resolve provider-extension models in local, in-process foreground children. Such a child never loads the parent's ambient extensions, so its model runtime only knew Pi's built-in providers and every model from an extension-registered provider failed with `Model "…" not found`; the child now inherits the providers registered in the parent session before resolving its model. Pane-native remote foreground children continue to use the remote machine's provider discovery and configuration. Builtin agents on such a model no longer need `async: true`. Thanks to [@lallenlowe](https://github.com/lallenlowe) for #2274.
|
|
39
59
|
- Preserve a readable async result when result indexing or archiving fails, then retry saving it without delivering it twice. Thanks to [@shaharmor](https://github.com/shaharmor) for #2267 and #2266.
|
package/docs/agents.md
CHANGED
|
@@ -58,6 +58,10 @@ If you disabled the old bundled `gpt-pro` workaround with `agentOverrides.gpt-pr
|
|
|
58
58
|
|
|
59
59
|
The Pi async run remains the source of truth for status, artifacts, wake/wait, mission attachment, retention, and diagnostics.
|
|
60
60
|
|
|
61
|
+
### Command-runner agents as typed steps
|
|
62
|
+
|
|
63
|
+
`runner.type: external-cli` with a plain `command` (no `adapter`) runs any local executable as a subagent: the assembled prompt is written to stdin, stdout becomes the child's output, and the run gets the usual run id, status, mission entry, and workflow key. This is how a classifier, a scoring script, or a small evaluation model becomes a `runs.run` step. Such agents are async-only (workflows launch children async by default; a direct `async: false` call is refused), receive no forked transcript, and cannot produce `structuredOutput` themselves; parse their `output` in the workflow, or pair them with a [typed gate](tool-reference.md#typed-gates). Generic commands are local-only; saved-machine placement accepts only the code-owned adapters below.
|
|
64
|
+
|
|
61
65
|
### Advisory runner data boundary
|
|
62
66
|
|
|
63
67
|
External CLI agents use their own runner contract. They are deliberate execution modes, not implicit recovery paths for a failed native `subagent` workflow. For backlog lanes and other subagent-governed workflows, switching to an external, foreground, or CLI runner requires explicit owner approval after the exact failure/run/worktree state is recorded and the worktree is verified clean or its partial diff is captured. Do not pass native Pi child options such as model override, structured output, acceptance/agent contract, tool budgets, fast mode, fork context, skills, or native Pi tools unless the adapter explicitly implements them.
|
package/docs/tool-reference.md
CHANGED
|
@@ -122,13 +122,13 @@ The complete plain-JSON inventory is validated before the first launch (maximum
|
|
|
122
122
|
| `usageBudget` | object | none | Optional root-only reported-usage budget `{ tokens?: { soft?, hard }, costUsd?: { soft?, hard } }`. Soft limits are status-only. Hard limits prevent later child launches after reported usage is reconciled; already-running children are not stopped and no reservations are made. |
|
|
123
123
|
| `cwd` | string | runtime cwd | Override working directory. With `machine`, the directory on that machine. |
|
|
124
124
|
| `machine` | string | - | Herdr saved machine (label or profile id) for external-cli agents; see [agents.md](agents.md#running-external-cli-agents-on-a-herdr-saved-machine). |
|
|
125
|
-
| `maxOutput` | object |
|
|
125
|
+
| `maxOutput` | object | none | Final output truncation limits `{ bytes?, lines? }`. Only applied when set; there is no default cap on the inline path, so use `outputMode: "file-only"` for large outputs. |
|
|
126
126
|
| `artifacts` | boolean | true | Write debug artifacts. |
|
|
127
127
|
| `includeProgress` | boolean | false | Include full progress in result. |
|
|
128
128
|
| `share` | boolean | false | Upload session export to GitHub Gist. |
|
|
129
129
|
| `sessionDir` | string | derived | Override session log directory. |
|
|
130
130
|
| `acceptance` | string/object/false | inferred | Configure evidence gates. See [Acceptance gates](#acceptance-gates). |
|
|
131
|
-
| `gate` | string | - | One host-run verification command, shorthand for `acceptance: { level: "verified", verify: [{ id: "gate", command }] }`. Also valid on individual `runs.run`/`runs.all` items. Rejects `acceptance` except `false` (treated as omitted),
|
|
131
|
+
| `gate` | string \| object | - | One host-run verification command, shorthand for `acceptance: { level: "verified", verify: [{ id: "gate", command }] }`. The object form `{ command, output?: "json", schema?, timeoutMs? }` adds a [typed gate](#typed-gates): with `output: "json"`, a passing command's stdout becomes the child's `structuredOutput`. Also valid on individual `runs.run`/`runs.all` items. Rejects `acceptance` except `false` (treated as omitted), rejects retained `resume`, and `output: "json"` rejects `outputSchema`. |
|
|
132
132
|
|
|
133
133
|
### Budget guidance for writers
|
|
134
134
|
|
|
@@ -381,6 +381,8 @@ The `/subagents-steer <run-id> [--child <child-id>] <message>` slash command is
|
|
|
381
381
|
|
|
382
382
|
Every run resolves an effective acceptance policy. Callers may omit `acceptance` for the inferred default, or set it on single runs, top-level parallel task items, chain steps, static parallel tasks, and dynamic fanout templates.
|
|
383
383
|
|
|
384
|
+
Checked writers reject staged files by default. When a parent intentionally starts a single writer with reviewed staged content, opt in with `acceptance: { level: "checked", preserveStagedIndex: true }`. The host captures the repository-wide index tree immediately before each launch (including each retained resume) and accepts only if `git write-tree` produces the same tree at completion. Working-tree-only fixes are allowed; child-created staging is rejected. Capture or terminal Git failures, including an unavailable or unmerged index, fail closed. This option does not stage or restore files and should not be used for concurrent writers sharing one worktree.
|
|
385
|
+
|
|
384
386
|
Prefer an inline JSON object. JSON-encoded object strings are tolerated only during input normalization; invalid strings fail closed. `true` is invalid. Supported evidence kinds are `changed-files`, `tests-added`, `commands-run`, `validation-output`, `residual-risks`, `no-staged-files`, `diff-summary`, `review-findings`, and `manual-notes`. For example: `{level:"checked",evidence:["commands-run","changed-files"],review:{required:true}}`. Evidence levels end at `verified`; independent review is a separate gate, not a stronger evidence level.
|
|
385
387
|
|
|
386
388
|
```ts
|
|
@@ -406,6 +408,25 @@ When one host-run command is the entire verification contract, use the `gate` sh
|
|
|
406
408
|
|
|
407
409
|
`gate` normalizes to verified acceptance with that single command, so the runtime executes it on the host and records the result as evidence. Verification results are memoized per tracked workspace state and effective environment, so an unchanged tree does not rerun the same command. Use explicit `acceptance.verify` when you need multiple commands, timeouts, or custom criteria. `gate` rejects `acceptance` except `false` (treated as omitted), and rejects retained `resume` items. With `worktree: true`, the gate runs inside the child's managed worktree.
|
|
408
410
|
|
|
411
|
+
### Typed gates
|
|
412
|
+
|
|
413
|
+
A gate given as `{ command, output: "json" }` runs like a string gate, and then parses the command's stdout:
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
{ workflowScript: `return runs.run("review", {
|
|
417
|
+
agent: "reviewer", task: "Review the change", output: "reports/review.md", outputMode: "file-only",
|
|
418
|
+
gate: { command: "classify --report reports/review.md", output: "json",
|
|
419
|
+
schema: { type: "object", properties: { verdict: { enum: ["ok", "blocked"] } }, required: ["verdict"] } }
|
|
420
|
+
})` }
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- The command runs after the child's output file is saved, in the child's cwd or managed worktree, so it can read what the child wrote.
|
|
424
|
+
- A passing command must print one JSON document on stdout (at most 12,000 characters). The parsed value becomes `result.structuredOutput`, is recorded on the verify run as `structuredOutput`, and is projected into `status.json`. When `schema` is given, the value must validate against it.
|
|
425
|
+
- Empty, truncated, non-JSON, or schema-invalid stdout marks the gate `failed` with a `structuredOutputError`; explicit acceptance then fails the run, exactly as a non-zero exit would. The verdict is never silently dropped.
|
|
426
|
+
- Typed gates are never memoized: their input (a report, a log) can change without the tracked tree changing.
|
|
427
|
+
- `output: "json"` cannot be combined with an `outputSchema` from any source: the launch `outputSchema` param or the agent's frontmatter, paired with `gate`, an explicit `acceptance.verify` entry, or the agent's `defaultAcceptance`. Preflight rejects the launch and names both sources. A child has exactly one structured-output source.
|
|
428
|
+
- `runs.lanes` treats a bridged `structuredOutput.verdict === "blocked"` like any other blocked stage. Scripts read the value as `result.structuredOutput`.
|
|
429
|
+
|
|
409
430
|
### Levels and inference
|
|
410
431
|
|
|
411
432
|
Acceptance evidence levels are `auto`, `none`, `attested`, `checked`, and `verified`. `acceptance: "auto"` is the default.
|
package/docs/workflows.md
CHANGED
|
@@ -194,6 +194,54 @@ The helper validates the complete plain-JSON lane inventory before launching any
|
|
|
194
194
|
|
|
195
195
|
The board is bounded and contains only lane/stage keys, state, success, retained run ids, explicit output references, bounded errors, and an optional structured verdict. It does not return child transcripts or create a lane registry or cleanup authority. Use raw `runs.run(...)`/`runs.all(...)` when a workflow needs conditional or rolling orchestration beyond this helper.
|
|
196
196
|
|
|
197
|
+
### Typed post-run checks and typed steps
|
|
198
|
+
|
|
199
|
+
Two ways to put a small classifier (a script, a lookup, a fast evaluation model) into a workflow without spending an LLM turn on it.
|
|
200
|
+
|
|
201
|
+
**A typed gate** runs a command after a child finishes and turns its JSON stdout into that child's `structuredOutput`:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
subagent({ workflowScript: `
|
|
205
|
+
const review = await runs.run("review", {
|
|
206
|
+
agent: "reviewer", task: packet,
|
|
207
|
+
output: "reports/review.md", outputMode: "file-only",
|
|
208
|
+
gate: { command: "classify --report reports/review.md", output: "json" }
|
|
209
|
+
});
|
|
210
|
+
if (review.structuredOutput.verdict === "blocked") {
|
|
211
|
+
return runs.run("fix", { agent: "worker", task: "Fix the findings in reports/review.md" });
|
|
212
|
+
}
|
|
213
|
+
return { verdict: review.structuredOutput.verdict, report: "reports/review.md" };
|
|
214
|
+
` });
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The parent receives a pointer plus a verdict instead of the review text. See [typed gates](tool-reference.md#typed-gates) for the contract and failure rules.
|
|
218
|
+
|
|
219
|
+
**A typed step** is an agent whose runner is a command rather than a Pi session. The prompt arrives on stdin, stdout is the child's output, and the workflow uses it like any other child:
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
---
|
|
223
|
+
name: classifier
|
|
224
|
+
description: Typed classification of the task text
|
|
225
|
+
runner:
|
|
226
|
+
type: external-cli
|
|
227
|
+
command: /path/to/classify
|
|
228
|
+
args: [--stdin, --json]
|
|
229
|
+
promptDelivery: stdin
|
|
230
|
+
async: true
|
|
231
|
+
systemPromptMode: replace
|
|
232
|
+
inheritProjectContext: false
|
|
233
|
+
inheritGlobalContext: false
|
|
234
|
+
inheritSkills: false
|
|
235
|
+
---
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
const results = await runs.all(items.map((item) => ({ key: item.key, agent: "classifier", task: item.text })));
|
|
240
|
+
const routed = results.map((r, i) => ({ key: items[i].key, ...JSON.parse(r.output) }));
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Command-runner agents are async-only; workflows launch children async by default, but a direct `async: false` call is refused. The runner gets only the assembled prompt, never a forked transcript, so keep `inheritProjectContext` and `inheritGlobalContext` off unless the command wants that text. See [examples/typed-gate](https://github.com/nicobailon/pi-subagents/tree/main/examples/typed-gate) for a runnable version of both shapes.
|
|
244
|
+
|
|
197
245
|
### Host command steps
|
|
198
246
|
|
|
199
247
|
Use the named `run-ci` resource when a permission/policy extension needs to admit one supported non-interactive command as workflow evidence instead of a child-agent run:
|
package/package.json
CHANGED
|
@@ -24,7 +24,7 @@ Project settings resolve from the nearest parent directory containing `.pi` or `
|
|
|
24
24
|
|
|
25
25
|
An agent may set `runner.type: external-cli` with a non-empty `command`, optional string `args`, and `promptDelivery: stdin` (the default). The command runs with `shell: false`, inherits the resolved cwd and environment, and receives the combined agent instructions and task through stdin. It must already be installed; pi-subagents adds no CLI dependency.
|
|
26
26
|
|
|
27
|
-
External CLI profiles are async-only and one-shot. They support lifecycle artifacts, stdout/stderr logs, timeout, and stop. Full stdout and stderr are retained in their log files, while the final stdout response and stderr error kept in memory are each limited to their last 64 KiB. They do not support native Pi child options such as model override, structured output, acceptance/agent contract, tool budgets, fast mode, fork context, skills, or native Pi tools unless the runner explicitly implements them. Foreground/clarify, steer/resume/interrupt-as-pause, nested subagents, and sessions are also unsupported.
|
|
27
|
+
A command-runner agent with a plain `command` (no adapter) is also how a classifier or scoring script becomes a typed workflow step: the prompt arrives on stdin, stdout is the child's `output`, and the script parses it. Keep `inheritProjectContext`, `inheritGlobalContext`, and `inheritSkills` off unless the command wants that text. External CLI profiles are async-only and one-shot. They support lifecycle artifacts, stdout/stderr logs, timeout, and stop. Full stdout and stderr are retained in their log files, while the final stdout response and stderr error kept in memory are each limited to their last 64 KiB. They do not support native Pi child options such as model override, structured output, acceptance/agent contract, tool budgets, fast mode, fork context, skills, or native Pi tools unless the runner explicitly implements them. Foreground/clarify, steer/resume/interrupt-as-pause, nested subagents, and sessions are also unsupported.
|
|
28
28
|
|
|
29
29
|
### External job profiles
|
|
30
30
|
|
|
@@ -99,6 +99,8 @@ If `runs.all` is missing in a running session, reload or update `pi-subagents` b
|
|
|
99
99
|
|
|
100
100
|
For one host-run verification command, pass `gate: "npm test"` on a `runs.run`/`runs.all` item (or at the top level as a workflow default). It is shorthand for verified acceptance with that single command: the runtime executes it on the host, records the result as evidence, and memoizes it per tracked workspace state and effective environment. `gate` cannot be combined with `acceptance`; use explicit `acceptance.verify` for multiple commands or custom criteria.
|
|
101
101
|
|
|
102
|
+
For a typed post-run check, pass the object form `gate: { command, output: "json", schema?, timeoutMs? }`. A passing command must print one JSON document (under 12,000 characters); the parsed value, validated against `schema` when given, becomes the child's `structuredOutput`, so a script can branch on `result.structuredOutput` and `runs.lanes` blocks on `verdict === "blocked"` without the parent reading the child's output. Pair it with `output` + `outputMode: "file-only"` so the command reads the saved file. Empty, non-JSON, or schema-invalid stdout fails the gate and rejects the run. Typed gates are never memoized. A typed gate cannot be combined with an `outputSchema` from the launch or the agent; the launch is rejected before any child starts. See the `tool-reference` guide, "Typed gates".
|
|
103
|
+
|
|
102
104
|
If omitted, acceptance is inferred from role, mode, and risk. Use `level: "checked"` for ordinary writer evidence and `level: "verified"` when the runtime should run explicit validation commands. Independent review is orthogonal: use `review: { required: true, agent: "reviewer" }`; reviewer/read-only calls omit `acceptance`. `review-required` means evidence passed but review is pending; `reviewed` means an independent review found no blockers. Never request `level: "reviewed"`; it is recognized only so preflight can return an actionable correction. Disable gates with `{ level: "none", reason: "..." }`; bare `"none"` is rejected and `false` is only a deprecated shorthand. Child-reported command success is evidence, not runtime verification.
|
|
103
105
|
|
|
104
106
|
Completed workflow children from this parent session stay addressable as retained children. `subagent({ action: "children.list" })` lists up to the last 10 with run ids and reports each row as `resumable` or `not resumable` with a reason. Resume only rows reported `resumable`. For a retained-child challenge, use `resume` instead of `steer` when the child is complete. If no retained child is resumable, launch a same-role fallback challenge and label it as fallback. A later workflow continues a resumable child with `runs.run(key, { resume: "<run-id>", task: "follow-up" })`. Inside `workflowScript`, awaiting that call waits for the revived child to finish and returns its completed output and new `runId`; top-level `{ action: "resume" }` remains detached. Pass explicit follow-up task text. Assign each returned child result back to the loop variable because every resume can return a new retained `runId`; always resume the latest returned id. `resume` and `agent` are mutually exclusive, the revived child keeps its stored agent/model/tool contract, and `gate` is rejected on retained resume items.
|
package/src/agents/agents.ts
CHANGED
|
@@ -495,7 +495,7 @@ function getGlobalNpmRoot(): string | null {
|
|
|
495
495
|
}
|
|
496
496
|
|
|
497
497
|
try {
|
|
498
|
-
cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true }).trim());
|
|
498
|
+
cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim());
|
|
499
499
|
return cachedGlobalNpmRoot;
|
|
500
500
|
} catch {
|
|
501
501
|
cachedGlobalNpmRoot = "";
|
package/src/agents/skills.ts
CHANGED
|
@@ -142,7 +142,7 @@ function getGlobalNpmRoot(): string | null {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
try {
|
|
145
|
-
cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true }).trim());
|
|
145
|
+
cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim());
|
|
146
146
|
return cachedGlobalNpmRoot;
|
|
147
147
|
} catch {
|
|
148
148
|
// Global npm root is optional in constrained environments.
|
package/src/extension/schemas.ts
CHANGED
|
@@ -389,7 +389,13 @@ const SubagentParamProperties = {
|
|
|
389
389
|
outputSchema: Type.Optional(OutputSchemaOverride),
|
|
390
390
|
agentContract: Type.Optional(AgentContractOverride),
|
|
391
391
|
acceptance: Type.Optional(AcceptanceOverride),
|
|
392
|
-
gate: Type.Optional(Type.
|
|
392
|
+
gate: Type.Optional(Type.Unsafe({
|
|
393
|
+
anyOf: [
|
|
394
|
+
{ type: "string", minLength: 1 },
|
|
395
|
+
{ type: "object", properties: { command: { type: "string", minLength: 1 }, output: { type: "string", enum: ["json"] }, schema: { type: "object" }, timeoutMs: { type: "integer", minimum: 1 } }, required: ["command"], additionalProperties: false },
|
|
396
|
+
],
|
|
397
|
+
description: "Host gate command run after the child finishes: a string, or { command, output: \"json\", schema?, timeoutMs? } whose passing stdout becomes structuredOutput (not with outputSchema). Cannot be combined with acceptance; an explicit acceptance of false is treated as omitted.",
|
|
398
|
+
})),
|
|
393
399
|
};
|
|
394
400
|
|
|
395
401
|
const SubagentParamsSchema = Type.Object(SubagentParamProperties);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { openGhosttyInspector, type GhosttyRunner } from "./actions.ts";
|
|
2
2
|
import type { InspectorPlugin } from "../types.ts";
|
|
3
3
|
|
|
4
|
+
/** 独立 Ghostty.app 的 bundle id (作者 Mitchell Hashimoto)。 */
|
|
5
|
+
const GHOSTTY_BUNDLE_ID = "com.mitchellh.ghostty";
|
|
6
|
+
|
|
4
7
|
export interface GhosttyPluginDeps {
|
|
5
8
|
platform?: NodeJS.Platform;
|
|
6
9
|
runner?: GhosttyRunner;
|
|
@@ -10,7 +13,16 @@ export function createGhosttyInspectorPlugin(deps: GhosttyPluginDeps = {}): Insp
|
|
|
10
13
|
const platform = deps.platform ?? process.platform;
|
|
11
14
|
return {
|
|
12
15
|
name: "ghostty",
|
|
13
|
-
available: (context) =>
|
|
16
|
+
available: (context) => {
|
|
17
|
+
// cmux 内嵌 Ghostty 内核, 也会把 TERM_PROGRAM 设成 "ghostty"。仅凭环境变量会让 plugin
|
|
18
|
+
// 在 cmux 下误接管, 随后 osascript 连不上真正的 Ghostty 应用而抛 -1728/-2741。
|
|
19
|
+
if (platform !== "darwin") return false;
|
|
20
|
+
if (context.env.TERM_PROGRAM?.toLowerCase() !== "ghostty") return false;
|
|
21
|
+
// macOS GUI 应用启动子进程时注入 __CFBundleIdentifier, 标识当前终端宿主 app。
|
|
22
|
+
// cmux 的 bundle id 是 com.cmuxterm.app, 而非 Ghostty; 即便系统同时装了独立 Ghostty,
|
|
23
|
+
// 也能据此判定当前终端不是 Ghostty, 避免误连独立 Ghostty 的窗口。
|
|
24
|
+
return context.env.__CFBundleIdentifier?.trim() === GHOSTTY_BUNDLE_ID;
|
|
25
|
+
},
|
|
14
26
|
owns: () => false,
|
|
15
27
|
open: (context, launch, params) => openGhosttyInspector(context, launch, params, deps.runner),
|
|
16
28
|
};
|
|
@@ -136,7 +136,7 @@ import { assertThinkingWithinCeiling } from "../../shared/thinking-ceiling.ts";
|
|
|
136
136
|
import { resolveLaunchBinding } from "../../shared/launch-contract.ts";
|
|
137
137
|
import { writeInitialProgressFile } from "../../shared/settings.ts";
|
|
138
138
|
import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
|
|
139
|
-
import { acceptanceFailureMessage, aggregateAcceptanceReport, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveAcceptanceReportMode, resolveEffectiveAcceptance, stripAcceptanceReport } from "../shared/acceptance.ts";
|
|
139
|
+
import { acceptanceFailureMessage, aggregateAcceptanceReport, buildSkippedAcceptanceLedger, captureStagedIndexBaseline, evaluateAcceptance, formatAcceptancePrompt, resolveAcceptanceReportMode, resolveEffectiveAcceptance, stripAcceptanceReport, typedVerifyOutput } from "../shared/acceptance.ts";
|
|
140
140
|
import { attachContractProjections, isAgentContract } from "../shared/agent-contract.ts";
|
|
141
141
|
import { waitForImportedAsyncRoot } from "./chain-root-attachment.ts";
|
|
142
142
|
import { appendRunnerStepsToStatus, consumeChainAppendRequests, countPendingChainAppendRequests, statusStepDescription } from "./chain-append.ts";
|
|
@@ -1118,6 +1118,7 @@ export async function runSingleStepInner(
|
|
|
1118
1118
|
const aggregateUsage = emptyUsage();
|
|
1119
1119
|
let launched = false;
|
|
1120
1120
|
let recoveryTask = task;
|
|
1121
|
+
let stagedIndexBaseline: string | undefined;
|
|
1121
1122
|
singleLaunch: for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
|
|
1122
1123
|
if (ctx.timeoutSignal?.aborted || ctx.stopSignal?.aborted || ctx.skipAcceptance?.()) break singleLaunch;
|
|
1123
1124
|
const expectedModelForVerification = candidate && !step.skipPrimaryModelVerification ? candidate : undefined;
|
|
@@ -1214,6 +1215,14 @@ export async function runSingleStepInner(
|
|
|
1214
1215
|
// Each attempt rewrites the step output log; synchronous appends keep a
|
|
1215
1216
|
// retried attempt from interleaving with the previous attempt's flush.
|
|
1216
1217
|
fs.writeFileSync(ctx.outputFile, "", "utf-8");
|
|
1218
|
+
if (step.effectiveAcceptance?.preserveStagedIndex && stagedIndexBaseline === undefined) {
|
|
1219
|
+
try {
|
|
1220
|
+
stagedIndexBaseline = captureStagedIndexBaseline(step.cwd ?? ctx.cwd);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1223
|
+
return { agent: step.agent, output: message, error: message, exitCode: 1, context: step.context };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1217
1226
|
const run = await runChildSession(omitUndefinedProperties({
|
|
1218
1227
|
factory: ctx.childSessions,
|
|
1219
1228
|
launch,
|
|
@@ -1494,6 +1503,7 @@ export async function runSingleStepInner(
|
|
|
1494
1503
|
? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only", durable: resolvedOutput.savedPath !== undefined }
|
|
1495
1504
|
: undefined,
|
|
1496
1505
|
cwd: step.cwd ?? ctx.cwd,
|
|
1506
|
+
stagedIndexBaseline,
|
|
1497
1507
|
signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
|
|
1498
1508
|
abortMessage: ctx.stopSignal?.aborted ? ctx.stopMessage ?? "Subagent stopped by user." : ctx.timeoutMessage ?? "Subagent timed out.",
|
|
1499
1509
|
reportOptional: isAgentContract(step.agentContract),
|
|
@@ -1514,6 +1524,12 @@ export async function runSingleStepInner(
|
|
|
1514
1524
|
const acceptanceFailure = effectiveAcceptance ? acceptanceFailureMessage(effectiveAcceptance) : undefined;
|
|
1515
1525
|
const acceptanceCanFailRun = acceptanceFailure && effectiveAcceptance?.explicit && (finalResult?.exitCode ?? 1) === 0 && !finalResult?.interrupted && !timedOutAfterAcceptance && !stoppedAfterAcceptance && !isAgentContract(step.agentContract);
|
|
1516
1526
|
const effectiveFinalExitCode = timedOutAfterAcceptance || stoppedAfterAcceptance ? 1 : acceptanceCanFailRun ? 1 : finalResult?.exitCode ?? 1;
|
|
1527
|
+
// A passing typed gate supplies the structured output for runs that have no
|
|
1528
|
+
// outputSchema of their own; preflight rejects the combination.
|
|
1529
|
+
const typedGate = typedVerifyOutput(effectiveAcceptance);
|
|
1530
|
+
if (typedGate && finalResult && finalResult.structuredOutput === undefined && effectiveFinalExitCode === 0) {
|
|
1531
|
+
finalResult = { ...finalResult, structuredOutput: typedGate.value };
|
|
1532
|
+
}
|
|
1517
1533
|
const intercomDetachReceipt = finalResult?.finalOutput === INTERCOM_DETACH_RECEIPT;
|
|
1518
1534
|
const baseFinalError = stoppedAfterAcceptance
|
|
1519
1535
|
? ctx.stopMessage ?? "Subagent stopped by user."
|
|
@@ -88,7 +88,7 @@ import {
|
|
|
88
88
|
shouldEscalateMutatingFailures,
|
|
89
89
|
summarizeRecentMutatingFailures,
|
|
90
90
|
} from "../shared/long-running-guard.ts";
|
|
91
|
-
import { acceptanceFailureMessage, buildSkippedAcceptanceLedger, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport, validateAcceptanceInput } from "../shared/acceptance.ts";
|
|
91
|
+
import { acceptanceFailureMessage, buildSkippedAcceptanceLedger, captureStagedIndexBaseline, evaluateAcceptance, formatAcceptancePrompt, resolveEffectiveAcceptance, stripAcceptanceReport, validateAcceptanceInput, typedVerifyOutput } from "../shared/acceptance.ts";
|
|
92
92
|
import { PROMPT_REDACTED } from "../../shared/utils.ts";
|
|
93
93
|
import { attachContractProjections, isAgentContract } from "../shared/agent-contract.ts";
|
|
94
94
|
import { initialToolBudgetState, toolBudgetState } from "../shared/tool-budget.ts";
|
|
@@ -1892,6 +1892,22 @@ async function runSyncCompletionInner(
|
|
|
1892
1892
|
const verifyModel = Boolean(candidate) && !options.modelOverrideFromParent;
|
|
1893
1893
|
let lastResult: SingleResult | undefined;
|
|
1894
1894
|
let recoveryPrompt = task;
|
|
1895
|
+
let stagedIndexBaseline: string | undefined;
|
|
1896
|
+
if (effectiveAcceptance.preserveStagedIndex) {
|
|
1897
|
+
try {
|
|
1898
|
+
stagedIndexBaseline = captureStagedIndexBaseline(options.cwd ?? runtimeCwd);
|
|
1899
|
+
} catch (error) {
|
|
1900
|
+
return redactResultPrompt(withRunContext({
|
|
1901
|
+
index: options.index ?? 0,
|
|
1902
|
+
agent: agentName,
|
|
1903
|
+
task,
|
|
1904
|
+
exitCode: 1,
|
|
1905
|
+
messages: [],
|
|
1906
|
+
usage: emptyUsage(),
|
|
1907
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1908
|
+
}, options.context));
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1895
1911
|
for (let attemptIndex = 0; attemptIndex < 2; attemptIndex++) {
|
|
1896
1912
|
const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
|
|
1897
1913
|
const attemptResult = await runSingleAttempt(runtimeCwd, agent, recoveryPrompt, candidate, attemptOptions, {
|
|
@@ -2022,6 +2038,7 @@ async function runSyncCompletionInner(
|
|
|
2022
2038
|
? { content: childWrittenOutput, path: options.outputPath, authoritative: options.outputMode === "file-only", durable: result.savedOutputPath !== undefined }
|
|
2023
2039
|
: undefined,
|
|
2024
2040
|
cwd: options.cwd ?? runtimeCwd,
|
|
2041
|
+
stagedIndexBaseline,
|
|
2025
2042
|
reportOptional: isAgentContract(options.agentContract),
|
|
2026
2043
|
artifactsDir: options.artifactsDir,
|
|
2027
2044
|
runId: options.runId,
|
|
@@ -2034,6 +2051,12 @@ async function runSyncCompletionInner(
|
|
|
2034
2051
|
}
|
|
2035
2052
|
const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
|
|
2036
2053
|
stripAcceptanceReportsFromMessages(result.messages);
|
|
2054
|
+
// A passing typed gate supplies the structured output for runs that have no
|
|
2055
|
+
// outputSchema of their own; preflight rejects the combination.
|
|
2056
|
+
const typedGate = typedVerifyOutput(result.acceptance);
|
|
2057
|
+
if (typedGate && result.structuredOutput === undefined && !acceptanceFailure && result.exitCode === 0) {
|
|
2058
|
+
result.structuredOutput = typedGate.value;
|
|
2059
|
+
}
|
|
2037
2060
|
if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.interrupted && !result.timedOut && !isAgentContract(options.agentContract)) {
|
|
2038
2061
|
result.exitCode = 1;
|
|
2039
2062
|
if (result.savedOutputPath) {
|
|
@@ -55,7 +55,7 @@ import { isScheduledRunAction } from "../background/scheduled-runs.ts";
|
|
|
55
55
|
import { encodeIndexSegment } from "../background/index-segment.ts";
|
|
56
56
|
import { enqueueChainAppendRequest, readPendingChainAppendRequests, runnerStepOutputNames } from "../background/chain-append.ts";
|
|
57
57
|
import { ChainOutputValidationError, validateChainOutputBindingsWithContext } from "../shared/chain-outputs.ts";
|
|
58
|
-
import { normalizeGateAcceptance, resolveAcceptanceReportMode, validateAcceptanceInput, validateExecutionAcceptance, validateExecutionAcceptancePolicy } from "../shared/acceptance.ts";
|
|
58
|
+
import { acceptanceHasTypedVerify, normalizeGateAcceptance, resolveAcceptanceReportMode, TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT, validateAcceptanceInput, validateExecutionAcceptance, validateExecutionAcceptancePolicy } from "../shared/acceptance.ts";
|
|
59
59
|
import { canPreferFork, createForkContextResolver, resolveSubagentLaunchContext } from "../../shared/fork-context.ts";
|
|
60
60
|
import { createPrunedForkSessionWriter } from "../../shared/pruned-fork.ts";
|
|
61
61
|
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
@@ -4814,6 +4814,15 @@ function normalizeGateParams(params: SubagentParamsLike): GateParamsNormalizatio
|
|
|
4814
4814
|
return { ok: true, params: { ...rest, ...(normalized.acceptance !== undefined ? { acceptance: normalized.acceptance } : {}) } };
|
|
4815
4815
|
}
|
|
4816
4816
|
|
|
4817
|
+
function describeTypedVerifyOutputSchemaConflict(effective: SubagentParamsLike, requested: SubagentParamsLike): string | undefined {
|
|
4818
|
+
if (!acceptanceHasTypedVerify(effective.acceptance)) return undefined;
|
|
4819
|
+
if (effective.outputSchema === undefined || effective.outputSchema === false) return undefined;
|
|
4820
|
+
const agent = effective.agent ?? "?";
|
|
4821
|
+
const schemaSource = requested.outputSchema !== undefined && requested.outputSchema !== false ? "outputSchema" : `agent '${agent}' outputSchema`;
|
|
4822
|
+
const verifySource = requested.gate !== undefined ? "gate.output" : requested.acceptance !== undefined ? "acceptance.verify" : `agent '${agent}' defaultAcceptance`;
|
|
4823
|
+
return `${verifySource}: ${TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT.replace("with outputSchema", `with ${schemaSource}`)}`;
|
|
4824
|
+
}
|
|
4825
|
+
|
|
4817
4826
|
function formatWorkflowValue(value: unknown): string {
|
|
4818
4827
|
if (value === undefined) return "(undefined)";
|
|
4819
4828
|
if (typeof value === "string") return value;
|
|
@@ -6889,6 +6898,12 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
6889
6898
|
}
|
|
6890
6899
|
const modelScope = discovered.modelScope;
|
|
6891
6900
|
effectiveParams = applySingleAgentLaunchDefaults(effectiveParams, discoveredAgents);
|
|
6901
|
+
// The gate shorthand, an explicit acceptance.verify list, and an agent's
|
|
6902
|
+
// defaultAcceptance all normalize to verify commands, and the agent's
|
|
6903
|
+
// frontmatter outputSchema has been merged by now, so this one check keeps
|
|
6904
|
+
// a run to a single structured-output source regardless of spelling.
|
|
6905
|
+
const typedVerifyConflict = describeTypedVerifyOutputSchemaConflict(effectiveParams, params);
|
|
6906
|
+
if (typedVerifyConflict) return buildRequestedModeError(effectiveParams, typedVerifyConflict);
|
|
6892
6907
|
// An agent-level defaultContext is a preference, unlike an explicit request.
|
|
6893
6908
|
// Prefer fork only when the parent session is persisted and has a current leaf;
|
|
6894
6909
|
// otherwise use fresh immediately instead of launching a guaranteed-to-fail fork.
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
AcceptanceReviewResult,
|
|
19
19
|
AcceptanceVerifyCommand,
|
|
20
20
|
AcceptanceVerifyResult,
|
|
21
|
+
JsonSchemaObject,
|
|
21
22
|
ResolvedAcceptanceConfig,
|
|
22
23
|
ResolvedAcceptanceGate,
|
|
23
24
|
SingleResult,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
} from "../../shared/types.ts";
|
|
27
28
|
import { unresolvedChildWatchdogBlockers } from "../../watchdog/child-status.ts";
|
|
28
29
|
import { isAgentContract } from "./agent-contract.ts";
|
|
30
|
+
import { validateStructuredOutputValue } from "./structured-output.ts";
|
|
29
31
|
import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
|
|
30
32
|
|
|
31
33
|
const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
|
|
@@ -50,9 +52,9 @@ const VALID_EVIDENCE_KINDS: AcceptanceEvidenceKind[] = [
|
|
|
50
52
|
const VALID_EVIDENCE = new Set<AcceptanceEvidenceKind>(VALID_EVIDENCE_KINDS);
|
|
51
53
|
const ACCEPTANCE_EVIDENCE_HELP = `Supported evidence kinds: ${VALID_EVIDENCE_KINDS.join(", ")}. Example: { level: "checked", evidence: ["commands-run", "changed-files"] }.`;
|
|
52
54
|
const ACCEPTANCE_OBJECT_EXAMPLE = "Example: { level: \"checked\", evidence: [\"commands-run\", \"changed-files\"] }.";
|
|
53
|
-
const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
|
|
55
|
+
const ACCEPTANCE_CONFIG_KEYS = new Set(["level", "report", "preserveStagedIndex", "criteria", "evidence", "verify", "review", "stopRules", "reason"]);
|
|
54
56
|
const ACCEPTANCE_GATE_KEYS = new Set(["id", "must", "evidence", "severity"]);
|
|
55
|
-
const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure"]);
|
|
57
|
+
const ACCEPTANCE_VERIFY_KEYS = new Set(["id", "command", "timeoutMs", "cwd", "env", "allowFailure", "output", "schema"]);
|
|
56
58
|
const ACCEPTANCE_REVIEW_KEYS = new Set(["agent", "focus", "required"]);
|
|
57
59
|
const EXPLICIT_REVIEWED_UNAVAILABLE = "is an achieved status, not a requestable acceptance level. For a read-only reviewer call, omit acceptance. To require independent review of a writer result, use acceptance.review.required and orchestrate the reviewer separately.";
|
|
58
60
|
|
|
@@ -200,15 +202,59 @@ type GateAcceptanceNormalizationResult =
|
|
|
200
202
|
| { ok: true; acceptance?: AcceptanceInput }
|
|
201
203
|
| { ok: false; error: string };
|
|
202
204
|
|
|
205
|
+
const GATE_OBJECT_KEYS = new Set(["command", "output", "schema", "timeoutMs"]);
|
|
206
|
+
export const GATE_INPUT_ERROR = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
|
|
207
|
+
|
|
208
|
+
export interface GateObjectInput {
|
|
209
|
+
command: string;
|
|
210
|
+
output?: "json";
|
|
211
|
+
schema?: JsonSchemaObject;
|
|
212
|
+
timeoutMs?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Validates the two accepted gate shapes without normalizing them. Shared by preflight and the workflow sandbox mirror. */
|
|
216
|
+
export function parseGateInput(gate: unknown): { ok: true; gate: GateObjectInput } | { ok: false; error: string } {
|
|
217
|
+
if (typeof gate === "string") {
|
|
218
|
+
return gate.trim() ? { ok: true, gate: { command: gate.trim() } } : { ok: false, error: GATE_INPUT_ERROR };
|
|
219
|
+
}
|
|
220
|
+
if (!gate || typeof gate !== "object" || Array.isArray(gate)) return { ok: false, error: GATE_INPUT_ERROR };
|
|
221
|
+
const value = gate as Record<string, unknown>;
|
|
222
|
+
for (const key of Object.keys(value)) {
|
|
223
|
+
if (!GATE_OBJECT_KEYS.has(key)) return { ok: false, error: `gate.${key} is not supported.` };
|
|
224
|
+
}
|
|
225
|
+
if (typeof value.command !== "string" || !value.command.trim()) return { ok: false, error: GATE_INPUT_ERROR };
|
|
226
|
+
if (value.output !== undefined && value.output !== "json") return { ok: false, error: "gate.output must be \"json\" when present." };
|
|
227
|
+
if (value.schema !== undefined) {
|
|
228
|
+
if (value.output !== "json") return { ok: false, error: "gate.schema requires gate.output: \"json\"." };
|
|
229
|
+
if (!value.schema || typeof value.schema !== "object" || Array.isArray(value.schema)) return { ok: false, error: "gate.schema must be a JSON Schema object." };
|
|
230
|
+
}
|
|
231
|
+
if (value.timeoutMs !== undefined && (!Number.isInteger(value.timeoutMs) || (value.timeoutMs as number) < 1)) return { ok: false, error: "gate.timeoutMs must be an integer >= 1." };
|
|
232
|
+
const parsed: GateObjectInput = { command: value.command.trim() };
|
|
233
|
+
if (value.output === "json") parsed.output = "json";
|
|
234
|
+
// SAFETY: schema was checked above to be a non-array object; timeoutMs to be an integer >= 1.
|
|
235
|
+
if (value.schema !== undefined) parsed.schema = value.schema as JsonSchemaObject;
|
|
236
|
+
if (value.timeoutMs !== undefined) parsed.timeoutMs = value.timeoutMs as number;
|
|
237
|
+
return { ok: true, gate: parsed };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** True when an acceptance policy declares at least one `output: "json"` verify command, from either the gate shorthand or an explicit verify list. */
|
|
241
|
+
export function acceptanceHasTypedVerify(acceptance: AcceptanceInput | undefined): boolean {
|
|
242
|
+
if (!acceptance || typeof acceptance !== "object") return false;
|
|
243
|
+
return Array.isArray(acceptance.verify) && acceptance.verify.some((command) => command && typeof command === "object" && command.output === "json");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const TYPED_VERIFY_OUTPUT_SCHEMA_CONFLICT = "a typed verify command (output: \"json\") cannot be combined with outputSchema; the child would have two structured-output sources.";
|
|
247
|
+
|
|
203
248
|
export function normalizeGateAcceptance(gate: unknown, acceptance: AcceptanceInput | undefined): GateAcceptanceNormalizationResult {
|
|
204
249
|
if (gate === undefined) {
|
|
205
250
|
if (acceptance === undefined) return { ok: true };
|
|
206
251
|
const normalized = normalizeAcceptanceValue(acceptance);
|
|
207
252
|
return normalized.error ? { ok: false, error: normalized.error } : { ok: true, acceptance: normalized.value as AcceptanceInput };
|
|
208
253
|
}
|
|
209
|
-
|
|
254
|
+
const parsed = parseGateInput(gate);
|
|
255
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
210
256
|
if (acceptance !== undefined && acceptance !== false) return { ok: false, error: "gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(gate, acceptance) };
|
|
211
|
-
return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate",
|
|
257
|
+
return { ok: true, acceptance: { level: "verified", verify: [{ id: "gate", ...parsed.gate }] } };
|
|
212
258
|
}
|
|
213
259
|
|
|
214
260
|
export function describeGateAcceptanceConflict(gate: unknown, acceptance: unknown): string {
|
|
@@ -267,6 +313,12 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
|
|
|
267
313
|
if (value.report !== undefined && value.report !== "on" && value.report !== "off") {
|
|
268
314
|
errors.push(`${pathLabel}.report must be on or off.`);
|
|
269
315
|
}
|
|
316
|
+
if (value.preserveStagedIndex !== undefined && value.preserveStagedIndex !== true) {
|
|
317
|
+
errors.push(`${pathLabel}.preserveStagedIndex must be true when provided.`);
|
|
318
|
+
}
|
|
319
|
+
if (value.preserveStagedIndex === true && value.level !== "checked" && value.level !== "verified") {
|
|
320
|
+
errors.push(`${pathLabel}.preserveStagedIndex requires level checked or verified.`);
|
|
321
|
+
}
|
|
270
322
|
if (value.level === "none" && (typeof value.reason !== "string" || !value.reason.trim())) {
|
|
271
323
|
errors.push(`${pathLabel}.reason is required when level is none.`);
|
|
272
324
|
}
|
|
@@ -348,6 +400,11 @@ export function validateAcceptanceInput(input: unknown, pathLabel = "acceptance"
|
|
|
348
400
|
if (cmd.allowFailure !== undefined && typeof cmd.allowFailure !== "boolean") {
|
|
349
401
|
errors.push(`${pathLabel}.verify[${index}].allowFailure must be a boolean.`);
|
|
350
402
|
}
|
|
403
|
+
if (cmd.output !== undefined && cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].output must be "json" when present.`);
|
|
404
|
+
if (cmd.schema !== undefined) {
|
|
405
|
+
if (cmd.output !== "json") errors.push(`${pathLabel}.verify[${index}].schema requires output: "json".`);
|
|
406
|
+
if (!cmd.schema || typeof cmd.schema !== "object" || Array.isArray(cmd.schema)) errors.push(`${pathLabel}.verify[${index}].schema must be a JSON Schema object.`);
|
|
407
|
+
}
|
|
351
408
|
}
|
|
352
409
|
}
|
|
353
410
|
if (value.review !== undefined && value.review !== false) {
|
|
@@ -471,6 +528,7 @@ export function resolveEffectiveAcceptance(input: {
|
|
|
471
528
|
inferredReason: [],
|
|
472
529
|
criteria,
|
|
473
530
|
evidence,
|
|
531
|
+
preserveStagedIndex: explicit.preserveStagedIndex,
|
|
474
532
|
verify: explicit.verify ?? [],
|
|
475
533
|
review: explicit.review,
|
|
476
534
|
stopRules: explicit.stopRules ?? [],
|
|
@@ -496,6 +554,7 @@ export function resolveEffectiveAcceptance(input: {
|
|
|
496
554
|
inferredReason: inferred.reasons,
|
|
497
555
|
criteria: level === "none" ? [] : criteria,
|
|
498
556
|
evidence: level === "none" ? [] : evidence,
|
|
557
|
+
preserveStagedIndex: explicit.preserveStagedIndex,
|
|
499
558
|
verify: explicit.verify ?? [],
|
|
500
559
|
review,
|
|
501
560
|
stopRules: explicit.stopRules ?? [],
|
|
@@ -527,6 +586,9 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
|
|
|
527
586
|
"",
|
|
528
587
|
`Required evidence: ${acceptance.evidence.join(", ") || "none"}`,
|
|
529
588
|
];
|
|
589
|
+
if (acceptance.preserveStagedIndex) {
|
|
590
|
+
lines.push("The host will verify that the staged index is unchanged from launch; report noStagedFiles truthfully even when the preserved index is non-empty.");
|
|
591
|
+
}
|
|
530
592
|
if (acceptance.verify.length > 0) {
|
|
531
593
|
lines.push("", "Runtime verification commands configured by parent:");
|
|
532
594
|
for (const command of acceptance.verify) lines.push(`- ${command.id}: ${command.command}`);
|
|
@@ -1079,9 +1141,32 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
|
|
|
1079
1141
|
: { id: "no-staged-files", status: "failed", message: `Staged files present: ${staged.join(", ")}` };
|
|
1080
1142
|
}
|
|
1081
1143
|
|
|
1144
|
+
/** Capture the repository-wide index tree. Throws when Git cannot provide trustworthy evidence. */
|
|
1145
|
+
export function captureStagedIndexBaseline(cwd: string): string {
|
|
1146
|
+
const result = spawnSync("git", ["write-tree"], { cwd, encoding: "utf-8", windowsHide: true });
|
|
1147
|
+
const oid = result.stdout.trim();
|
|
1148
|
+
if (result.status !== 0 || !oid) {
|
|
1149
|
+
const detail = result.stderr.trim();
|
|
1150
|
+
throw new Error(`Unable to capture staged index baseline${detail ? `: ${detail}` : "."}`);
|
|
1151
|
+
}
|
|
1152
|
+
return oid;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function checkStagedIndexUnchanged(cwd: string, baseline: string): AcceptanceRuntimeCheck {
|
|
1156
|
+
try {
|
|
1157
|
+
const terminal = captureStagedIndexBaseline(cwd);
|
|
1158
|
+
return terminal === baseline
|
|
1159
|
+
? { id: "staged-index-unchanged", status: "passed", message: "Staged index matches the launch baseline." }
|
|
1160
|
+
: { id: "staged-index-unchanged", status: "failed", message: "Staged index changed after launch." };
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
return { id: "staged-index-unchanged", status: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1082
1166
|
function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
|
|
1083
1167
|
const checks: AcceptanceRuntimeCheck[] = [];
|
|
1084
1168
|
for (const kind of acceptance.evidence) {
|
|
1169
|
+
if (kind === "no-staged-files" && acceptance.preserveStagedIndex) continue;
|
|
1085
1170
|
if (kind === "no-staged-files" && report.noStagedFiles === undefined) continue;
|
|
1086
1171
|
const status = reportEvidenceStatus(report, kind);
|
|
1087
1172
|
checks.push({
|
|
@@ -1094,7 +1179,7 @@ function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: Accep
|
|
|
1094
1179
|
: `${kind} evidence missing from child report.`,
|
|
1095
1180
|
});
|
|
1096
1181
|
}
|
|
1097
|
-
if (acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
|
|
1182
|
+
if (!acceptance.preserveStagedIndex && acceptance.evidence.includes("no-staged-files")) checks.push(checkNoStagedFiles(cwd));
|
|
1098
1183
|
return checks;
|
|
1099
1184
|
}
|
|
1100
1185
|
|
|
@@ -1202,6 +1287,43 @@ function isCachedVerifyResult(value: unknown): value is AcceptanceVerifyResult {
|
|
|
1202
1287
|
&& typeof result.durationMs === "number";
|
|
1203
1288
|
}
|
|
1204
1289
|
|
|
1290
|
+
const TYPED_VERIFY_OUTPUT_MAX_BYTES = 12_000;
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* A passing `output: "json"` command must print one JSON document on stdout.
|
|
1294
|
+
* The parsed value becomes the run's structured output; anything else turns
|
|
1295
|
+
* the passing run into a failed one so acceptance rejects rather than
|
|
1296
|
+
* silently dropping the verdict. Stdout is already bounded by trimOutput, so a
|
|
1297
|
+
* truncated document is detected by its marker instead of a parse guess.
|
|
1298
|
+
*/
|
|
1299
|
+
async function applyTypedVerifyOutput(command: AcceptanceVerifyCommand, run: AcceptanceVerifyResult): Promise<void> {
|
|
1300
|
+
if (run.status !== "passed") return;
|
|
1301
|
+
const stdout = run.stdout ?? "";
|
|
1302
|
+
const fail = (message: string): void => {
|
|
1303
|
+
run.status = "failed";
|
|
1304
|
+
run.structuredOutputError = message;
|
|
1305
|
+
};
|
|
1306
|
+
if (!stdout.trim()) return fail("output: \"json\" command printed nothing on stdout.");
|
|
1307
|
+
if (stdout.endsWith("...[truncated]")) return fail(`output: "json" stdout exceeded ${TYPED_VERIFY_OUTPUT_MAX_BYTES} characters and was truncated.`);
|
|
1308
|
+
let value: unknown;
|
|
1309
|
+
try {
|
|
1310
|
+
value = JSON.parse(stdout);
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
return fail(`output: "json" stdout is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1313
|
+
}
|
|
1314
|
+
if (command.schema) {
|
|
1315
|
+
const validation = await validateStructuredOutputValue(command.schema, value);
|
|
1316
|
+
if (validation.status === "invalid") return fail(`output: "json" stdout does not match gate.schema: ${validation.message}`);
|
|
1317
|
+
}
|
|
1318
|
+
run.structuredOutput = value;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** The first typed verify result on a ledger, if any command produced one. */
|
|
1322
|
+
export function typedVerifyOutput(ledger: AcceptanceLedger | undefined): { value: unknown } | undefined {
|
|
1323
|
+
const run = ledger?.verifyRuns?.find((entry) => entry.structuredOutput !== undefined);
|
|
1324
|
+
return run ? { value: run.structuredOutput } : undefined;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1205
1327
|
async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaultCwd: string, options: {
|
|
1206
1328
|
signal?: AbortSignal;
|
|
1207
1329
|
abortMessage?: string;
|
|
@@ -1215,7 +1337,9 @@ async function runMemoizedVerifyCommand(command: AcceptanceVerifyCommand, defaul
|
|
|
1215
1337
|
} catch {
|
|
1216
1338
|
workspaceState = undefined;
|
|
1217
1339
|
}
|
|
1218
|
-
|
|
1340
|
+
// Typed gates read inputs (a report file, a log) that can change without
|
|
1341
|
+
// the tracked tree changing, so their verdicts are never reused.
|
|
1342
|
+
if (!workspaceState || !options.artifactsDir || !options.runId || command.output === "json") {
|
|
1219
1343
|
return runVerifyCommand(command, defaultCwd, options);
|
|
1220
1344
|
}
|
|
1221
1345
|
const envKeys = Object.keys(command.env ?? {}).sort();
|
|
@@ -1387,6 +1511,8 @@ export async function evaluateAcceptance(input: {
|
|
|
1387
1511
|
acceptance: ResolvedAcceptanceConfig;
|
|
1388
1512
|
output: string;
|
|
1389
1513
|
cwd: string;
|
|
1514
|
+
/** Host-captured launch index tree; required by preserveStagedIndex. */
|
|
1515
|
+
stagedIndexBaseline?: string;
|
|
1390
1516
|
/**
|
|
1391
1517
|
* Content the child sent to its configured output file (from its own write
|
|
1392
1518
|
* tool calls, not from disk, so a concurrent writer to the same path cannot
|
|
@@ -1482,30 +1608,36 @@ export async function evaluateAcceptance(input: {
|
|
|
1482
1608
|
ledger.runtimeChecks.push({ id: "verification-config", status: "failed", message: "verified acceptance requires runtime verify commands." });
|
|
1483
1609
|
ledger.status = "rejected";
|
|
1484
1610
|
ledger.evidenceStatus = "rejected";
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
ledger.status
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1611
|
+
} else {
|
|
1612
|
+
ledger.verifyRuns = [];
|
|
1613
|
+
for (const command of acceptance.verify) {
|
|
1614
|
+
const run = await runMemoizedVerifyCommand(command, input.cwd, {
|
|
1615
|
+
signal: input.signal,
|
|
1616
|
+
abortMessage: input.abortMessage,
|
|
1617
|
+
artifactsDir: input.artifactsDir,
|
|
1618
|
+
runId: input.runId,
|
|
1619
|
+
});
|
|
1620
|
+
if (command.output === "json") await applyTypedVerifyOutput(command, run);
|
|
1621
|
+
ledger.verifyRuns.push(run);
|
|
1622
|
+
if (input.signal?.aborted) break;
|
|
1623
|
+
}
|
|
1624
|
+
if (ledger.verifyRuns.some((run) => run.status === "failed" || run.status === "timed-out")) {
|
|
1625
|
+
ledger.status = "rejected";
|
|
1626
|
+
ledger.evidenceStatus = "rejected";
|
|
1627
|
+
} else if (!ledger.runtimeChecks.some((check) => check.status === "failed")) {
|
|
1628
|
+
ledger.status = "verified";
|
|
1629
|
+
ledger.evidenceStatus = "verified";
|
|
1630
|
+
}
|
|
1505
1631
|
}
|
|
1506
1632
|
}
|
|
1507
1633
|
|
|
1508
|
-
if (
|
|
1634
|
+
if (acceptance.preserveStagedIndex) {
|
|
1635
|
+
ledger.runtimeChecks.push(input.stagedIndexBaseline
|
|
1636
|
+
? checkStagedIndexUnchanged(input.cwd, input.stagedIndexBaseline)
|
|
1637
|
+
: { id: "staged-index-unchanged", status: "failed", message: "Staged index launch baseline is unavailable." });
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
if (ledger.status === "rejected" || ledger.runtimeChecks.some((check) => check.status === "failed")) {
|
|
1509
1641
|
ledger.status = "rejected";
|
|
1510
1642
|
ledger.evidenceStatus = "rejected";
|
|
1511
1643
|
return ledger;
|
|
@@ -1559,6 +1691,7 @@ export function acceptanceFailureMessage(ledger: AcceptanceLedger): string | und
|
|
|
1559
1691
|
const failedCheck = ledger.runtimeChecks.find((check) => check.status === "failed");
|
|
1560
1692
|
if (failedCheck) return `Acceptance rejected: ${failedCheck.message}`;
|
|
1561
1693
|
const failedVerify = ledger.verifyRuns.find((run) => run.status === "failed" || run.status === "timed-out");
|
|
1694
|
+
if (failedVerify?.structuredOutputError) return `Acceptance verification '${failedVerify.id}' failed: ${failedVerify.structuredOutputError}`;
|
|
1562
1695
|
if (failedVerify) return `Acceptance verification '${failedVerify.id}' ${failedVerify.status}.`;
|
|
1563
1696
|
if (ledger.reviewResult?.status === "blockers") return "Acceptance review found blockers.";
|
|
1564
1697
|
return "Acceptance rejected.";
|
|
@@ -743,13 +743,35 @@ export function resolveExpectedWorktreeAgentCwd(cwd: string, runId: string, inde
|
|
|
743
743
|
function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boolean {
|
|
744
744
|
const nodeModulesPath = path.join(toplevel, "node_modules");
|
|
745
745
|
const nodeModulesLinkPath = path.join(worktreePath, "node_modules");
|
|
746
|
-
|
|
746
|
+
const hasDirectoryEntry = (candidate: string): boolean => {
|
|
747
|
+
try { fs.lstatSync(candidate); return true; }
|
|
748
|
+
catch (error) {
|
|
749
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
747
753
|
try {
|
|
748
|
-
|
|
754
|
+
if (hasDirectoryEntry(nodeModulesLinkPath)) return false;
|
|
755
|
+
let sourceRealPath: string;
|
|
756
|
+
try {
|
|
757
|
+
if (!fs.statSync(nodeModulesPath).isDirectory()) throw new Error("source node_modules is not a directory");
|
|
758
|
+
sourceRealPath = fs.realpathSync.native(nodeModulesPath);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
761
|
+
throw error;
|
|
762
|
+
}
|
|
763
|
+
fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath, process.platform === "win32" ? "junction" : "dir");
|
|
764
|
+
if (!fs.lstatSync(nodeModulesLinkPath).isSymbolicLink()
|
|
765
|
+
|| fs.realpathSync.native(nodeModulesLinkPath) !== sourceRealPath) {
|
|
766
|
+
throw new Error("created link does not resolve to the source node_modules");
|
|
767
|
+
}
|
|
749
768
|
return true;
|
|
750
|
-
} catch {
|
|
751
|
-
|
|
752
|
-
|
|
769
|
+
} catch (error) {
|
|
770
|
+
const code = error instanceof Error && "code" in error && typeof error.code === "string" ? `${error.code}: ` : "";
|
|
771
|
+
throw new Error(
|
|
772
|
+
`failed to link node_modules from ${nodeModulesPath} to ${nodeModulesLinkPath}: ${code}${error instanceof Error ? error.message : String(error)}`,
|
|
773
|
+
{ cause: error },
|
|
774
|
+
);
|
|
753
775
|
}
|
|
754
776
|
}
|
|
755
777
|
|
package/src/shared/types.ts
CHANGED
|
@@ -1028,6 +1028,10 @@ export interface AcceptanceVerifyCommand {
|
|
|
1028
1028
|
cwd?: string;
|
|
1029
1029
|
env?: Record<string, string>;
|
|
1030
1030
|
allowFailure?: boolean;
|
|
1031
|
+
/** When "json", a passing command's stdout is parsed and becomes the run's structured output. */
|
|
1032
|
+
output?: "json";
|
|
1033
|
+
/** Optional JSON Schema the parsed stdout must satisfy; only meaningful with `output: "json"`. */
|
|
1034
|
+
schema?: JsonSchemaObject;
|
|
1031
1035
|
}
|
|
1032
1036
|
|
|
1033
1037
|
export interface AcceptanceReviewGate {
|
|
@@ -1039,6 +1043,8 @@ export interface AcceptanceReviewGate {
|
|
|
1039
1043
|
export interface AcceptanceConfig {
|
|
1040
1044
|
level?: AcceptanceLevel;
|
|
1041
1045
|
report?: "on" | "off";
|
|
1046
|
+
/** Preserve an intentional launch-time staged index, while rejecting any terminal index change. */
|
|
1047
|
+
preserveStagedIndex?: true;
|
|
1042
1048
|
criteria?: Array<string | AcceptanceGate>;
|
|
1043
1049
|
evidence?: AcceptanceEvidenceKind[];
|
|
1044
1050
|
verify?: AcceptanceVerifyCommand[];
|
|
@@ -1063,6 +1069,7 @@ export interface ResolvedAcceptanceConfig {
|
|
|
1063
1069
|
inferredReason: string[];
|
|
1064
1070
|
criteria: ResolvedAcceptanceGate[];
|
|
1065
1071
|
evidence: AcceptanceEvidenceKind[];
|
|
1072
|
+
preserveStagedIndex?: true;
|
|
1066
1073
|
verify: AcceptanceVerifyCommand[];
|
|
1067
1074
|
review?: AcceptanceReviewGate | false;
|
|
1068
1075
|
stopRules: string[];
|
|
@@ -1121,6 +1128,10 @@ export interface AcceptanceVerifyResult {
|
|
|
1121
1128
|
diffHash: string;
|
|
1122
1129
|
};
|
|
1123
1130
|
artifactError?: string;
|
|
1131
|
+
/** Parsed stdout of a passing `output: "json"` command. */
|
|
1132
|
+
structuredOutput?: unknown;
|
|
1133
|
+
/** Why a passing `output: "json"` command still failed: invalid JSON, truncated stdout, or schema mismatch. */
|
|
1134
|
+
structuredOutputError?: string;
|
|
1124
1135
|
}
|
|
1125
1136
|
|
|
1126
1137
|
export interface AcceptanceReviewResult {
|
|
@@ -5,7 +5,7 @@ import { Worker } from "node:worker_threads";
|
|
|
5
5
|
import { DEFAULT_GLOBAL_CONCURRENCY_LIMIT, Semaphore } from "../runs/shared/parallel-utils.ts";
|
|
6
6
|
import { HOST_STEP_MAX_COUNT } from "../runs/shared/host-step-status.ts";
|
|
7
7
|
import { classifyTaskMutationIntent } from "../runs/shared/task-intent.ts";
|
|
8
|
-
import { describeGateAcceptanceConflict } from "../runs/shared/acceptance.ts";
|
|
8
|
+
import { describeGateAcceptanceConflict, parseGateInput } from "../runs/shared/acceptance.ts";
|
|
9
9
|
import type { AcceptanceRecoveryMetadata, HostStepNode, SingleResult } from "../shared/types.ts";
|
|
10
10
|
import { normalizeWorkflowHostCommandParams, type WorkflowHostCommandParams, type WorkflowHostCommandResult } from "./host-command.ts";
|
|
11
11
|
|
|
@@ -633,6 +633,24 @@ function validateLaneMetadata(value, label, workflowKey) {
|
|
|
633
633
|
}
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
+
// Mirrors parseGateInput in src/runs/shared/acceptance.ts; the sandbox cannot import it.
|
|
637
|
+
function describeGateShapeError(gate) {
|
|
638
|
+
const shape = "gate must be a non-empty command string or { command, output?: \"json\", schema?, timeoutMs? }.";
|
|
639
|
+
if (typeof gate === "string") return gate.trim() ? undefined : shape;
|
|
640
|
+
if (!gate || typeof gate !== "object" || Array.isArray(gate)) return shape;
|
|
641
|
+
for (const key of Object.keys(gate)) {
|
|
642
|
+
if (!["command", "output", "schema", "timeoutMs"].includes(key)) return "gate." + key + " is not supported.";
|
|
643
|
+
}
|
|
644
|
+
if (typeof gate.command !== "string" || !gate.command.trim()) return shape;
|
|
645
|
+
if (gate.output !== undefined && gate.output !== "json") return "gate.output must be \"json\" when present.";
|
|
646
|
+
if (gate.schema !== undefined) {
|
|
647
|
+
if (gate.output !== "json") return "gate.schema requires gate.output: \"json\".";
|
|
648
|
+
if (!gate.schema || typeof gate.schema !== "object" || Array.isArray(gate.schema)) return "gate.schema must be a JSON Schema object.";
|
|
649
|
+
}
|
|
650
|
+
if (gate.timeoutMs !== undefined && (!Number.isInteger(gate.timeoutMs) || gate.timeoutMs < 1)) return "gate.timeoutMs must be an integer >= 1.";
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
636
654
|
function describeGateAcceptanceConflict(gate, acceptance) {
|
|
637
655
|
const render = (value) => {
|
|
638
656
|
let encoded;
|
|
@@ -658,7 +676,10 @@ function validateRunCall(key, params, label, fingerprints) {
|
|
|
658
676
|
if (params.worktree !== undefined && typeof params.worktree !== "boolean") throw new Error(label + " worktree must be true or false.");
|
|
659
677
|
if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) throw new Error(label + " baseRef must be a valid Git ref: use HEAD or a supported named ref (for example, refs/heads/main). Full 40/64-character commit IDs and revision expressions are unsupported.");
|
|
660
678
|
validateLaneMetadata(params.lane, label + " lane", key);
|
|
661
|
-
if (params.gate !== undefined
|
|
679
|
+
if (params.gate !== undefined) {
|
|
680
|
+
const gateError = describeGateShapeError(params.gate);
|
|
681
|
+
if (gateError) throw new Error(label + " " + gateError);
|
|
682
|
+
}
|
|
662
683
|
if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) throw new Error(label + " gate cannot be combined with acceptance; use one gate command or acceptance.verify." + describeGateAcceptanceConflict(params.gate, params.acceptance));
|
|
663
684
|
if (params.gate !== undefined && params.resume !== undefined) throw new Error(label + " gate is not supported with retained resume.");
|
|
664
685
|
if (params.extensionBindings !== undefined && params.resume !== undefined) throw new Error(label + " extensionBindings is not supported with retained resume; resume uses the original retained child binding.");
|
|
@@ -2418,8 +2439,9 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
|
|
|
2418
2439
|
if (params.baseRef !== undefined && (typeof params.baseRef !== "string" || !validGitRef(params.baseRef))) {
|
|
2419
2440
|
return respond(Promise.reject(new Error(`runs.run('${key}') ${BASE_REF_VALIDATION_ERROR}`)));
|
|
2420
2441
|
}
|
|
2421
|
-
if (params.gate !== undefined
|
|
2422
|
-
|
|
2442
|
+
if (params.gate !== undefined) {
|
|
2443
|
+
const parsedGate = parseGateInput(params.gate);
|
|
2444
|
+
if (!parsedGate.ok) return respond(Promise.reject(new Error(`runs.run('${key}') ${parsedGate.error}`)));
|
|
2423
2445
|
}
|
|
2424
2446
|
if (params.gate !== undefined && params.acceptance !== undefined && params.acceptance !== false) {
|
|
2425
2447
|
return respond(Promise.reject(new Error(`runs.run('${key}') gate cannot be combined with acceptance; use one gate command or acceptance.verify.` + describeGateAcceptanceConflict(params.gate, params.acceptance))));
|