@selesai/code 0.13.31 → 0.13.33
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 -0
- package/dist/core/remote-catalog-provider.js +5 -1
- package/dist/core/remote-catalog-provider.test.d.ts +1 -0
- package/dist/core/remote-catalog-provider.test.js +29 -0
- package/dist/extensions/capability-gateway/index.ts +9 -4
- package/dist/extensions/capability-gateway/integration.test.ts +69 -3
- package/dist/extensions/pi-subagents/agents/worker.md +3 -2
- package/dist/extensions/pi-subagents/docs/agents.md +2 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
- package/dist/extensions/pi-subagents/docs/observability.md +2 -0
- package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
- package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
- package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
- package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
- package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
- package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
- package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
- package/dist/extensions/pi-web-agent/src/extension.ts +2 -1
- package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@selesai/code` will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.13.33] - 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Subagent cost is exposed through extension RPC.** `cost` joins the versioned `subagents:rpc:v1` method list and returns the same parent-plus-child accounting `/subagent-cost` renders; `ping` advertises the report version. It is read-only, but it walks the current session branch and existing run artifacts, so callers should request it on turn boundaries rather than on a timer.
|
|
9
|
+
- **Async workflow status reports terminal proof.** `subagent({ action: "status" })` details now include `workflowTerminalProof`, the shared child-exit evidence for workflow runs.
|
|
10
|
+
- **Native children can use the scoped capability gateway.** A child launch loads the gateway when extension policy permits it, exposing `capability_catalog`, `capability_discover`, and `capability_skill_show` for tools in that child's effective registry. The gateway never installs or provides a missing tool provider, and `SELESAI_CAPABILITY_GATEWAY=0` still disables it.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **The packaged `worker` agent starts from fresh context.** `worker` now declares `defaultContext: fresh` and `acceptanceRole: writer`; an explicit `context: "fork"` still wins.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Capability gateway controls no longer count as mutation capability.** A read-only child that receives `capability_catalog`, `capability_discover`, and `capability_skill_show` is still rejected for an implementation task with no mutation-capable tools, so the gateway does not disarm the implementation guard.
|
|
17
|
+
- **Structured output rejections keep their evidence.** Foreground and background runs now report the bounded rejection diagnostic — schema and validator failures summarized, submitted values redacted — instead of a generic missing-call error, and background results mark `structuredOutputFailed`.
|
|
18
|
+
- **`subagent_supervisor` requires fanout authorization.** A child that declares the supervisor reply tool without `subagent` in its effective tools allowlist (or `allowNestedSubagents`) now fails the launch instead of silently receiving the tool.
|
|
19
|
+
- **Git routing environment is stripped from child processes.** Background runners and external CLI default launches no longer inherit `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and Git's other local environment variables, so a child's Git commands stay in its own working directory.
|
|
20
|
+
|
|
21
|
+
## [0.13.32] - 2026-09-24
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Cached provider model catalogs are no longer discarded when `Last-Modified` is missing.** Models such as `openai-codex/gpt-6-sol` and `openai-codex/gpt-6-luna` remain available after a catalog refresh when the cached catalog is newer than the bundled metadata.
|
|
25
|
+
|
|
5
26
|
## [0.13.31] - 2026-09-23
|
|
6
27
|
|
|
7
28
|
### Changed
|
|
@@ -30,7 +30,11 @@ function parseCatalog(providerId, value) {
|
|
|
30
30
|
function remoteModels(entry, localGeneratedAt) {
|
|
31
31
|
if (!entry)
|
|
32
32
|
return [];
|
|
33
|
-
|
|
33
|
+
// Some catalog responses (and the 404/501 fallback) have no usable
|
|
34
|
+
// Last-Modified value. Use the completed check time instead so a cached
|
|
35
|
+
// catalog is not discarded merely because that header was absent.
|
|
36
|
+
const remoteGeneratedAt = entry.lastModified && entry.lastModified > 0 ? entry.lastModified : entry.checkedAt;
|
|
37
|
+
if (localGeneratedAt !== undefined && (remoteGeneratedAt === undefined || remoteGeneratedAt <= localGeneratedAt)) {
|
|
34
38
|
return [];
|
|
35
39
|
}
|
|
36
40
|
return entry.models;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { withRemoteCatalog } from "./remote-catalog-provider.js";
|
|
3
|
+
const cachedModel = {
|
|
4
|
+
id: "gpt-6-sol",
|
|
5
|
+
provider: "openai-codex",
|
|
6
|
+
};
|
|
7
|
+
function refreshContext(stored) {
|
|
8
|
+
return {
|
|
9
|
+
stored,
|
|
10
|
+
allowNetwork: false,
|
|
11
|
+
signal: new AbortController().signal,
|
|
12
|
+
publish: async ({ update }) => {
|
|
13
|
+
update?.();
|
|
14
|
+
return true;
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe("remote model catalog cache", () => {
|
|
19
|
+
it("keeps a newer cached catalog when Last-Modified is unavailable", async () => {
|
|
20
|
+
const localGeneratedAt = 100;
|
|
21
|
+
const provider = {
|
|
22
|
+
id: "openai-codex",
|
|
23
|
+
getModels: () => [],
|
|
24
|
+
};
|
|
25
|
+
const wrapped = withRemoteCatalog(provider, "https://example.test", localGeneratedAt);
|
|
26
|
+
await wrapped.refreshModels?.(refreshContext({ models: [cachedModel], checkedAt: localGeneratedAt + 1, lastModified: 0 }));
|
|
27
|
+
expect(wrapped.getModels().map((model) => model.id)).toEqual(["gpt-6-sol"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -94,14 +94,19 @@ function catalogEntries(pi: ExtensionAPI): CatalogEntry[] {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const EMBEDDED_SKILL_BLOCK = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?<\/skill>/gi;
|
|
97
|
+
const GITHUB_OR_OPEN_SOURCE_QUERY = /\b(?:github|open[\s-]*source)\b/i;
|
|
97
98
|
|
|
98
99
|
function routePrompt(prompt: string, entries: CatalogEntry[]): ReturnType<typeof route> {
|
|
99
100
|
const loadedSkills = new Set([...prompt.matchAll(EMBEDDED_SKILL_BLOCK)].map((match) => match[1]!.toLowerCase()));
|
|
100
101
|
const query = prompt.replace(EMBEDDED_SKILL_BLOCK, " ");
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const candidates = entries.filter((entry) => entry.kind !== "skill" || !loadedSkills.has(entry.name.toLowerCase()));
|
|
103
|
+
if (GITHUB_OR_OPEN_SOURCE_QUERY.test(query)) {
|
|
104
|
+
const grepAppSearch = candidates.find(
|
|
105
|
+
(entry) => entry.kind === "tool" && entry.eligible && entry.name === "grep_app_search",
|
|
106
|
+
);
|
|
107
|
+
if (grepAppSearch) return { action: "activate", entry: grepAppSearch };
|
|
108
|
+
}
|
|
109
|
+
return route(query, candidates);
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
function formatCatalog(entries: CatalogEntry[]): string {
|
|
@@ -19,6 +19,7 @@ import { allowNetwork } from "../../../test/test-network-env.ts";
|
|
|
19
19
|
const EXTENSIONS_DIR = fileURLToPath(new URL("../../", import.meta.url));
|
|
20
20
|
const GATEWAY_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
21
21
|
const GREP_APP_DIR = fileURLToPath(new URL("../grep-app", import.meta.url));
|
|
22
|
+
const WEB_AGENT_DIR = fileURLToPath(new URL("../pi-web-agent", import.meta.url));
|
|
22
23
|
const GRAFT_DIR = fileURLToPath(new URL("../pi-graft", import.meta.url));
|
|
23
24
|
const INLINE_SKILLS_FILE = fileURLToPath(new URL("../inline-skills.ts", import.meta.url));
|
|
24
25
|
|
|
@@ -37,6 +38,8 @@ interface HarnessOptions {
|
|
|
37
38
|
jev?: Record<string, unknown>;
|
|
38
39
|
/** Make the gateway's telemetry channel throw on every emit. */
|
|
39
40
|
telemetryDown?: boolean;
|
|
41
|
+
/** SDK-level child tool allowlist. */
|
|
42
|
+
tools?: string[];
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
/** A bus that fails only on the gateway's own telemetry channel. */
|
|
@@ -129,6 +132,7 @@ async function createGatewaySession(options: HarnessOptions): Promise<Harness> {
|
|
|
129
132
|
resourceLoader: loader,
|
|
130
133
|
sessionManager: SessionManager.create(cwd, join(home, "sessions")),
|
|
131
134
|
settingsManager,
|
|
135
|
+
...(options.tools !== undefined ? { tools: options.tools } : {}),
|
|
132
136
|
});
|
|
133
137
|
const session = created.session;
|
|
134
138
|
await session.bindExtensions({});
|
|
@@ -243,6 +247,39 @@ describe("capability gateway integration", () => {
|
|
|
243
247
|
expect(draw({})).toContain("(all)");
|
|
244
248
|
});
|
|
245
249
|
|
|
250
|
+
it("limits child discovery and activation to the SDK-filtered tool registry", async () => {
|
|
251
|
+
const h = await createGatewaySession({
|
|
252
|
+
enabled: true,
|
|
253
|
+
extensions: [GATEWAY_DIR, GREP_APP_DIR],
|
|
254
|
+
tools: ["capability_catalog", "capability_discover", "grep_app_search"],
|
|
255
|
+
});
|
|
256
|
+
harnesses.push(h);
|
|
257
|
+
const registeredNames = h.session.getAllTools().map((tool) => tool.name);
|
|
258
|
+
expect(registeredNames).toContain("grep_app_search");
|
|
259
|
+
expect(registeredNames).not.toContain("grep_app_fetch");
|
|
260
|
+
|
|
261
|
+
const catalog = h.session.getToolDefinition("capability_catalog");
|
|
262
|
+
const catalogResult = await catalog!.execute("call-catalog", { query: "grep_app" }, undefined, undefined, {} as never);
|
|
263
|
+
expect(String(catalogResult.content[0]!.text)).toContain("grep_app_search");
|
|
264
|
+
expect(String(catalogResult.content[0]!.text)).not.toContain("grep_app_fetch");
|
|
265
|
+
|
|
266
|
+
const discover = h.session.getToolDefinition("capability_discover");
|
|
267
|
+
const allowed = await discover!.execute("call-allowed", { name: "grep_app_search" }, undefined, undefined, {} as never);
|
|
268
|
+
expect(String(allowed.content[0]!.text)).toContain("Activated");
|
|
269
|
+
expect(h.session.getActiveToolNames()).toContain("grep_app_search");
|
|
270
|
+
|
|
271
|
+
const denied = await discover!.execute("call-denied", { name: "grep_app_fetch" }, undefined, undefined, {} as never);
|
|
272
|
+
expect(String(denied.content[0]!.text)).not.toContain("Activated");
|
|
273
|
+
expect(h.session.getActiveToolNames()).not.toContain("grep_app_fetch");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("exposes no gateway controls to an explicitly empty child tool allowlist", async () => {
|
|
277
|
+
const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR], tools: [] });
|
|
278
|
+
harnesses.push(h);
|
|
279
|
+
expect(h.session.getAllTools().map((tool) => tool.name)).not.toContain("capability_catalog");
|
|
280
|
+
expect(h.session.getActiveToolNames()).not.toContain("capability_discover");
|
|
281
|
+
});
|
|
282
|
+
|
|
246
283
|
it("activates a discovered tool for the run and resets after agent_settled", async () => {
|
|
247
284
|
const h = await createGatewaySession({ enabled: true });
|
|
248
285
|
harnesses.push(h);
|
|
@@ -337,9 +374,9 @@ describe("capability gateway integration", () => {
|
|
|
337
374
|
// ---------------------------------------------------------------------------
|
|
338
375
|
|
|
339
376
|
describe("capability gateway Jev routing", () => {
|
|
340
|
-
// "
|
|
341
|
-
// two-candidate hint, which is the only Jev trigger.
|
|
342
|
-
const HINT_PROMPT = "
|
|
377
|
+
// "search fetch" weakly suggests both grep-app tools: the deterministic router returns a
|
|
378
|
+
// two-candidate hint, which is the only Jev trigger. GitHub/open-source mentions route directly.
|
|
379
|
+
const HINT_PROMPT = "search fetch";
|
|
343
380
|
// No tool name, alias, or summary token matches: the router has no lexical signal and Jev
|
|
344
381
|
// must never be consulted.
|
|
345
382
|
const NO_SIGNAL_PROMPT = "continue where we left off last time";
|
|
@@ -393,6 +430,35 @@ describe("capability gateway Jev routing", () => {
|
|
|
393
430
|
return h.telemetry.filter((event) => event.event === "route");
|
|
394
431
|
}
|
|
395
432
|
|
|
433
|
+
it("routes only GitHub/open-source mentions to grep.app; other web searches stay on web_explore", async () => {
|
|
434
|
+
allowNetwork();
|
|
435
|
+
const { fetchMock } = stubJev("none", 1);
|
|
436
|
+
const h = await createGatewaySession({
|
|
437
|
+
enabled: true,
|
|
438
|
+
extensions: [GATEWAY_DIR, GREP_APP_DIR, WEB_AGENT_DIR],
|
|
439
|
+
jev: jevSettings(),
|
|
440
|
+
});
|
|
441
|
+
harnesses.push(h);
|
|
442
|
+
|
|
443
|
+
for (const prompt of [
|
|
444
|
+
"search GitHub code",
|
|
445
|
+
"find open-source implementations",
|
|
446
|
+
"find open source implementations",
|
|
447
|
+
"find opensource examples",
|
|
448
|
+
]) {
|
|
449
|
+
await route(h, prompt);
|
|
450
|
+
expect(h.session.getActiveToolNames()).toContain("grep_app_search");
|
|
451
|
+
expect(h.session.getActiveToolNames()).not.toContain("web_explore");
|
|
452
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
453
|
+
await h.session.extensionRunner.emit({ type: "agent_settled" });
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await route(h, "web search for release notes");
|
|
457
|
+
expect(h.session.getActiveToolNames()).toContain("web_explore");
|
|
458
|
+
expect(h.session.getActiveToolNames()).not.toContain("grep_app_search");
|
|
459
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
460
|
+
});
|
|
461
|
+
|
|
396
462
|
// Telemetry may carry route metadata and canonical tool names, never prompt
|
|
397
463
|
// text, conversation turns, credentials, raw Jev output, or tool arguments.
|
|
398
464
|
const TELEMETRY_KEYS = new Set([
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
name: worker
|
|
3
3
|
description: Implementation agent for normal tasks and approved oracle handoffs
|
|
4
4
|
aliases: developer, coder, implementer, develop
|
|
5
|
+
acceptanceRole: writer
|
|
5
6
|
thinking: high
|
|
6
7
|
systemPromptMode: replace
|
|
7
8
|
inheritProjectContext: true
|
|
8
9
|
inheritSkills: false
|
|
9
10
|
tools: read, grep, find, ls, bash, edit, write, contact_supervisor
|
|
10
|
-
defaultContext:
|
|
11
|
+
defaultContext: fresh
|
|
11
12
|
output: implementation.md
|
|
12
13
|
defaultReads: context.md, research.md, plan.md, implementation.md, review.md
|
|
13
14
|
defaultProgress: true
|
|
@@ -17,7 +18,7 @@ You are `worker`: the implementation subagent.
|
|
|
17
18
|
|
|
18
19
|
You are the single writer thread. Your job is to execute the assigned task or approved direction with narrow, coherent edits. The main agent and user remain the decision authority.
|
|
19
20
|
|
|
20
|
-
Use the provided tools directly. First read the
|
|
21
|
+
Use the provided tools directly. First read the provided context, supplied files, plan, task paths, and named seams. Then implement carefully and minimally. Use broad search only to verify or expand from that starting point.
|
|
21
22
|
|
|
22
23
|
The builtin worker uses a strict tool allowlist. It does not inherit ambient extension tools from the parent session. To use an extension tool, configure a custom agent with the tool name explicitly listed in `tools` and load its provider through `extensions` or `subagentOnlyExtensions`.
|
|
23
24
|
|
|
@@ -198,6 +198,8 @@ fallbackModels:
|
|
|
198
198
|
|
|
199
199
|
Field notes:
|
|
200
200
|
|
|
201
|
+
Native children expose the capability gateway only when extension policy permits it. The gateway catalogs and activates only tools visible in that child's effective registry; it does not install or provide a missing tool provider.
|
|
202
|
+
|
|
201
203
|
| Field | Notes |
|
|
202
204
|
|-------|-------|
|
|
203
205
|
| `package` | Optional package identifier. A file with `name: scout` and `package: code-analysis` registers as `code-analysis.scout`; serialization keeps `name` and `package` separate. |
|
|
@@ -114,7 +114,7 @@ pi.events.emit("subagents:rpc:v1:request", {
|
|
|
114
114
|
});
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
The RPC methods are `ping`, `status`, `manage`, `spawn`, `steer`, `interrupt`, `stop`, and `
|
|
117
|
+
The RPC methods are `ping`, `status`, `manage`, `spawn`, `steer`, `interrupt`, `stop`, `resume`, and `cost`. `status`, `manage`, `steer`, `interrupt`, and `resume` reuse normal package-owned actions.
|
|
118
118
|
|
|
119
119
|
Method notes:
|
|
120
120
|
|
|
@@ -124,6 +124,7 @@ Method notes:
|
|
|
124
124
|
- `resume` requires a run target and non-empty `message`. It delegates to the existing revival path, which validates current-session ownership, persisted session/recovery metadata, stopped/live state, capability ceilings, and the exclusive session lease before returning the new async run details. Callers may request a `file-only` output path for the revived result without overriding its model, tools, or budgets. `ping.capabilities.resume` advertises this seam.
|
|
125
125
|
- `stop` targets current-session top-level async runs through the stop control channel and records a `stopped` lifecycle instead of reporting a timeout.
|
|
126
126
|
- `status` keeps targeted and rich requests on the executor-backed path. A request with no `id`, `runId`, `dir`, `index`, `view`, or `lines` may use the restored in-memory projections and a short summary; when the live state is missing, stale, session-mismatched, or not restored, it falls back to normal executor status. Status `view`, `lines`, and `index` are forwarded for targeted transcript/fleet requests. Successful replies retain `text`, `details`, `fleet`, and `asyncSnapshot`; the short summary intentionally omits canonical filesystem details, wait subscriptions, and budget annotations.
|
|
127
|
+
- `cost` returns the same parent-plus-child accounting `/subagent-cost` renders, as `{ version: 1, parent, children, childTotal, total, unresolvedAsyncChildren }`. Usage objects contain `input`, `output`, `cacheRead`, `cacheWrite`, `cost`, and `turns`; child rows add `label` plus `agent`, `runId`, or `sessionFile` when known. It is read-only and walks the current session branch and existing artifacts, so request it at a turn boundary rather than on a timer. A non-zero `unresolvedAsyncChildren` means `childTotal` is a lower bound. `ping.capabilities.cost` advertises `{ version: 1 }`.
|
|
127
128
|
|
|
128
129
|
Capability advertisements on `ping`:
|
|
129
130
|
|
|
@@ -136,6 +137,7 @@ Capability advertisements on `ping`:
|
|
|
136
137
|
- `resume` — the revival seam described above.
|
|
137
138
|
- `statusProjection: { version: 1, untargeted: "in-memory-when-ready", targeted: "executor" }` — untargeted status may use restored bounded projections; targeted or rich status remains executor-backed.
|
|
138
139
|
- `fleetStatus: { version: 1 }` — successful `status` replies additionally include `data.fleet`.
|
|
140
|
+
- `cost: { version: 1 }` — the `cost` method is available with the report shape described above.
|
|
139
141
|
|
|
140
142
|
Structured delegation progress updates carry `runId` as soon as foreground execution allocates it, so a caller can retain the package-owned revival target even if its own tool turn is interrupted before the terminal response. Foreground `details.results[]` rows also include a numeric `index` that is unique within the run and stable across partial progress snapshots and the final result; use `(runId, index)` instead of row position to correlate single, counted parallel, and chain children.
|
|
141
143
|
|
|
@@ -27,6 +27,8 @@ subagent({ action: "status", id: "..." }) // one run
|
|
|
27
27
|
|
|
28
28
|
Or ask naturally: "Show me the current async runs."
|
|
29
29
|
|
|
30
|
+
Use `/subagent-cost` for combined parent-plus-child usage. Other extensions can request the same versioned data through the in-process RPC `cost` method instead of scraping slash-command text; it is read-only and should be called at a turn boundary, not polled. `unresolvedAsyncChildren` counts children whose usage metadata could not be read, so a non-zero count means the child total is a lower bound. See [extension-api.md](extension-api.md#in-process-event-bus-rpc).
|
|
31
|
+
|
|
30
32
|
The under-editor async widget gives a short view while work runs. Its expand key follows your Pi keybinding:
|
|
31
33
|
|
|
32
34
|
```text
|
|
@@ -90,7 +90,7 @@ The complete plain-JSON inventory is validated before the first launch (maximum
|
|
|
90
90
|
| `action` | string | - | Offline workflow `validate`, agent management (including `guide`, `children.list`, and `refine`/`refine.show`/`refine.rollback`), lane evidence (`lane.status`, `lane.recordMerge`, `lane.recordSupersession`), mission (`mission.create/list/show/update/resolve-decision/attach-run/close`), Herdr inspector (`inspector.open/status/close`), Herdr project pane (`project.open/status/close`), status/control, plan-only `worktree.cleanup`, schedule, watchdog, or doctor action. |
|
|
91
91
|
| `topic` | `overview \| workflows \| agents \| missions \| observability \| tool-reference \| configuration \| models \| watchdog \| extension-api` | `overview` | Packaged guide topic for `action: "guide"`. |
|
|
92
92
|
| `config` | object/string | - | Agent config for management create/update. |
|
|
93
|
-
| `context` | `fresh \| fork` | global or per-agent default, else `fresh` | Explicit `fresh` or `fork` overrides every workflow child. When omitted, [`defaultSubagentContext`](configuration.md#defaultsubagentcontext) wins over each agent's `defaultContext`; `"fork"` creates a real branched session when the parent session file and current leaf exist, otherwise it falls back to `fresh`. Packaged `worker
|
|
93
|
+
| `context` | `fresh \| fork` | global or per-agent default, else `fresh` | Explicit `fresh` or `fork` overrides every workflow child. When omitted, [`defaultSubagentContext`](configuration.md#defaultsubagentcontext) wins over each agent's `defaultContext`; `"fork"` creates a real branched session when the parent session file and current leaf exist, otherwise it falls back to `fresh`. Packaged `worker` defaults to `fresh`; `oracle` and `advisor` default to `fork`. |
|
|
94
94
|
| `missionId` | string | - | Attach a workflow to an existing project mission instead of creating its default enclosing mission. |
|
|
95
95
|
| `mission` | object/false | auto-create | Override the default enclosing mission with `{ title \| summary, objective?, goal?, budget?, labels? }`. Set exactly one non-empty `title` or `summary`; `objective` and `labels` are optional. `goal` may only be `true`, requires `budget.tokens`, and enables continuation notices. Pass `false` for an intentionally ephemeral workflow with no mission for it or its children and no `state` global. Explicit mission persistence failures are strict. |
|
|
96
96
|
| `handoffPath` | string | - | Aggregate handoff manifest for `action: "worktree.discard"` or lane evidence actions, or optional explicit metadata for `action: "worktree.cleanup"`. |
|
|
@@ -134,7 +134,7 @@ Explicit `context: "fork"` fails fast when the parent session is not persisted,
|
|
|
134
134
|
|
|
135
135
|
When the inherited transcript contains signed Anthropic `thinking` / `redacted_thinking` blocks, `pi-subagents` strips those provider-private blocks from the forked child session. It forces thinking `off` only when the child's effective primary or fallback model resolves through the model registry to the Anthropic provider or `anthropic-messages` API; unresolved models are treated conservatively. The result reports every affected child, including on failed runs. Use `context: "fresh"` when an Anthropic child needs thinking. Explicit `context: "fork"` never silently downgrades to `fresh`.
|
|
136
136
|
|
|
137
|
-
In workflow runs that omit `context`, each `runs.run` child follows the global `defaultSubagentContext` when set, then its own `defaultContext`. Without the global setting, a fresh-default
|
|
137
|
+
In workflow runs that omit `context`, each `runs.run` child follows the global `defaultSubagentContext` when set, then its own `defaultContext`. Without the global setting, a fresh-default worker can run fresh beside a fork-default oracle. If the parent session file or current leaf is not available yet, implicit fork-default children run fresh. Pass explicit `context: "fork"` or `context: "fresh"` when you intentionally want one context for every child.
|
|
138
138
|
|
|
139
139
|
### Workflow steering
|
|
140
140
|
|
|
@@ -10,7 +10,7 @@ Use orchestration as parent-agent guidance, not as a runtime workflow mode. For
|
|
|
10
10
|
clarify → scout → worker → fresh reviewers → worker
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Packaged `worker
|
|
13
|
+
Packaged `worker` defaults to fresh context; `oracle` and `advisor` default to forked context when a launch omits `context`. An implicit fork preference falls back to `fresh` when the parent has no persisted session file or current leaf. Pass `context: "fork"` when you intentionally want a worker to reuse the parent thread, or when fork must remain strict.
|
|
14
14
|
|
|
15
15
|
Child-safety boundaries are enforced at runtime:
|
|
16
16
|
|
|
@@ -23,6 +23,7 @@ import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text
|
|
|
23
23
|
import { readStatus } from "../shared/utils.ts";
|
|
24
24
|
import { SubagentParams } from "./schemas.ts";
|
|
25
25
|
import { normalizePublicSubagentExecution } from "./public-execution.ts";
|
|
26
|
+
import { collectSubagentCost, SUBAGENT_COST_REPORT_VERSION } from "../slash/subagent-cost.ts";
|
|
26
27
|
import { ASYNC_STATUS_SNAPSHOT_KIND, ASYNC_STATUS_SNAPSHOT_VERSION, buildAsyncStatusSnapshotForState } from "../runs/background/async-status-snapshot.ts";
|
|
27
28
|
import { isStoppableAsyncStatusStep, resolveAsyncStatusChild, stopStoppableAsyncStatusChildren, type ResolvedAsyncStatusChild } from "../runs/shared/child-identity.ts";
|
|
28
29
|
|
|
@@ -31,7 +32,7 @@ export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
|
|
|
31
32
|
export const SUBAGENT_RPC_READY_EVENT = "subagents:rpc:v1:ready";
|
|
32
33
|
export const SUBAGENT_RPC_REPLY_EVENT_PREFIX = "subagents:rpc:v1:reply:";
|
|
33
34
|
|
|
34
|
-
export const SUBAGENT_RPC_METHODS = ["ping", "status", "manage", "spawn", "steer", "interrupt", "stop", "resume"] as const;
|
|
35
|
+
export const SUBAGENT_RPC_METHODS = ["ping", "status", "manage", "spawn", "steer", "interrupt", "stop", "resume", "cost"] as const;
|
|
35
36
|
export type SubagentRpcMethod = typeof SUBAGENT_RPC_METHODS[number];
|
|
36
37
|
|
|
37
38
|
export interface SubagentRpcRequestEnvelope {
|
|
@@ -456,6 +457,7 @@ function pingData(ctx: ExtensionContext | null) {
|
|
|
456
457
|
launchResolvedExtensions: { version: 1, source: "launch-resolved" },
|
|
457
458
|
runtimeAcknowledgedExtensions: { version: 1, source: "child-runtime", event: "subagent:acknowledge-extension" },
|
|
458
459
|
processTerminalProof: { version: 1, lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION },
|
|
460
|
+
cost: { version: SUBAGENT_COST_REPORT_VERSION },
|
|
459
461
|
},
|
|
460
462
|
events: {
|
|
461
463
|
ready: SUBAGENT_RPC_READY_EVENT,
|
|
@@ -761,6 +763,13 @@ async function handleRequest(
|
|
|
761
763
|
if (request.method === "resume") {
|
|
762
764
|
return executeChecked(options, ctx, request.requestId, request.method, resumeParams(request.params));
|
|
763
765
|
}
|
|
766
|
+
if (request.method === "cost") {
|
|
767
|
+
// The same parent-plus-child accounting `/subagent-cost` renders, as data.
|
|
768
|
+
// Read-only: it walks the current session branch and existing artifacts,
|
|
769
|
+
// so callers should request it on their own turn boundaries, not on a timer.
|
|
770
|
+
if (request.params !== undefined && !isRecord(request.params)) throw new SubagentRpcError("invalid_params", "RPC cost params must be an object when provided.");
|
|
771
|
+
return collectSubagentCost(ctx, options.state ?? { baseCwd: ctx.cwd });
|
|
772
|
+
}
|
|
764
773
|
throw new SubagentRpcError("unsupported_method", `Unsupported subagent RPC method: ${String(request.method)}`);
|
|
765
774
|
}
|
|
766
775
|
|
|
@@ -6,6 +6,7 @@ import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } fro
|
|
|
6
6
|
import { readStatus } from "../../shared/utils.ts";
|
|
7
7
|
import { checkPidLiveness, type PidLiveness } from "./stale-run-reconciler.ts";
|
|
8
8
|
import { readProcessTerminal } from "./process-terminal.ts";
|
|
9
|
+
import { isTerminalAsyncState as terminalState, readWorkflowChildProcessEvidence } from "./workflow-terminal-proof.ts";
|
|
9
10
|
|
|
10
11
|
export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
|
|
11
12
|
export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
|
|
@@ -209,10 +210,6 @@ function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacit
|
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
function terminalState(state: AsyncStatus["state"]): boolean {
|
|
213
|
-
return state !== "queued" && state !== "running" && state !== "paused";
|
|
214
|
-
}
|
|
215
|
-
|
|
216
213
|
function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
217
214
|
if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
|
|
218
215
|
if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
|
|
@@ -269,23 +266,8 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncSt
|
|
|
269
266
|
if (status.mode !== "workflow") return { state: "retained", reason: `status mode is ${status.mode}, not workflow` };
|
|
270
267
|
if (!terminalState(status.state)) return { state: "retained", reason: `workflow is still ${status.state}` };
|
|
271
268
|
if (liveWorkflowRunIds.has(owner.runId)) return { state: "retained", reason: "workflow controller is still live" };
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (typeof step.async !== "boolean") return { state: "retained", reason: `workflow child ${label} is missing async classification` };
|
|
275
|
-
if (!step.async) continue;
|
|
276
|
-
if (!step.runId) return { state: "retained", reason: `async workflow child ${label} is missing run id` };
|
|
277
|
-
const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
|
|
278
|
-
if (!fs.existsSync(childDir)) return { state: "retained", reason: `async workflow child ${label} directory is missing` };
|
|
279
|
-
const childStatus = readStatus(childDir);
|
|
280
|
-
if (!childStatus) return { state: "retained", reason: `async workflow child ${label} status is missing or unreadable` };
|
|
281
|
-
if (!terminalState(childStatus.state)) return { state: "retained", reason: `async workflow child ${label} is still ${childStatus.state}` };
|
|
282
|
-
if (!childStatus.processTerminal?.runnerProcessInstanceId) return { state: "retained", reason: `async workflow child ${label} has no runner process identity` };
|
|
283
|
-
const proof = readProcessTerminal(childDir, {
|
|
284
|
-
runId: step.runId,
|
|
285
|
-
runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
|
|
286
|
-
});
|
|
287
|
-
if (proof?.state !== "observed" || proof.runId !== step.runId) return { state: "retained", reason: `async workflow child ${label} process-terminal proof is ${proof?.state ?? "missing"}` };
|
|
288
|
-
}
|
|
269
|
+
const evidence = readWorkflowChildProcessEvidence(owner.asyncDir, status.steps);
|
|
270
|
+
if (evidence.state !== "observed") return { state: "retained", reason: evidence.reason };
|
|
289
271
|
return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
|
|
290
272
|
}
|
|
291
273
|
|
|
@@ -83,6 +83,7 @@ import { assertAgentAllowedByCapabilityCeiling, intersectSubagentCapabilityCeili
|
|
|
83
83
|
import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
|
|
84
84
|
import { resolvePermissionRules, type PermissionConfig } from "../shared/permissions.ts";
|
|
85
85
|
import { normalizeExtensionBindings, omitExtensionBindingsEnv, type ExtensionBindings } from "../shared/extension-bindings.ts";
|
|
86
|
+
import { omitGitRoutingEnv } from "../shared/git-environment.ts";
|
|
86
87
|
import { assertWorkflowLaneKey, normalizeWorkflowLaneMetadata } from "../shared/lane-metadata.ts";
|
|
87
88
|
|
|
88
89
|
const require = createRequire(import.meta.url);
|
|
@@ -595,7 +596,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Om
|
|
|
595
596
|
...backgroundProcessOptions(),
|
|
596
597
|
stdio: ["ignore", stdoutFd ?? "ignore", stderrFd ?? "ignore"],
|
|
597
598
|
env: {
|
|
598
|
-
...omitExtensionBindingsEnv(process.env),
|
|
599
|
+
...omitGitRoutingEnv(omitExtensionBindingsEnv(process.env)),
|
|
599
600
|
[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV]: piPackageRoot,
|
|
600
601
|
[JITI_ALIAS_ENV]: JSON.stringify(hostPeerAliases.aliases),
|
|
601
602
|
},
|
|
@@ -124,6 +124,7 @@ export interface RunChildSessionResult {
|
|
|
124
124
|
observedMutationAttempt?: boolean;
|
|
125
125
|
structuredOutputToolInvoked?: boolean;
|
|
126
126
|
structuredOutputMessageStartIndex?: number;
|
|
127
|
+
structuredOutputFailed?: boolean;
|
|
127
128
|
watchdog?: ChildWatchdogStateSnapshot;
|
|
128
129
|
sessionFile?: string;
|
|
129
130
|
currentTool?: string;
|
|
@@ -16,6 +16,7 @@ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts
|
|
|
16
16
|
import { normalizeExternalCliRunnerStatus } from "../shared/external-cli-contract.ts";
|
|
17
17
|
import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
|
|
18
18
|
import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
|
|
19
|
+
import { readWorkflowTerminalProof } from "./workflow-terminal-proof.ts";
|
|
19
20
|
import { formatWaitSubscriptions } from "./wait-subscriptions.ts";
|
|
20
21
|
import { resolveAsyncRunLocation } from "./async-resume.ts";
|
|
21
22
|
import { resolveSubagentRunId } from "./run-id-resolver.ts";
|
|
@@ -704,7 +705,10 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
704
705
|
|
|
705
706
|
const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
|
|
706
707
|
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
|
|
707
|
-
|
|
708
|
+
const workflowTerminalProof = workflowChildren
|
|
709
|
+
? readWorkflowTerminalProof(asyncDir, status.steps, workflowChildren, validHostStepNodes(status.workflowGraph).length, status.endedAt ?? status.lastUpdate ?? status.startedAt)
|
|
710
|
+
: undefined;
|
|
711
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(status.workflowReceiptPath ? { workflowReceiptPath: status.workflowReceiptPath } : {}), ...(status.preflight ? { preflight: status.preflight } : {}), ...(status.workflow?.preflightWarnings?.length ? { preflightWarnings: status.workflow.preflightWarnings } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowTerminalProof ? { workflowTerminalProof } : {}), ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
|
|
708
712
|
}
|
|
709
713
|
}
|
|
710
714
|
|
|
@@ -105,7 +105,7 @@ import { SUBAGENT_CHILD_ENV } from "../shared/child-runtime-config.ts";
|
|
|
105
105
|
import { deriveChildSessionName } from "../../shared/child-session-name.ts";
|
|
106
106
|
import { alignForkedSessionCwd } from "../../shared/fork-session-cwd.ts";
|
|
107
107
|
import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
|
|
108
|
-
import { clearStructuredOutputCaptures, createStructuredOutputFileCapture, createStructuredOutputRuntime, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput, readStructuredOutputAcceptanceReport } from "../shared/structured-output.ts";
|
|
108
|
+
import { clearStructuredOutputCaptures, createStructuredOutputFileCapture, createStructuredOutputRuntime, formatStructuredOutputRejectionError, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput, readStructuredOutputAcceptanceReport } from "../shared/structured-output.ts";
|
|
109
109
|
import { formatMidToolExitError, isOrdinaryToolForMidToolExit, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
|
|
110
110
|
import { formatChildToolDiagnostic } from "../shared/tool-availability.ts";
|
|
111
111
|
import { buildTimeoutRecoverySummary, collectTrackedMutationEvidence, snapshotTrackedMutations } from "../shared/mutation-evidence.ts";
|
|
@@ -285,6 +285,7 @@ interface StepResult {
|
|
|
285
285
|
review?: import("../../shared/types.ts").ReviewProjection;
|
|
286
286
|
effects?: import("../../shared/types.ts").EffectsProjection;
|
|
287
287
|
structuredOutput?: unknown;
|
|
288
|
+
structuredOutputFailed?: boolean;
|
|
288
289
|
structuredOutputPath?: string;
|
|
289
290
|
structuredOutputSchemaPath?: string;
|
|
290
291
|
acceptance?: import("../../shared/types.ts").AcceptanceLedger;
|
|
@@ -1221,7 +1222,8 @@ export async function runSingleStepInner(
|
|
|
1221
1222
|
schemaPath: effectiveStructuredOutput.schemaPath,
|
|
1222
1223
|
outputPath: effectiveStructuredOutput.outputPath,
|
|
1223
1224
|
});
|
|
1224
|
-
if (structured.error) structuredError =
|
|
1225
|
+
if (structured.error === MISSING_STRUCTURED_OUTPUT_CALL_ERROR) structuredError = formatStructuredOutputRejectionError(run.messages);
|
|
1226
|
+
else if (structured.error) structuredError = structured.error;
|
|
1225
1227
|
else {
|
|
1226
1228
|
structuredOutput = structured.value;
|
|
1227
1229
|
const acceptanceReport = readStructuredOutputAcceptanceReport(effectiveStructuredOutput);
|
|
@@ -1343,7 +1345,7 @@ export async function runSingleStepInner(
|
|
|
1343
1345
|
afterCompactionSettlement: run.afterCompactionSettlement === true,
|
|
1344
1346
|
});
|
|
1345
1347
|
const fileMutationEffect = completionEvidence.fileMutation ?? (missingRequiredOutputAfterMutation ? { status: "observed" as const, expected: completionEvidence.mutationExpected, attempted: true, evidence: mutationEvidence } : undefined);
|
|
1346
|
-
finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput, runtimeAcknowledgedExtensions, ...(step.agentContract ? { agentContract: step.agentContract } : {}), ...(fileMutationEffect || settlementDiagnostic ? { effects: { ...(fileMutationEffect ? { fileMutation: fileMutationEffect } : {}), ...(settlementDiagnostic ? { settlementDiagnostic } : {}) } } : {}) } as RunChildSessionResult;
|
|
1348
|
+
finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput, structuredOutputFailed: structuredError ? true : undefined, runtimeAcknowledgedExtensions, ...(step.agentContract ? { agentContract: step.agentContract } : {}), ...(fileMutationEffect || settlementDiagnostic ? { effects: { ...(fileMutationEffect ? { fileMutation: fileMutationEffect } : {}), ...(settlementDiagnostic ? { settlementDiagnostic } : {}) } } : {}) } as RunChildSessionResult;
|
|
1347
1349
|
const abortRecovery = !attempt.success ? planAbortRecovery({
|
|
1348
1350
|
messages: run.messages,
|
|
1349
1351
|
error,
|
|
@@ -1603,6 +1605,7 @@ export async function runSingleStepInner(
|
|
|
1603
1605
|
completionGuardTriggered: completionGuardTriggeredFinal,
|
|
1604
1606
|
...((finalResult as (RunChildSessionResult & { effects?: import("../../shared/types.ts").EffectsProjection }) | undefined)?.effects ? { effects: (finalResult as RunChildSessionResult & { effects?: import("../../shared/types.ts").EffectsProjection }).effects } : {}),
|
|
1605
1607
|
structuredOutput: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : (finalResult as (RunChildSessionResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
|
|
1608
|
+
structuredOutputFailed: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : finalResult?.structuredOutputFailed,
|
|
1606
1609
|
structuredOutputPath: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : effectiveStructuredOutput?.outputPath,
|
|
1607
1610
|
structuredOutputSchemaPath: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : effectiveStructuredOutput?.schemaPath,
|
|
1608
1611
|
acceptance: effectiveAcceptance,
|
|
@@ -3788,6 +3791,7 @@ export async function runSubagent(
|
|
|
3788
3791
|
review: pr.review,
|
|
3789
3792
|
timeoutRecovery: pr.timeoutRecovery,
|
|
3790
3793
|
structuredOutput: pr.structuredOutput,
|
|
3794
|
+
structuredOutputFailed: pr.structuredOutputFailed,
|
|
3791
3795
|
structuredOutputPath: pr.structuredOutputPath,
|
|
3792
3796
|
structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
|
|
3793
3797
|
acceptance: pr.acceptance,
|
|
@@ -4245,6 +4249,7 @@ export async function runSubagent(
|
|
|
4245
4249
|
review: pr.review,
|
|
4246
4250
|
timeoutRecovery: pr.timeoutRecovery,
|
|
4247
4251
|
structuredOutput: pr.structuredOutput,
|
|
4252
|
+
structuredOutputFailed: pr.structuredOutputFailed,
|
|
4248
4253
|
structuredOutputPath: pr.structuredOutputPath,
|
|
4249
4254
|
structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
|
|
4250
4255
|
acceptance: pr.acceptance,
|
|
@@ -4539,6 +4544,7 @@ export async function runSubagent(
|
|
|
4539
4544
|
review: singleResult.review,
|
|
4540
4545
|
timeoutRecovery: singleResult.timeoutRecovery,
|
|
4541
4546
|
structuredOutput: singleResult.structuredOutput,
|
|
4547
|
+
structuredOutputFailed: singleResult.structuredOutputFailed,
|
|
4542
4548
|
structuredOutputPath: singleResult.structuredOutputPath,
|
|
4543
4549
|
structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
|
|
4544
4550
|
acceptance: singleResult.acceptance,
|
|
@@ -4923,6 +4929,7 @@ export async function runSubagent(
|
|
|
4923
4929
|
review: r.review,
|
|
4924
4930
|
effects: r.effects,
|
|
4925
4931
|
structuredOutput: r.structuredOutput,
|
|
4932
|
+
structuredOutputFailed: r.structuredOutputFailed,
|
|
4926
4933
|
structuredOutputPath: r.structuredOutputPath,
|
|
4927
4934
|
structuredOutputSchemaPath: r.structuredOutputSchemaPath,
|
|
4928
4935
|
acceptance: r.acceptance,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AsyncStatus, ProcessTerminal, WorkflowChildSummary, WorkflowTerminalProof } from "../../shared/types.ts";
|
|
4
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
5
|
+
import { readProcessTerminal } from "./process-terminal.ts";
|
|
6
|
+
|
|
7
|
+
const TERMINAL_WORKFLOW_STATES = new Set<WorkflowChildSummary["workflowState"]>(["completed", "failed", "stopped"]);
|
|
8
|
+
|
|
9
|
+
export function isTerminalAsyncState(state: AsyncStatus["state"]): boolean {
|
|
10
|
+
return state !== "queued" && state !== "running" && state !== "paused";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type WorkflowChildProcessEvidence =
|
|
14
|
+
| { state: "observed"; children: ProcessTerminal[] }
|
|
15
|
+
| { state: "pending" | "unknown"; reason: string };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Process evidence for a workflow's async children. Status proofs and capacity
|
|
19
|
+
* release both use this so they cannot disagree about the same workflow.
|
|
20
|
+
* Synchronous children run inside the workflow host and have no process of their own.
|
|
21
|
+
*/
|
|
22
|
+
export function readWorkflowChildProcessEvidence(workflowAsyncDir: string, steps: AsyncStatus["steps"]): WorkflowChildProcessEvidence {
|
|
23
|
+
const children: ProcessTerminal[] = [];
|
|
24
|
+
for (const step of steps ?? []) {
|
|
25
|
+
const label = step.workflowKey ?? step.agent;
|
|
26
|
+
if (typeof step.async !== "boolean") return { state: "unknown", reason: `workflow child ${label} is missing async classification` };
|
|
27
|
+
if (!step.async) continue;
|
|
28
|
+
if (!step.runId || path.basename(step.runId) !== step.runId) return { state: "unknown", reason: `async workflow child ${label} is missing run id` };
|
|
29
|
+
const childDir = path.join(path.dirname(workflowAsyncDir), step.runId);
|
|
30
|
+
if (!fs.existsSync(childDir)) return { state: "unknown", reason: `async workflow child ${label} directory is missing` };
|
|
31
|
+
const childStatus = readStatus(childDir);
|
|
32
|
+
if (!childStatus) return { state: "unknown", reason: `async workflow child ${label} status is missing or unreadable` };
|
|
33
|
+
if (!isTerminalAsyncState(childStatus.state)) return { state: "pending", reason: `async workflow child ${label} is still ${childStatus.state}` };
|
|
34
|
+
const recorded = childStatus.processTerminal;
|
|
35
|
+
if (!recorded?.runnerProcessInstanceId) return { state: "unknown", reason: `async workflow child ${label} has no runner process identity` };
|
|
36
|
+
// A runner startup failure records `not-started` in status and never writes a process-terminal sidecar.
|
|
37
|
+
if (recorded.state === "not-started" && recorded.runId === step.runId && typeof childStatus.error === "string" && childStatus.error) {
|
|
38
|
+
children.push(recorded);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const proof = readProcessTerminal(childDir, { runId: step.runId, runnerProcessInstanceId: recorded.runnerProcessInstanceId });
|
|
42
|
+
if (proof?.state !== "observed" || proof.runId !== step.runId) {
|
|
43
|
+
return { state: !proof || proof.state === "pending" ? "pending" : "unknown", reason: `async workflow child ${label} process-terminal proof is ${proof?.state ?? "missing"}` };
|
|
44
|
+
}
|
|
45
|
+
children.push(proof);
|
|
46
|
+
}
|
|
47
|
+
return { state: "observed", children };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function unresolved(runId: string, state: "pending" | "unknown", dispatchClosed: boolean, reason: string): WorkflowTerminalProof {
|
|
51
|
+
return { version: 1, kind: "workflow", runId, state, dispatchClosed, reason };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A persistent workflow host is terminal only after dispatch closes and every async child has process evidence. */
|
|
55
|
+
export function readWorkflowTerminalProof(asyncDir: string, steps: AsyncStatus["steps"], summary: WorkflowChildSummary, hostCommandCount: number, closedAt: number): WorkflowTerminalProof {
|
|
56
|
+
const runId = summary.workflowRunId;
|
|
57
|
+
if (!summary.inventoryComplete || !TERMINAL_WORKFLOW_STATES.has(summary.workflowState)) {
|
|
58
|
+
return unresolved(runId, "pending", false, "Workflow dispatch is still open.");
|
|
59
|
+
}
|
|
60
|
+
if (hostCommandCount > 0) {
|
|
61
|
+
return unresolved(runId, "unknown", true, "Workflow host commands have no process-terminal proof.");
|
|
62
|
+
}
|
|
63
|
+
const evidence = readWorkflowChildProcessEvidence(asyncDir, steps);
|
|
64
|
+
if (evidence.state !== "observed") return unresolved(runId, evidence.state, true, evidence.reason);
|
|
65
|
+
const observedAt = Math.max(closedAt, ...evidence.children.map((child) => child.state === "observed" ? child.observedAt : 0));
|
|
66
|
+
return { version: 1, kind: "workflow", runId, state: "observed", dispatchClosed: true, observedAt, children: evidence.children };
|
|
67
|
+
}
|