@hcflabs/pi-subagent 27.11.0 → 28.0.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/README.md +22 -2
- package/dist/full-suite.d.ts +34 -0
- package/dist/full-suite.js +142 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +37 -0
- package/dist/runner.d.ts +6 -2
- package/dist/runner.js +27 -31
- package/dist/schema.d.ts +38 -3
- package/dist/schema.js +23 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.js +9 -7
- package/docs/orchestration.md +2 -2
- package/extensions/isolated.ts +14 -2
- package/extensions/subagent.ts +2 -0
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ Use isolated mode for checked implementation. An isolated request has one durabl
|
|
|
153
153
|
}
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
`dependsOn` controls scheduling. `contextFrom` also schedules dependencies and supplies completed text outputs in order. Do not repeat a task in both fields. Changesets require focused checks; graphs with changesets require final checks.
|
|
156
|
+
`dependsOn` controls scheduling. `contextFrom` also schedules dependencies and supplies completed text outputs in order. Do not repeat a task in both fields. Changesets require focused checks; graphs with changesets require final checks. Admission selects one Full suite for the repository and adds it to the final checks if absent. See [Full suite](#full-suite).
|
|
157
157
|
|
|
158
158
|
Workers use Main's pinned committed branch, HEAD, and tree, not staged, unstaged, or untracked edits. Branch or HEAD changes block new waves. Candidates must be committed with no tracked or untracked changes. Ignored generated files permit candidacy but prevent automatic cleanup.
|
|
159
159
|
|
|
@@ -174,6 +174,24 @@ Reuse assumes that checks use only tracked content. Ignored files, for example `
|
|
|
174
174
|
|
|
175
175
|
The extension sends follow-ups when a candidate becomes ready, a worker needs attention, and the wave finishes. A missed follow-up does not authorize replay. Read [Isolated contract](./docs/orchestration.md#isolated-contract) and [Exact integration actions](./docs/orchestration.md#exact-integration-actions) for graph, judgment, revision, refresh, and release details.
|
|
176
176
|
|
|
177
|
+
### Full suite
|
|
178
|
+
|
|
179
|
+
Each isolated request with a changeset task must pass one Full suite on the combined tip before promotion. Admission selects it in this order:
|
|
180
|
+
|
|
181
|
+
1. The `repoSuites` entry for the repository in the config file.
|
|
182
|
+
2. `pnpm test`, when the root `package.json` has a non-empty `scripts.test`.
|
|
183
|
+
3. `make test`, when the root `GNUmakefile`, `makefile`, or `Makefile` declares a literal `test` target, such as `test:` or `test::`.
|
|
184
|
+
|
|
185
|
+
If no source applies, the request fails before it saves state. A read or parse error also fails. It never counts as an absent source. The extension reads the Makefile as text and never runs Make to find targets, because Make can run repository code. Generated, included, or pattern targets are not found. Use `repoSuites` for them:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{ "repoSuites": { "/home/me/code/service": { "command": "uv", "args": ["run", "pytest", "-q"] } } }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The key is the absolute path of the primary checkout. Two keys for the same checkout block the request. The command runs without a shell. To use a shell, name it as the command.
|
|
192
|
+
|
|
193
|
+
Admission saves the selected suite and its source with the request. Validation, resume, and recovery use the saved suite, so a later config change does not change an admitted request. Validation rejects a combined tip that changes the selected `package.json` test script or Makefile. A dry run shows the selected suite.
|
|
194
|
+
|
|
177
195
|
### Inspect work
|
|
178
196
|
|
|
179
197
|
Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. The menu groups rows under **Direct**, **Isolated**, and **Orphaned** headers. Each isolated row shows the request ID, the status, and a short goal summary. **Refresh** or a header reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
|
|
@@ -217,6 +235,7 @@ Package-owned: `~/.pi/agent/config/pi-subagent/config.json`. Missing files silen
|
|
|
217
235
|
| `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive; within Node's timer range | `10` |
|
|
218
236
|
| `timeout.maxMinutes` | Ephemeral child hard runtime | Greater than idle; within Node's timer range | `30` |
|
|
219
237
|
| `roleDefaults` | Shared resources for built-in and user Roles | Object with `extensions`, `tools`, or `skills` arrays; same validation as Role fields | — |
|
|
238
|
+
| `repoSuites` | Full suite for isolated changesets in one repository | Object keyed by the absolute path of the primary checkout. Each value is `{ "command": "<executable>", "args": [...] }` | — |
|
|
220
239
|
|
|
221
240
|
`roleDefaults` entries precede Role entries; merging removes duplicates before resource resolution. Use it for a provider extension that every child needs. Limits come only from this file. Requests cannot override or replenish them. Durable requests keep recorded policy; a lower current correction limit can tighten recovery. There is no whole-run timeout.
|
|
222
241
|
|
|
@@ -225,6 +244,7 @@ The extension reports each new problem once per session. It reads config at laun
|
|
|
225
244
|
- Unknown keys warn and are ignored. A likely misspelling, such as `maxToken` for missing `maxTokens`, counts as invalid for that key.
|
|
226
245
|
- Invalid `maxSubagents`, `maxTurns`, `maxCorrections`, or `timeout` values warn and use finite defaults. If maximum timeout does not exceed idle timeout, the whole `timeout` uses defaults.
|
|
227
246
|
- Unknown or invalid `roleDefaults` keys warn; only those keys are ignored, so Roles receive fewer default resources.
|
|
247
|
+
- An invalid `repoSuites` value blocks delegation. One bad entry blocks the whole value, so a bad entry cannot select a different Full suite.
|
|
228
248
|
- Invalid or misspelled `maxTokens` blocks delegation. Malformed JSON, invalid UTF-8, files above 64 KiB, and unreadable files also block delegation. The extension does not fall back to unlimited tokens.
|
|
229
249
|
|
|
230
250
|
`/subagent` remains available during a block. Correct the file to enable delegation. The next worker or isolated Role launch fails while the block remains; running workers retain their original limits. If the problem existed at session start, also run `/reload`, because that session took default execution limits.
|
|
@@ -281,7 +301,7 @@ The executor accepts concurrency, turn/token, idle/hard-runtime limits, and abor
|
|
|
281
301
|
|
|
282
302
|
Private durable state lives under `config/pi-subagent/state/`. Isolated worker sessions use mode `0600` under `config/pi-subagent/leases/`; worker umask stays unchanged. Reads stop at 16 MiB. Larger sessions require inspection and cannot prove completion.
|
|
283
303
|
|
|
284
|
-
Sessions are removed only after exact worker-tab cleanup. They can contain prompts and answers: do not share them. State
|
|
304
|
+
Sessions are removed only after exact worker-tab cleanup. They can contain prompts and answers: do not share them. State v6 rejects v5 and older files without migration. An unfinished v4 or v5 request blocks new isolated work in its repository. Finish or recover isolated requests with the earlier version before you upgrade. Preserve old state and recover its work manually.
|
|
285
305
|
|
|
286
306
|
## Limits and recovery
|
|
287
307
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AdmittedFullSuite, CheckCommand } from "./schema.ts";
|
|
2
|
+
export type { AdmittedFullSuite };
|
|
3
|
+
/** Make reads the first of these files that exists. */
|
|
4
|
+
declare const MAKEFILE_NAMES: readonly ["GNUmakefile", "makefile", "Makefile"];
|
|
5
|
+
/** The root `package.json` test script, or `undefined` when the file or the script is absent. A malformed file throws. */
|
|
6
|
+
export declare function packageTestScript(root: string): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Return true when the Makefile text declares a literal `test` target, for example `test:` or `test check::`.
|
|
9
|
+
* This is a static check. It never runs Make, because Make can run repository code while it reads a file.
|
|
10
|
+
* Generated, included, or pattern targets are not found. Configure `repoSuites` for them.
|
|
11
|
+
*/
|
|
12
|
+
export declare function declaresMakeTest(text: string): boolean;
|
|
13
|
+
/** The Makefile that Make would read in `root`, with its content hash. `undefined` when no Makefile exists. */
|
|
14
|
+
export declare function makefileIdentity(root: string): {
|
|
15
|
+
file: (typeof MAKEFILE_NAMES)[number];
|
|
16
|
+
text: string;
|
|
17
|
+
sha256: string;
|
|
18
|
+
} | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Select the Full suite for `root`. The order is: the configured suite, then `pnpm test` when the root
|
|
21
|
+
* `package.json` has a non-empty `scripts.test`, then `make test` when the root Makefile declares `test`.
|
|
22
|
+
* A read or parse error throws. It never counts as an absent source.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveFullSuite(root: string, configured?: CheckCommand): AdmittedFullSuite;
|
|
25
|
+
/** Throw when the checkout at `root` changed the source of an admitted Full suite. A configured suite has no source file. */
|
|
26
|
+
export declare function assertFullSuiteSource(suite: AdmittedFullSuite, root: string): void;
|
|
27
|
+
export declare const sameCheck: (left: CheckCommand, right: CheckCommand) => boolean;
|
|
28
|
+
/** A human-readable source label for plans and status. */
|
|
29
|
+
export declare function describeFullSuite(suite: AdmittedFullSuite): string;
|
|
30
|
+
/**
|
|
31
|
+
* The `repoSuites` entry for the primary checkout `primary`. Keys are compared by real path.
|
|
32
|
+
* Two keys for the same checkout throw, because the choice between them is not defined.
|
|
33
|
+
*/
|
|
34
|
+
export declare function configuredSuiteFor(repoSuites: Readonly<Record<string, CheckCommand>>, primary: string): CheckCommand | undefined;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** Make reads the first of these files that exists. */
|
|
5
|
+
const MAKEFILE_NAMES = ["GNUmakefile", "makefile", "Makefile"];
|
|
6
|
+
const MANIFEST_MAX_BYTES = 1024 * 1024;
|
|
7
|
+
function readOptional(path) {
|
|
8
|
+
let text;
|
|
9
|
+
try {
|
|
10
|
+
text = readFileSync(path, "utf8");
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const code = error.code;
|
|
14
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
15
|
+
return undefined;
|
|
16
|
+
throw new Error(`Full suite detection cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
17
|
+
}
|
|
18
|
+
if (Buffer.byteLength(text, "utf8") > MANIFEST_MAX_BYTES)
|
|
19
|
+
throw new Error(`Full suite detection refuses ${path}: the file is larger than 1 MiB.`);
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
/** The root `package.json` test script, or `undefined` when the file or the script is absent. A malformed file throws. */
|
|
23
|
+
export function packageTestScript(root) {
|
|
24
|
+
const path = join(root, "package.json");
|
|
25
|
+
const text = readOptional(path);
|
|
26
|
+
if (text === undefined)
|
|
27
|
+
return undefined;
|
|
28
|
+
let manifest;
|
|
29
|
+
try {
|
|
30
|
+
manifest = JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw new Error(`Full suite detection cannot parse ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
|
|
36
|
+
throw new Error(`Full suite detection found no JSON object in ${path}.`);
|
|
37
|
+
const scripts = manifest.scripts;
|
|
38
|
+
if (scripts === undefined)
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!scripts || typeof scripts !== "object" || Array.isArray(scripts))
|
|
41
|
+
throw new Error(`Full suite detection found a malformed scripts field in ${path}.`);
|
|
42
|
+
const test = scripts.test;
|
|
43
|
+
if (test === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (typeof test !== "string")
|
|
46
|
+
throw new Error(`Full suite detection found a test script that is not text in ${path}.`);
|
|
47
|
+
return test.trim() ? test : undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Return true when the Makefile text declares a literal `test` target, for example `test:` or `test check::`.
|
|
51
|
+
* This is a static check. It never runs Make, because Make can run repository code while it reads a file.
|
|
52
|
+
* Generated, included, or pattern targets are not found. Configure `repoSuites` for them.
|
|
53
|
+
*/
|
|
54
|
+
export function declaresMakeTest(text) {
|
|
55
|
+
let continued = false;
|
|
56
|
+
for (const line of text.split(/\r?\n/)) {
|
|
57
|
+
const previous = continued;
|
|
58
|
+
continued = /(^|[^\\])(\\\\)*\\$/.test(line);
|
|
59
|
+
// Recipe lines start with a tab. Continuation lines belong to the line before them.
|
|
60
|
+
if (previous || line.startsWith("\t"))
|
|
61
|
+
continue;
|
|
62
|
+
const rule = /^([^#:=]+?)::?(?![=:])/.exec(line);
|
|
63
|
+
if (!rule)
|
|
64
|
+
continue;
|
|
65
|
+
if (rule[1].trim().split(/\s+/).includes("test"))
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
/** The Makefile that Make would read in `root`, with its content hash. `undefined` when no Makefile exists. */
|
|
71
|
+
export function makefileIdentity(root) {
|
|
72
|
+
// Compare exact names. A case-insensitive file system would otherwise report Makefile as makefile.
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = new Set(readdirSync(root));
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new Error(`Full suite detection cannot list ${root}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
79
|
+
}
|
|
80
|
+
for (const file of MAKEFILE_NAMES) {
|
|
81
|
+
if (!names.has(file))
|
|
82
|
+
continue;
|
|
83
|
+
const text = readOptional(join(root, file));
|
|
84
|
+
if (text !== undefined)
|
|
85
|
+
return { file, text, sha256: createHash("sha256").update(text).digest("hex") };
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Select the Full suite for `root`. The order is: the configured suite, then `pnpm test` when the root
|
|
91
|
+
* `package.json` has a non-empty `scripts.test`, then `make test` when the root Makefile declares `test`.
|
|
92
|
+
* A read or parse error throws. It never counts as an absent source.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveFullSuite(root, configured) {
|
|
95
|
+
if (configured)
|
|
96
|
+
return { check: { command: configured.command, args: [...configured.args] }, source: { kind: "config" } };
|
|
97
|
+
const testScript = packageTestScript(root);
|
|
98
|
+
if (testScript !== undefined)
|
|
99
|
+
return { check: { command: "pnpm", args: ["test"] }, source: { kind: "package-json", testScript } };
|
|
100
|
+
const makefile = makefileIdentity(root);
|
|
101
|
+
if (makefile && declaresMakeTest(makefile.text)) {
|
|
102
|
+
return { check: { command: "make", args: ["test"] }, source: { kind: "makefile", file: makefile.file, sha256: makefile.sha256 } };
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`No Full suite is available for ${root}. Configure repoSuites in config/pi-subagent/config.json, add a package.json scripts.test, or add a Makefile test target.`);
|
|
105
|
+
}
|
|
106
|
+
/** Throw when the checkout at `root` changed the source of an admitted Full suite. A configured suite has no source file. */
|
|
107
|
+
export function assertFullSuiteSource(suite, root) {
|
|
108
|
+
const { source } = suite;
|
|
109
|
+
if (source.kind === "package-json" && packageTestScript(root) !== source.testScript) {
|
|
110
|
+
throw new Error("Combined tip changed the admitted root package.json test script.");
|
|
111
|
+
}
|
|
112
|
+
if (source.kind === "makefile") {
|
|
113
|
+
const makefile = makefileIdentity(root);
|
|
114
|
+
if (makefile?.file !== source.file || makefile.sha256 !== source.sha256)
|
|
115
|
+
throw new Error(`Combined tip changed the admitted root ${source.file}.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export const sameCheck = (left, right) => left.command === right.command && left.args.length === right.args.length && left.args.every((arg, index) => arg === right.args[index]);
|
|
119
|
+
/** A human-readable source label for plans and status. */
|
|
120
|
+
export function describeFullSuite(suite) {
|
|
121
|
+
const command = [suite.check.command, ...suite.check.args].join(" ");
|
|
122
|
+
return suite.source.kind === "config" ? `${command} (from repoSuites config)`
|
|
123
|
+
: suite.source.kind === "package-json" ? `${command} (from package.json scripts.test)`
|
|
124
|
+
: `${command} (from ${suite.source.file} test target)`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The `repoSuites` entry for the primary checkout `primary`. Keys are compared by real path.
|
|
128
|
+
* Two keys for the same checkout throw, because the choice between them is not defined.
|
|
129
|
+
*/
|
|
130
|
+
export function configuredSuiteFor(repoSuites, primary) {
|
|
131
|
+
const matches = Object.entries(repoSuites).filter(([key]) => {
|
|
132
|
+
try {
|
|
133
|
+
return realpathSync.native(key) === primary;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return key === primary;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
if (matches.length > 1)
|
|
140
|
+
throw new Error(`repoSuites has ${matches.length} entries for ${primary}: ${matches.map(([key]) => key).join(", ")}. Keep one entry.`);
|
|
141
|
+
return matches[0]?.[1];
|
|
142
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -94,6 +94,16 @@ export interface ResolvedRoleSkills {
|
|
|
94
94
|
export declare function parseRoleName(value: unknown, source?: string): RoleName;
|
|
95
95
|
/** Validate `config.json` `roleDefaults` with the same rules as Role fields; `label` prefixes errors. */
|
|
96
96
|
export declare function parseRoleDefaults(value: unknown, label?: string): RoleDefaults;
|
|
97
|
+
/** A configured Full suite: one executable and its argument list. No shell runs unless the executable is a shell. */
|
|
98
|
+
export interface RepoSuite {
|
|
99
|
+
command: string;
|
|
100
|
+
args: string[];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Strict `repoSuites`: any bad entry makes the whole value invalid, and the field disables delegation.
|
|
104
|
+
* Dropping one bad entry could select a different Full suite without notice.
|
|
105
|
+
*/
|
|
106
|
+
export declare function parseRepoSuites(raw: unknown): Record<string, RepoSuite>;
|
|
97
107
|
export interface SubagentConfig {
|
|
98
108
|
maxSubagents: number;
|
|
99
109
|
maxTurns: number;
|
|
@@ -104,6 +114,8 @@ export interface SubagentConfig {
|
|
|
104
114
|
maxMinutes: number;
|
|
105
115
|
};
|
|
106
116
|
roleDefaults: RoleDefaults;
|
|
117
|
+
/** Full suite overrides for isolated changesets, keyed by absolute primary checkout path. */
|
|
118
|
+
repoSuites: Record<string, RepoSuite>;
|
|
107
119
|
}
|
|
108
120
|
export declare const DEFAULT_SUBAGENT_CONFIG: Readonly<SubagentConfig>;
|
|
109
121
|
/**
|
|
@@ -121,6 +133,7 @@ export declare function createSubagentConfigStore(agentDir?: string): import("@h
|
|
|
121
133
|
maxMinutes: number;
|
|
122
134
|
}>;
|
|
123
135
|
roleDefaults: import("@hcflabs/pi-config-store").Field<RoleDefaults>;
|
|
136
|
+
repoSuites: import("@hcflabs/pi-config-store").Field<Record<string, RepoSuite>>;
|
|
124
137
|
}>>;
|
|
125
138
|
/**
|
|
126
139
|
* Report `roleDefaults` issues at each session start, and return a function that reports them after a lazy
|
package/dist/index.js
CHANGED
|
@@ -194,6 +194,41 @@ const roleDefaultsField = (raw, report) => {
|
|
|
194
194
|
}
|
|
195
195
|
return defaults;
|
|
196
196
|
};
|
|
197
|
+
const MAX_REPO_SUITES = 200;
|
|
198
|
+
const MAX_SUITE_ARGS = 128;
|
|
199
|
+
const MAX_SUITE_TEXT = 16_000;
|
|
200
|
+
/**
|
|
201
|
+
* Strict `repoSuites`: any bad entry makes the whole value invalid, and the field disables delegation.
|
|
202
|
+
* Dropping one bad entry could select a different Full suite without notice.
|
|
203
|
+
*/
|
|
204
|
+
export function parseRepoSuites(raw) {
|
|
205
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
206
|
+
throw new Error("must be a JSON object keyed by absolute primary checkout paths");
|
|
207
|
+
const entries = Object.entries(raw);
|
|
208
|
+
if (entries.length > MAX_REPO_SUITES)
|
|
209
|
+
throw new Error(`has ${entries.length} entries; the limit is ${MAX_REPO_SUITES}`);
|
|
210
|
+
const suites = {};
|
|
211
|
+
for (const [key, value] of entries) {
|
|
212
|
+
const label = JSON.stringify(key);
|
|
213
|
+
if (!isAbsolute(key) || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key))
|
|
214
|
+
throw new Error(`key ${label} must be an absolute path without control characters`);
|
|
215
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
216
|
+
throw new Error(`${label} must be an object with command and args`);
|
|
217
|
+
for (const field of Object.keys(value)) {
|
|
218
|
+
if (field !== "command" && field !== "args")
|
|
219
|
+
throw new Error(`${label} has unknown key ${JSON.stringify(field)}; expected command and args`);
|
|
220
|
+
}
|
|
221
|
+
const { command, args } = value;
|
|
222
|
+
if (typeof command !== "string" || !command.trim() || command !== command.trim() || command.length > MAX_SUITE_TEXT || /[\u0000-\u001f\u007f]/.test(command)) {
|
|
223
|
+
throw new Error(`${label}.command must be one executable name or path without spaces at the ends or control characters`);
|
|
224
|
+
}
|
|
225
|
+
if (!Array.isArray(args) || args.length > MAX_SUITE_ARGS || args.some((arg) => typeof arg !== "string" || arg.length > MAX_SUITE_TEXT || arg.includes("\0"))) {
|
|
226
|
+
throw new Error(`${label}.args must be a list of at most ${MAX_SUITE_ARGS} strings without NUL`);
|
|
227
|
+
}
|
|
228
|
+
suites[key] = { command, args: [...args] };
|
|
229
|
+
}
|
|
230
|
+
return suites;
|
|
231
|
+
}
|
|
197
232
|
export const DEFAULT_SUBAGENT_CONFIG = Object.freeze({
|
|
198
233
|
maxSubagents: 5,
|
|
199
234
|
maxTurns: DEFAULT_MAX_TURNS,
|
|
@@ -201,6 +236,7 @@ export const DEFAULT_SUBAGENT_CONFIG = Object.freeze({
|
|
|
201
236
|
maxCorrections: 1,
|
|
202
237
|
timeout: Object.freeze({ idleMinutes: 10, maxMinutes: 30 }),
|
|
203
238
|
roleDefaults: Object.freeze({}),
|
|
239
|
+
repoSuites: Object.freeze({}),
|
|
204
240
|
});
|
|
205
241
|
const timeoutMinutes = parsers.number({ greaterThan: 0, max: 2_147_483_647 / 60_000 });
|
|
206
242
|
const timeoutField = (raw, report) => {
|
|
@@ -229,6 +265,7 @@ export function createSubagentConfigStore(agentDir = getAgentDir()) {
|
|
|
229
265
|
maxCorrections: field(parsers.integer({ min: 0 }), DEFAULT_SUBAGENT_CONFIG.maxCorrections),
|
|
230
266
|
timeout: field(timeoutField, DEFAULT_SUBAGENT_CONFIG.timeout),
|
|
231
267
|
roleDefaults: field(roleDefaultsField, DEFAULT_SUBAGENT_CONFIG.roleDefaults),
|
|
268
|
+
repoSuites: field((raw) => parseRepoSuites(raw), DEFAULT_SUBAGENT_CONFIG.repoSuites, { onInvalid: "disable" }),
|
|
232
269
|
},
|
|
233
270
|
});
|
|
234
271
|
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type DiscardGuards, type DiscardPlan } from "./discard.ts";
|
|
|
2
2
|
import { type EphemeralSubagentExecutor } from "./ephemeral.ts";
|
|
3
3
|
import type { PruneOwnership } from "./prune.ts";
|
|
4
4
|
import { IntegrationGit } from "./integration-git.ts";
|
|
5
|
-
import { type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
|
|
5
|
+
import { type AdmittedFullSuite, type AllocationKind, type CheckCommand, type ChangesetTaskRequest, type CleanupKind, type ExecuteRequest, type ExecutionPolicySnapshot, type HostAllocationIntent, type HostAllocationPlan, type ModelClass, type ReviewEvidence, type ResumeRequest, type RunState, type TaskAttempt, type TaskRequest, type TaskState, type WorktreeAllocationIntent, type WorktreeAllocationPlan, type WorkspaceIdentity } from "./schema.ts";
|
|
6
6
|
import { FileRunStore } from "./store.ts";
|
|
7
7
|
/** One durable follow-up per attempt; this instruction permits a worker commit, never discarded work. */
|
|
8
8
|
export declare const COMMIT_NUDGE_INSTRUCTION = "Your turn ended with uncommitted changes in your worktree. Commit the intended changes now so the worktree is clean, or explain in your reply why they must not be committed. Do not discard work.";
|
|
@@ -100,6 +100,8 @@ export type ResolvedRoute = Pick<VerifiedLaunch, "role" | "modelClass" | "model"
|
|
|
100
100
|
/** A dry-run result: the normalized request and the resolved routes; no state exists for it. */
|
|
101
101
|
export interface ExecutePlan {
|
|
102
102
|
request: ExecuteRequest;
|
|
103
|
+
/** The Full suite for a request with changeset tasks. */
|
|
104
|
+
fullSuite?: AdmittedFullSuite;
|
|
103
105
|
root: string;
|
|
104
106
|
main: WorkspaceIdentity;
|
|
105
107
|
routes: readonly ResolvedRoute[];
|
|
@@ -311,9 +313,11 @@ export declare class IsolatedRunner {
|
|
|
311
313
|
private readonly integrationGit;
|
|
312
314
|
private readonly textExecutor;
|
|
313
315
|
private readonly currentPolicy;
|
|
316
|
+
/** The configured `repoSuites`, read at admission only. */
|
|
317
|
+
private readonly repoSuites;
|
|
314
318
|
private readonly productiveLifecycles;
|
|
315
319
|
private readonly followupControls;
|
|
316
|
-
constructor(coordinatorRuntime: CoordinatorRuntime, hostRuntime: HostRuntime, gitRuntime: GitRuntime, store: FileRunStore | undefined, textExecutor: EphemeralSubagentExecutor, policy?: ExecutionPolicySnapshot, currentPolicy?: () => ExecutionPolicySnapshot, integrationGit?: IntegrationGit);
|
|
320
|
+
constructor(coordinatorRuntime: CoordinatorRuntime, hostRuntime: HostRuntime, gitRuntime: GitRuntime, store: FileRunStore | undefined, textExecutor: EphemeralSubagentExecutor, policy?: ExecutionPolicySnapshot, currentPolicy?: () => ExecutionPolicySnapshot, integrationGit?: IntegrationGit, repoSuites?: () => Readonly<Record<string, CheckCommand>>);
|
|
317
321
|
listRequests(root: string): Promise<{
|
|
318
322
|
requests: Array<{
|
|
319
323
|
id: string;
|
package/dist/runner.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { inspectDiscard, primaryCheckout, removeDiscardResources } from "./discard.js";
|
|
4
4
|
import { utf8Prefix } from "./ephemeral.js";
|
|
5
|
+
import { assertFullSuiteSource, configuredSuiteFor, resolveFullSuite, sameCheck } from "./full-suite.js";
|
|
5
6
|
import { createChildWorktree, finalizeChildWorktree, inspectWorktreeDirty } from "./worktree.js";
|
|
6
7
|
import { runGit } from "./git-process.js";
|
|
7
8
|
import { IntegrationGit } from "./integration-git.js";
|
|
@@ -19,23 +20,8 @@ export const CLEANUP_SAFETY_BUDGET_MS = 30_000;
|
|
|
19
20
|
export const TERMINATION_SAFETY_BUDGET_MS = 15_000;
|
|
20
21
|
export const STATUS_INSPECTION_BUDGET_MS = 5_000;
|
|
21
22
|
const ALLOCATION_KINDS = ["worktree", "workspace", "worker_tab", "agent"];
|
|
22
|
-
const FULL_SUITE = { command: "pnpm", args: ["test"] };
|
|
23
23
|
const MAX_RETAINED_INTEGRATION_GENERATIONS = 2;
|
|
24
|
-
|
|
25
|
-
let manifest;
|
|
26
|
-
try {
|
|
27
|
-
manifest = JSON.parse(readFileSync(`${root}/package.json`, "utf8"));
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
throw new Error("Canonical root package.json with a test script is required for isolated changesets.");
|
|
31
|
-
}
|
|
32
|
-
if (!manifest || typeof manifest !== "object" || !('scripts' in manifest)
|
|
33
|
-
|| !manifest.scripts || typeof manifest.scripts !== "object"
|
|
34
|
-
|| !('test' in manifest.scripts) || typeof manifest.scripts.test !== "string"
|
|
35
|
-
|| !manifest.scripts.test.trim())
|
|
36
|
-
throw new Error("Canonical root test script is missing.");
|
|
37
|
-
return manifest.scripts.test;
|
|
38
|
-
}
|
|
24
|
+
const MAX_FINAL_CHECKS = 32;
|
|
39
25
|
/** Run one launch action and surface cleanup failure, including after action failure or abort. */
|
|
40
26
|
export async function withTransientLaunch(handle, operation) {
|
|
41
27
|
let failed = false;
|
|
@@ -616,12 +602,15 @@ export class IsolatedRunner {
|
|
|
616
602
|
integrationGit;
|
|
617
603
|
textExecutor;
|
|
618
604
|
currentPolicy;
|
|
605
|
+
/** The configured `repoSuites`, read at admission only. */
|
|
606
|
+
repoSuites;
|
|
619
607
|
productiveLifecycles = new Map();
|
|
620
608
|
followupControls = new Map();
|
|
621
609
|
constructor(coordinatorRuntime, hostRuntime, gitRuntime, store = new FileRunStore(), textExecutor, policy = {
|
|
622
610
|
maxSubagents: 5, maxTurns: 50, childIdleMs: 600_000, childMaxMs: 1_800_000,
|
|
623
611
|
maxCorrections: 1,
|
|
624
|
-
}, currentPolicy = () => policy, integrationGit = new IntegrationGit()) {
|
|
612
|
+
}, currentPolicy = () => policy, integrationGit = new IntegrationGit(), repoSuites = () => ({})) {
|
|
613
|
+
this.repoSuites = repoSuites;
|
|
625
614
|
this.coordinatorRuntime = coordinatorRuntime;
|
|
626
615
|
this.hostRuntime = hostRuntime;
|
|
627
616
|
this.gitRuntime = gitRuntime;
|
|
@@ -833,11 +822,17 @@ export class IsolatedRunner {
|
|
|
833
822
|
const root = realpathSync.native(prepared.root);
|
|
834
823
|
if (root !== prepared.root)
|
|
835
824
|
throw new Error("Preflight repository root must be canonical.");
|
|
825
|
+
let fullSuite;
|
|
836
826
|
if (request.tasks.some((task) => task.kind === "changeset")) {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
827
|
+
// Main is clean and committed here, so the working files are the committed sources.
|
|
828
|
+
const repoSuites = this.repoSuites();
|
|
829
|
+
const configured = Object.keys(repoSuites).length ? configuredSuiteFor(repoSuites, await primaryCheckout(root)) : undefined;
|
|
830
|
+
fullSuite = resolveFullSuite(root, configured);
|
|
831
|
+
if (!request.finalChecks.some((check) => sameCheck(check, fullSuite.check))) {
|
|
832
|
+
if (request.finalChecks.length >= MAX_FINAL_CHECKS) {
|
|
833
|
+
throw new Error(`The Full suite ${formatCheckCommand(fullSuite.check)} cannot be added: the request already has ${MAX_FINAL_CHECKS} final checks.`);
|
|
834
|
+
}
|
|
835
|
+
request.finalChecks = [fullSuite.check, ...request.finalChecks];
|
|
841
836
|
}
|
|
842
837
|
}
|
|
843
838
|
const preparedMain = runtimeIdentity(prepared.main, "Preflight Main identity");
|
|
@@ -846,22 +841,23 @@ export class IsolatedRunner {
|
|
|
846
841
|
if (this.hasAnyActiveControl(root)) {
|
|
847
842
|
throw new Error("Another Pi Subagent request has active changeset work in this repository.");
|
|
848
843
|
}
|
|
849
|
-
return { request, policy, root, preparedMain, routes: prepared.routes ?? [] };
|
|
844
|
+
return { request, policy, root, preparedMain, routes: prepared.routes ?? [], ...(fullSuite ? { fullSuite } : {}) };
|
|
850
845
|
}
|
|
851
846
|
/** Dry run of execute: no state, lock, lease, worktree, or Herdr call. */
|
|
852
847
|
async plan(value, cwd, outerSignal) {
|
|
853
|
-
const { request, root, preparedMain, routes } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true);
|
|
854
|
-
return { request, root, main: preparedMain, routes };
|
|
848
|
+
const { request, root, preparedMain, routes, fullSuite } = await this.admitExecute(value, cwd, new ProductiveScope(outerSignal), true);
|
|
849
|
+
return { request, root, main: preparedMain, routes, ...(fullSuite ? { fullSuite } : {}) };
|
|
855
850
|
}
|
|
856
851
|
async execute(value, cwd, outerSignal) {
|
|
857
852
|
const scope = new ProductiveScope(outerSignal);
|
|
858
|
-
const { request, policy, root, preparedMain } = await this.admitExecute(value, cwd, scope, false);
|
|
853
|
+
const { request, policy, root, preparedMain, fullSuite } = await this.admitExecute(value, cwd, scope, false);
|
|
859
854
|
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
860
855
|
const createdAt = this.coordinatorRuntime.now();
|
|
861
856
|
const state = {
|
|
862
857
|
version: RUN_STATE_VERSION,
|
|
863
858
|
request,
|
|
864
859
|
policy,
|
|
860
|
+
...(fullSuite ? { fullSuite } : {}),
|
|
865
861
|
correctionCount: 0,
|
|
866
862
|
root,
|
|
867
863
|
requestStartMain: preparedMain,
|
|
@@ -1478,12 +1474,12 @@ export class IsolatedRunner {
|
|
|
1478
1474
|
generation.status = "validating";
|
|
1479
1475
|
await this.saveProductive(handle); // Record the exact validation intent before running checks.
|
|
1480
1476
|
try {
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
throw new Error("
|
|
1484
|
-
if (requireRootSuite(generation.worktree.path) !== requireRootSuite(root)) {
|
|
1485
|
-
throw new Error("Combined tip changed the admitted canonical root test script.");
|
|
1477
|
+
const fullSuite = state.fullSuite;
|
|
1478
|
+
if (!fullSuite || !request.finalChecks.some((check) => sameCheck(check, fullSuite.check))) {
|
|
1479
|
+
throw new Error("The Full suite was not recorded at admission.");
|
|
1486
1480
|
}
|
|
1481
|
+
// The saved suite runs as admitted. A later config edit does not change it.
|
|
1482
|
+
assertFullSuiteSource(fullSuite, generation.worktree.path);
|
|
1487
1483
|
await this.requireExactIntegration(handle, generation, receipts, scope);
|
|
1488
1484
|
const sources = stagedCheckSources(generation, state.integration.candidates);
|
|
1489
1485
|
const checks = await this.runCheckBatch(handle, request.finalChecks, action.expectedTip, "final", scope, undefined, generation.worktree.path, request.finalChecks.map((check) => reusableCheckSource(check, action.expectedTip, sources)));
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Type, type Static } from "typebox";
|
|
2
|
-
export declare const RUN_STATE_VERSION =
|
|
2
|
+
export declare const RUN_STATE_VERSION = 6;
|
|
3
3
|
export declare const MAX_TASKS = 8;
|
|
4
4
|
export declare const MAX_EXECUTE_REQUEST_BYTES: number;
|
|
5
5
|
export declare const MAX_PERSISTED_RUNTIME_TEXT_BYTES: number;
|
|
@@ -10,6 +10,24 @@ export declare const CheckCommandSchema: Type.TObject<{
|
|
|
10
10
|
command: Type.TString;
|
|
11
11
|
args: Type.TArray<Type.TString>;
|
|
12
12
|
}>;
|
|
13
|
+
/** The Full suite fixed at admission, with its source. Validation checks the same source again. */
|
|
14
|
+
export declare const FullSuiteSchema: Type.TObject<{
|
|
15
|
+
check: Type.TObject<{
|
|
16
|
+
command: Type.TString;
|
|
17
|
+
args: Type.TArray<Type.TString>;
|
|
18
|
+
}>;
|
|
19
|
+
source: Type.TUnion<[Type.TObject<{
|
|
20
|
+
kind: Type.TLiteral<"config">;
|
|
21
|
+
}>, Type.TObject<{
|
|
22
|
+
kind: Type.TLiteral<"package-json">;
|
|
23
|
+
testScript: Type.TString;
|
|
24
|
+
}>, Type.TObject<{
|
|
25
|
+
kind: Type.TLiteral<"makefile">;
|
|
26
|
+
file: Type.TUnion<[Type.TLiteral<"GNUmakefile">, Type.TLiteral<"makefile">, Type.TLiteral<"Makefile">]>;
|
|
27
|
+
sha256: Type.TString;
|
|
28
|
+
}>]>;
|
|
29
|
+
}>;
|
|
30
|
+
export type AdmittedFullSuite = Static<typeof FullSuiteSchema>;
|
|
13
31
|
/** One display form for a check command; quoted args keep argv boundaries visible. */
|
|
14
32
|
export declare function formatCheckCommand({ command, args }: {
|
|
15
33
|
command: string;
|
|
@@ -1756,7 +1774,7 @@ export type IntegrationState = Static<typeof IntegrationStateSchema>;
|
|
|
1756
1774
|
declare const TerminalRunStatusSchema: Type.TUnion<[Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
|
|
1757
1775
|
export declare const TERMINAL_RUN_STATUSES: readonly Static<typeof TerminalRunStatusSchema>[];
|
|
1758
1776
|
declare const RunStateSchema: Type.TObject<{
|
|
1759
|
-
version: Type.TLiteral<
|
|
1777
|
+
version: Type.TLiteral<6>;
|
|
1760
1778
|
request: Type.TObject<{
|
|
1761
1779
|
mode: Type.TLiteral<"isolated">;
|
|
1762
1780
|
id: Type.TString;
|
|
@@ -1807,6 +1825,23 @@ declare const RunStateSchema: Type.TObject<{
|
|
|
1807
1825
|
childMaxMs: Type.TNumber;
|
|
1808
1826
|
maxCorrections: Type.TInteger;
|
|
1809
1827
|
}>;
|
|
1828
|
+
/** Present exactly when the request has a changeset task. */
|
|
1829
|
+
fullSuite: Type.TOptional<Type.TObject<{
|
|
1830
|
+
check: Type.TObject<{
|
|
1831
|
+
command: Type.TString;
|
|
1832
|
+
args: Type.TArray<Type.TString>;
|
|
1833
|
+
}>;
|
|
1834
|
+
source: Type.TUnion<[Type.TObject<{
|
|
1835
|
+
kind: Type.TLiteral<"config">;
|
|
1836
|
+
}>, Type.TObject<{
|
|
1837
|
+
kind: Type.TLiteral<"package-json">;
|
|
1838
|
+
testScript: Type.TString;
|
|
1839
|
+
}>, Type.TObject<{
|
|
1840
|
+
kind: Type.TLiteral<"makefile">;
|
|
1841
|
+
file: Type.TUnion<[Type.TLiteral<"GNUmakefile">, Type.TLiteral<"makefile">, Type.TLiteral<"Makefile">]>;
|
|
1842
|
+
sha256: Type.TString;
|
|
1843
|
+
}>]>;
|
|
1844
|
+
}>>;
|
|
1810
1845
|
correctionCount: Type.TInteger;
|
|
1811
1846
|
root: Type.TString;
|
|
1812
1847
|
requestStartMain: Type.TObject<{
|
|
@@ -2429,7 +2464,7 @@ declare const RunStateSchema: Type.TObject<{
|
|
|
2429
2464
|
*/
|
|
2430
2465
|
export declare const StatusOutputSchema: Type.TObject<{
|
|
2431
2466
|
state: Type.TObject<{
|
|
2432
|
-
version: Type.TLiteral<
|
|
2467
|
+
version: Type.TLiteral<6>;
|
|
2433
2468
|
id: Type.TString;
|
|
2434
2469
|
status: Type.TUnion<[Type.TLiteral<"pending">, Type.TLiteral<"running">, Type.TLiteral<"needs_attention">, Type.TLiteral<"completed">, Type.TLiteral<"final_failed">, Type.TLiteral<"superseded">, Type.TLiteral<"aborted">]>;
|
|
2435
2470
|
accepted: Type.TBoolean;
|
package/dist/schema.js
CHANGED
|
@@ -2,7 +2,7 @@ import { isAbsolute } from "node:path";
|
|
|
2
2
|
import { parseRoleName } from "./index.js";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
import { Check, Errors } from "typebox/value";
|
|
5
|
-
export const RUN_STATE_VERSION =
|
|
5
|
+
export const RUN_STATE_VERSION = 6;
|
|
6
6
|
export const MAX_TASKS = 8;
|
|
7
7
|
export const MAX_EXECUTE_REQUEST_BYTES = 256 * 1024;
|
|
8
8
|
export const MAX_PERSISTED_RUNTIME_TEXT_BYTES = 8 * 1024;
|
|
@@ -26,6 +26,19 @@ export const CheckCommandSchema = Type.Object({
|
|
|
26
26
|
command: TextSchema,
|
|
27
27
|
args: Type.Array(Type.String({ maxLength: 16_000 }), { maxItems: 128 }),
|
|
28
28
|
}, { additionalProperties: false });
|
|
29
|
+
/** The Full suite fixed at admission, with its source. Validation checks the same source again. */
|
|
30
|
+
export const FullSuiteSchema = Type.Object({
|
|
31
|
+
check: CheckCommandSchema,
|
|
32
|
+
source: Type.Union([
|
|
33
|
+
Type.Object({ kind: Type.Literal("config") }, { additionalProperties: false }),
|
|
34
|
+
Type.Object({ kind: Type.Literal("package-json"), testScript: Type.String({ minLength: 1, maxLength: 16_000 }) }, { additionalProperties: false }),
|
|
35
|
+
Type.Object({
|
|
36
|
+
kind: Type.Literal("makefile"),
|
|
37
|
+
file: Type.Union([Type.Literal("GNUmakefile"), Type.Literal("makefile"), Type.Literal("Makefile")]),
|
|
38
|
+
sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }),
|
|
39
|
+
}, { additionalProperties: false }),
|
|
40
|
+
]),
|
|
41
|
+
}, { additionalProperties: false });
|
|
29
42
|
/** One display form for a check command; quoted args keep argv boundaries visible. */
|
|
30
43
|
export function formatCheckCommand({ command, args }) {
|
|
31
44
|
return [command, ...args.map((arg) => JSON.stringify(arg))].join(" ");
|
|
@@ -420,6 +433,8 @@ const RunStateSchema = Type.Object({
|
|
|
420
433
|
version: Type.Literal(RUN_STATE_VERSION),
|
|
421
434
|
request: ExecuteRequestSchema,
|
|
422
435
|
policy: ExecutionPolicySnapshotSchema,
|
|
436
|
+
/** Present exactly when the request has a changeset task. */
|
|
437
|
+
fullSuite: Type.Optional(FullSuiteSchema),
|
|
423
438
|
correctionCount: Type.Integer({ minimum: 0 }),
|
|
424
439
|
root: TextSchema,
|
|
425
440
|
requestStartMain: WorkspaceSchema,
|
|
@@ -1015,6 +1030,13 @@ export function parseRunState(value) {
|
|
|
1015
1030
|
const state = value;
|
|
1016
1031
|
const request = parseExecuteRequest(state.request);
|
|
1017
1032
|
parseIntegrationState(state.integration, request);
|
|
1033
|
+
const changeset = request.tasks.some((task) => task.kind === "changeset");
|
|
1034
|
+
if (changeset !== (state.fullSuite !== undefined))
|
|
1035
|
+
throw new Error("A Full suite must be recorded exactly for a request with changeset tasks.");
|
|
1036
|
+
if (state.fullSuite && !request.finalChecks.some(({ command, args }) => command === state.fullSuite.check.command
|
|
1037
|
+
&& args.length === state.fullSuite.check.args.length && args.every((arg, index) => arg === state.fullSuite.check.args[index]))) {
|
|
1038
|
+
throw new Error("The recorded Full suite is not in the request final checks.");
|
|
1039
|
+
}
|
|
1018
1040
|
if (state.status !== "aborted" && state.integration.generations.some((generation) => generation.supersededFrom === "discarded")) {
|
|
1019
1041
|
throw new Error("User-discarded generations require an aborted request.");
|
|
1020
1042
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export declare class FileRunStore {
|
|
|
53
53
|
invalidIds: string[];
|
|
54
54
|
}>;
|
|
55
55
|
private stateEntries;
|
|
56
|
-
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
56
|
+
/** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
|
|
57
57
|
assertLegacyAdmissionSafe(root: string): Promise<void>;
|
|
58
58
|
list(root: string): Promise<{
|
|
59
59
|
states: RunStateHandle[];
|
package/dist/store.js
CHANGED
|
@@ -18,8 +18,10 @@ const INITIAL_STATE_MAX_BYTES = 2 * 1024 * 1024;
|
|
|
18
18
|
const STATE_MAX_BYTES = 128 * 1024 * 1024;
|
|
19
19
|
const ADMISSION_MAX_FILES = 256;
|
|
20
20
|
const ADMISSION_MAX_BYTES = STATE_MAX_BYTES;
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
// v4 and v5 use the same request statuses. Admission reads only the version and the status of older files.
|
|
22
|
+
const LEGACY_VERSIONS = new Set([4, 5]);
|
|
23
|
+
const LEGACY_TERMINAL_STATUSES = new Set(["completed", "final_failed", "superseded", "aborted"]);
|
|
24
|
+
const LEGACY_UNFINISHED_STATUSES = new Set(["pending", "running", "needs_attention"]);
|
|
23
25
|
const LOCK_OPTIONS = { realpath: false, stale: 30_000, update: 5_000, retries: 0 };
|
|
24
26
|
const LIFECYCLE_LOCK_OPTIONS = {
|
|
25
27
|
...LOCK_OPTIONS,
|
|
@@ -317,7 +319,7 @@ export class FileRunStore {
|
|
|
317
319
|
throw error;
|
|
318
320
|
}
|
|
319
321
|
}
|
|
320
|
-
/** Read only version and request status; never migrate or interpret v4 worker evidence. */
|
|
322
|
+
/** Read only version and request status; never migrate or interpret v4 or v5 worker evidence. */
|
|
321
323
|
async assertLegacyAdmissionSafe(root) {
|
|
322
324
|
const directory = this.stateDirectory(root);
|
|
323
325
|
await this.assertSafeDestination(root, directory);
|
|
@@ -348,12 +350,12 @@ export class FileRunStore {
|
|
|
348
350
|
}
|
|
349
351
|
if (value.version === RUN_STATE_VERSION)
|
|
350
352
|
continue;
|
|
351
|
-
if (value.version !==
|
|
352
|
-
|| (!
|
|
353
|
+
if (typeof value.version !== "number" || !LEGACY_VERSIONS.has(value.version) || !("status" in value) || typeof value.status !== "string"
|
|
354
|
+
|| (!LEGACY_TERMINAL_STATUSES.has(value.status) && !LEGACY_UNFINISHED_STATUSES.has(value.status))) {
|
|
353
355
|
throw new Error(`Pi Subagent malformed or unsupported legacy state ${id} blocks safe admission. ${recovery}`);
|
|
354
356
|
}
|
|
355
|
-
if (
|
|
356
|
-
throw new Error(`Pi Subagent unfinished
|
|
357
|
+
if (LEGACY_UNFINISHED_STATUSES.has(value.status)) {
|
|
358
|
+
throw new Error(`Pi Subagent unfinished v${value.version} request ${id} (${value.status}) blocks new isolated work. ${recovery}`);
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
}
|
package/docs/orchestration.md
CHANGED
|
@@ -81,7 +81,7 @@ Direct `changeset` is rejected; use isolated mode for checked integration. Resou
|
|
|
81
81
|
|
|
82
82
|
### Dry run
|
|
83
83
|
|
|
84
|
-
Set `"dryRun": true` on a direct or isolated `delegate_task` request to check it without a launch. The dry run does the same checks as a launch: request shape, Role lookup, route and model with thinking level, Skills, tools, MCP allowlist, Role extensions, config, and `continueFrom` eligibility. For an isolated request, it also checks the task graph and that Main is clean and committed. If the request has a changeset task, it also
|
|
84
|
+
Set `"dryRun": true` on a direct or isolated `delegate_task` request to check it without a launch. The dry run does the same checks as a launch: request shape, Role lookup, route and model with thinking level, Skills, tools, MCP allowlist, Role extensions, config, and `continueFrom` eligibility. For an isolated request, it also checks the task graph and that Main is clean and committed. If the request has a changeset task, it also selects the Full suite, adds it to the final checks, and shows it. A failed check gives the same error as a launch.
|
|
85
85
|
|
|
86
86
|
The result is a bounded plan. It shows each task's Role, resolved `provider/model`, thinking level, access or kind, dependencies, and checks. An isolated plan also shows the final checks. The dry run makes no Herdr call, Herdr tab, session record, saved state, worktree, lease, checkout admission, or follow-up. The plan lists each check it cannot do without such an effect as `Not checked: <reason>`. Examples are the Herdr host preflight, checkout admission, and the stop of a continued agent.
|
|
87
87
|
|
|
@@ -257,7 +257,7 @@ Every task declares `id`, `kind`, `role`, `modelClass`, `requirements`, `deliver
|
|
|
257
257
|
- A `changeset` task requires focused checks and owns one worktree, Herdr workspace, worker tab, and retained worker.
|
|
258
258
|
- `dependsOn` controls ready waves; dependent tasks can run from a selected staged snapshot only after Main explicitly advances it.
|
|
259
259
|
- `contextFrom` may reference completed text tasks only, implies scheduling dependencies, and preserves declaration order. It must not overlap `dependsOn`.
|
|
260
|
-
- A graph containing changesets requires final checks.
|
|
260
|
+
- A graph containing changesets requires final checks. Admission selects one Full suite: the `repoSuites` config entry, then `pnpm test` for a root `package.json` test script, then `make test` for a literal Makefile `test` target. It prepends the suite to the final checks if absent and saves the suite with its source in state v6. Combined validation rejects a changed root test script or Makefile. A config change after admission does not change the saved suite.
|
|
261
261
|
|
|
262
262
|
Admission and worker-wave dispatch pin Main's exact committed branch, HEAD, and tree without inspecting or modifying its staged, unstaged, or untracked edits. Worker allocation verifies the same committed identity around worktree creation, including resumed waves; staged-dependent waves verify their clean integration snapshot. A dirty Main can therefore produce a checked retained candidate, but branch/HEAD drift stops new waves. The runner executes ready worker tasks concurrently, inspects clean committed candidates, runs preliminary task checks and optional preliminary judgment, then records ready candidates for Main. **It does not automatically integrate them.**
|
|
263
263
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { inspectPruneCandidates, listHerdrPanes, removePruneCandidates, type Pru
|
|
|
16
16
|
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
17
17
|
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
18
18
|
import { IsolatedRunner, nextIntegrationAction, type ExecutePlan, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
19
|
+
import { describeFullSuite } from "../dist/full-suite.js";
|
|
19
20
|
import {
|
|
20
21
|
IdOnlySchema,
|
|
21
22
|
ResumeRequestParameters,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
sameIdentity,
|
|
30
31
|
StatusOutputSchema,
|
|
31
32
|
type CheckBatchEvidence,
|
|
33
|
+
type CheckCommand,
|
|
32
34
|
type ExecuteRequest,
|
|
33
35
|
type ExecutionPolicySnapshot,
|
|
34
36
|
type ModelClass,
|
|
@@ -61,12 +63,14 @@ export type CreateIsolatedComponents = (options: {
|
|
|
61
63
|
executor: EphemeralSubagentExecutor;
|
|
62
64
|
policy: ExecutionPolicySnapshot;
|
|
63
65
|
currentPolicy(): ExecutionPolicySnapshot;
|
|
66
|
+
/** The configured Full suite overrides. Admission reads them after the policy check. */
|
|
67
|
+
repoSuites?(): Readonly<Record<string, CheckCommand>>;
|
|
64
68
|
onStateSaved(state: RunState): void;
|
|
65
69
|
}) => IsolatedExtensionComponents;
|
|
66
70
|
|
|
67
71
|
/** Construct the checked runtime while reusing the unified productive-assignment executor. */
|
|
68
72
|
export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
69
|
-
pi, context, executor, policy, currentPolicy, onStateSaved,
|
|
73
|
+
pi, context, executor, policy, currentPolicy, repoSuites, onStateSaved,
|
|
70
74
|
}) => {
|
|
71
75
|
const runProcess: DirectProcessRunner = async (command, args, options) => options.stdin === undefined
|
|
72
76
|
? await pi.exec(command, args, { cwd: options.cwd, signal: options.signal, timeout: options.timeoutMs })
|
|
@@ -96,6 +100,8 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
96
100
|
executor,
|
|
97
101
|
policy,
|
|
98
102
|
currentPolicy,
|
|
103
|
+
undefined,
|
|
104
|
+
repoSuites,
|
|
99
105
|
),
|
|
100
106
|
};
|
|
101
107
|
};
|
|
@@ -473,7 +479,8 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
473
479
|
}
|
|
474
480
|
|
|
475
481
|
/** Bounded dry-run plan; a route missing from the coordinator report shows as unresolved. */
|
|
476
|
-
export function formatExecutePlan(
|
|
482
|
+
export function formatExecutePlan(plan: ExecutePlan, hostChecked: boolean): { content: [{ type: "text"; text: string }]; details: unknown } {
|
|
483
|
+
const { request, root, main, routes } = plan;
|
|
477
484
|
const route = (role: string, modelClass: ModelClass) => {
|
|
478
485
|
const found = routes.find((candidate) => candidate.role === role && candidate.modelClass === modelClass);
|
|
479
486
|
return found ? { model: found.model, thinkingLevel: found.thinkingLevel } : undefined;
|
|
@@ -495,6 +502,7 @@ export function formatExecutePlan({ request, root, main, routes }: ExecutePlan,
|
|
|
495
502
|
...(task.kind === "changeset" ? [` checks: ${task.checks.map(formatCheckCommand).join("; ")}`,
|
|
496
503
|
...(task.judgment ? [` judgment: ${label(task.judgment.role, task.judgment.modelClass)}`] : [])] : []),
|
|
497
504
|
].join("\n")),
|
|
505
|
+
...(plan.fullSuite ? [`Full suite: ${describeFullSuite(plan.fullSuite)}`] : []),
|
|
498
506
|
`Final checks: ${request.finalChecks.map(formatCheckCommand).join("; ") || "none"}`,
|
|
499
507
|
...(request.finalJudgment ? [`Final judgment: ${label(request.finalJudgment.role, request.finalJudgment.modelClass)}`] : []),
|
|
500
508
|
...notChecked.map((reason) => `Not checked: ${reason}.`),
|
|
@@ -507,6 +515,7 @@ export function formatExecutePlan({ request, root, main, routes }: ExecutePlan,
|
|
|
507
515
|
...route(task.role, task.modelClass), dependsOn: task.dependsOn, contextFrom: task.contextFrom,
|
|
508
516
|
...(task.kind === "changeset" ? { checks: task.checks, ...(task.judgment ? { judgment: { ...task.judgment, ...route(task.judgment.role, task.judgment.modelClass) } } : {}) } : {}) })),
|
|
509
517
|
finalChecks: request.finalChecks,
|
|
518
|
+
...(plan.fullSuite ? { fullSuite: plan.fullSuite } : {}),
|
|
510
519
|
...(request.finalJudgment ? { finalJudgment: { ...request.finalJudgment, ...route(request.finalJudgment.role, request.finalJudgment.modelClass) } } : {}),
|
|
511
520
|
notChecked,
|
|
512
521
|
},
|
|
@@ -533,6 +542,8 @@ export interface RegisterIsolatedOptions {
|
|
|
533
542
|
executor: EphemeralSubagentExecutor;
|
|
534
543
|
policy: ExecutionPolicySnapshot;
|
|
535
544
|
currentPolicy(): ExecutionPolicySnapshot;
|
|
545
|
+
/** The configured Full suite overrides for isolated changesets. */
|
|
546
|
+
repoSuites?(): Readonly<Record<string, CheckCommand>>;
|
|
536
547
|
/** Report new config issues, and throw when the config now blocks delegation, before each deferred Role launch. */
|
|
537
548
|
beforeRoleLaunch?(ctx: ExtensionContext): void;
|
|
538
549
|
componentsFactory?: CreateIsolatedComponents;
|
|
@@ -566,6 +577,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
566
577
|
executor: options.executor,
|
|
567
578
|
policy: options.policy,
|
|
568
579
|
currentPolicy: options.currentPolicy,
|
|
580
|
+
...(options.repoSuites ? { repoSuites: options.repoSuites } : {}),
|
|
569
581
|
onStateSaved: (state) => {
|
|
570
582
|
savedRevision += 1;
|
|
571
583
|
for (const listener of stateListeners) listener(state);
|
package/extensions/subagent.ts
CHANGED
|
@@ -255,6 +255,8 @@ export default function subagentExtension(pi: ExtensionAPI): void {
|
|
|
255
255
|
executor,
|
|
256
256
|
policy: initialPolicy,
|
|
257
257
|
currentPolicy,
|
|
258
|
+
// Admission reads these after currentPolicy, which throws for a disabled config.
|
|
259
|
+
repoSuites: () => { const loaded = configStore.loadSync(); return loaded.status === "ok" ? loaded.value.repoSuites : {}; },
|
|
258
260
|
beforeRoleLaunch,
|
|
259
261
|
});
|
|
260
262
|
let directSequence = 0;
|
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ To build on a finished direct worker, add `continueFrom` with its reference (suc
|
|
|
30
30
|
|
|
31
31
|
# Isolated requests
|
|
32
32
|
|
|
33
|
-
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks.
|
|
33
|
+
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. Admission adds the repository's Full suite to final checks if missing: the `repoSuites` config entry, `pnpm test` for a root `package.json` test script, or `make test` for a Makefile `test` target. The suite runs against the combined tip before Main promotion. If no suite exists, tell the user to add one or configure `repoSuites`. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
|
|
34
34
|
|
|
35
35
|
Every changeset becomes ready automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. If a worker's turn ends with uncommitted changes, the runner sends it one commit reminder per attempt; this uses no correction, and a worktree still dirty afterwards needs attention. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
36
36
|
|