@henryqw/pi-subagent 18.0.1 → 22.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CONTEXT.md +76 -24
  2. package/README.md +116 -142
  3. package/dist/composition.d.ts +11 -0
  4. package/dist/composition.js +107 -0
  5. package/dist/direct-herdr.d.ts +17 -0
  6. package/dist/direct-herdr.js +219 -0
  7. package/dist/ephemeral.d.ts +2 -2
  8. package/dist/ephemeral.js +7 -7
  9. package/dist/git-runtime.d.ts +110 -0
  10. package/dist/git-runtime.js +472 -0
  11. package/dist/herdr-runtime.d.ts +137 -0
  12. package/dist/herdr-runtime.js +1228 -0
  13. package/dist/index.d.ts +11 -5
  14. package/dist/index.js +20 -16
  15. package/dist/integration-git.d.ts +59 -0
  16. package/dist/integration-git.js +484 -0
  17. package/dist/launch-runtime.d.ts +54 -0
  18. package/dist/launch-runtime.js +195 -0
  19. package/dist/process.d.ts +9 -0
  20. package/dist/process.js +11 -0
  21. package/dist/runner.d.ts +356 -0
  22. package/dist/runner.js +2710 -0
  23. package/dist/schema.d.ts +2234 -0
  24. package/dist/schema.js +1236 -0
  25. package/dist/store.d.ts +50 -0
  26. package/dist/store.js +288 -0
  27. package/dist/working-evidence.d.ts +25 -0
  28. package/dist/working-evidence.js +261 -0
  29. package/docs/adr/001-composable-ephemeral-execution.md +3 -3
  30. package/docs/delegation-routing.html +89 -0
  31. package/docs/delegation-routing.svg +78 -0
  32. package/docs/orchestration-architecture.html +238 -0
  33. package/docs/orchestration-architecture.svg +189 -0
  34. package/docs/orchestration.md +122 -331
  35. package/example.png +0 -0
  36. package/examples/roles/implementer.md +2 -3
  37. package/extensions/admission.ts +66 -0
  38. package/extensions/config.ts +51 -37
  39. package/extensions/isolated.ts +638 -0
  40. package/extensions/result-transport.ts +13 -103
  41. package/extensions/role-tools.ts +11 -11
  42. package/extensions/subagent-command.ts +167 -0
  43. package/extensions/subagent.ts +264 -540
  44. package/extensions/task-name.ts +1 -2
  45. package/extensions/workflow.ts +60 -96
  46. package/package.json +14 -6
  47. package/skills/pi-subagent/SKILL.md +35 -0
@@ -0,0 +1,472 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { createChildWorktree, inspectIndexFlags, inspectWorktreeDirty, WorktreeSetupError, } from "./worktree.js";
3
+ import { prepareExactReviewEvidence } from "./review-evidence.js";
4
+ import { sameIdentity, } from "./schema.js";
5
+ import { withTransientLaunch, } from "./runner.js";
6
+ import { runProcess as defaultRunProcess } from "./process.js";
7
+ const GIT_OPERATION_CAP_MS = 30_000;
8
+ const DIAGNOSTIC_LIMIT = 1_000;
9
+ function text(error) {
10
+ return (error instanceof Error ? error.message : String(error)).slice(0, DIAGNOSTIC_LIMIT);
11
+ }
12
+ function oneLine(value, field) {
13
+ const line = value.replace(/\r?\n$/, "");
14
+ if (!line || /[\r\n\0]/.test(line))
15
+ throw new Error(`Git returned malformed ${field}.`);
16
+ return line;
17
+ }
18
+ function oid(value, field) {
19
+ const result = oneLine(value, field).toLowerCase();
20
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(result))
21
+ throw new Error(`Git returned invalid ${field}.`);
22
+ return result;
23
+ }
24
+ function commandFailure(args, result) {
25
+ const detail = (result.stderr.trim() || result.stdout.trim()).slice(0, DIAGNOSTIC_LIMIT);
26
+ return `git ${args.map((arg) => JSON.stringify(arg)).join(" ")} failed with exit ${result.code}${detail ? `: ${detail}` : ""}`;
27
+ }
28
+ function worktreeIntent(attempt) {
29
+ const intent = [...attempt.allocations].reverse().find((candidate) => candidate.kind === "worktree" && candidate.status === "owned");
30
+ if (!intent?.worktree || intent.worktree.path !== intent.worktree.cwd) {
31
+ throw new Error("Task has no exact owned worktree allocation metadata.");
32
+ }
33
+ return intent.worktree;
34
+ }
35
+ function cloneWorktree(info) {
36
+ return {
37
+ path: info.path,
38
+ cwd: info.cwd,
39
+ branch: info.branch,
40
+ repoRoot: info.repoRoot,
41
+ baseCommit: info.baseCommit.toLowerCase(),
42
+ };
43
+ }
44
+ function pathMissing(error) {
45
+ return Boolean(error && typeof error === "object" && error.code === "ENOENT");
46
+ }
47
+ async function pathExists(path) {
48
+ try {
49
+ await lstat(path);
50
+ return true;
51
+ }
52
+ catch (error) {
53
+ if (pathMissing(error))
54
+ return false;
55
+ throw error;
56
+ }
57
+ }
58
+ export class CheckedGitRuntime {
59
+ execute;
60
+ executeReview;
61
+ mainOperationTails = new Map();
62
+ constructor(options = {}) {
63
+ this.execute = options.runProcess ?? defaultRunProcess;
64
+ this.executeReview = options.executeReview;
65
+ }
66
+ async inspectMain(input, context) {
67
+ return await this.serializeMainOperation(input.root, context.signal, async () => {
68
+ return await this.inspectWorkspace(input.root, false, context);
69
+ });
70
+ }
71
+ async allocateWorktree(input, context) {
72
+ return await this.serializeMainOperation(input.root, context.signal, async () => {
73
+ return await this.allocateWorktreeExclusive(input, context);
74
+ });
75
+ }
76
+ async allocateWorktreeExclusive(input, context) {
77
+ let prepared;
78
+ let createdWorktree;
79
+ const baseRoot = input.baseRoot ?? input.root;
80
+ const before = await this.inspectWorkspace(baseRoot, false, context);
81
+ if (!sameIdentity(before, input.attempt.waveBase)) {
82
+ return { kind: "worktree", outcome: "absent", failure: "Recorded wave snapshot drifted before worktree allocation." };
83
+ }
84
+ try {
85
+ const created = await createChildWorktree(baseRoot, `isolated-${input.task.id}-${input.intent.token}`, this.gitRunner(context), context.signal, async (info) => {
86
+ prepared = cloneWorktree(info);
87
+ if (prepared.baseCommit !== input.attempt.waveBase.head) {
88
+ throw new Error("pi-subagent prepared a worktree from a base other than the recorded wave base.");
89
+ }
90
+ await input.onPrepared(prepared);
91
+ const current = await this.inspectWorkspace(baseRoot, false, context);
92
+ if (!sameIdentity(current, input.attempt.waveBase)) {
93
+ throw new Error("Recorded wave snapshot drifted after worktree preparation and before git worktree add.");
94
+ }
95
+ });
96
+ if (!created)
97
+ return { kind: "worktree", outcome: "absent", failure: "Git worktree allocation requires a committed repository." };
98
+ const worktree = cloneWorktree(created);
99
+ createdWorktree = worktree;
100
+ if (!prepared || worktree.baseCommit !== input.attempt.waveBase.head
101
+ || JSON.stringify(worktree) !== JSON.stringify(prepared)) {
102
+ return {
103
+ kind: "worktree",
104
+ outcome: "unknown",
105
+ failure: "Created worktree did not match its exact persisted preparation metadata.",
106
+ possibleResources: [worktree.path, worktree.branch],
107
+ };
108
+ }
109
+ const after = await this.inspectWorkspace(baseRoot, false, context);
110
+ if (!sameIdentity(after, input.attempt.waveBase)) {
111
+ return {
112
+ kind: "worktree",
113
+ outcome: "unknown",
114
+ failure: "Main drifted during worktree setup; the created worktree remains retained.",
115
+ possibleResources: [worktree.path, worktree.branch],
116
+ };
117
+ }
118
+ return { kind: "worktree", outcome: "owned" };
119
+ }
120
+ catch (error) {
121
+ if (error instanceof WorktreeSetupError) {
122
+ const attempted = cloneWorktree(error.worktree);
123
+ return {
124
+ kind: "worktree",
125
+ outcome: "unknown",
126
+ failure: text(error),
127
+ possibleResources: [attempted.path, attempted.branch],
128
+ };
129
+ }
130
+ if (createdWorktree) {
131
+ return {
132
+ kind: "worktree",
133
+ outcome: "unknown",
134
+ failure: `Created worktree could not be verified: ${text(error)}`,
135
+ possibleResources: [createdWorktree.path, createdWorktree.branch],
136
+ };
137
+ }
138
+ if (prepared)
139
+ return { kind: "worktree", outcome: "absent", failure: text(error) };
140
+ throw error;
141
+ }
142
+ }
143
+ async serializeMainOperation(root, signal, operation) {
144
+ const previous = this.mainOperationTails.get(root) ?? Promise.resolve();
145
+ let release;
146
+ const current = new Promise((resolve) => { release = resolve; });
147
+ const tail = previous.then(async () => await current);
148
+ this.mainOperationTails.set(root, tail);
149
+ const clearTail = () => {
150
+ if (this.mainOperationTails.get(root) === tail)
151
+ this.mainOperationTails.delete(root);
152
+ };
153
+ void tail.then(clearTail, clearTail);
154
+ let onAbort;
155
+ const aborted = new Promise((_, reject) => {
156
+ onAbort = () => reject(signal.reason ?? new Error("Main Git operation was interrupted."));
157
+ signal.addEventListener("abort", onAbort, { once: true });
158
+ });
159
+ try {
160
+ signal.throwIfAborted();
161
+ await Promise.race([previous, aborted]);
162
+ signal.throwIfAborted();
163
+ return await operation();
164
+ }
165
+ finally {
166
+ signal.removeEventListener("abort", onAbort);
167
+ release();
168
+ }
169
+ }
170
+ async reconcileWorktreeAllocation(input, context) {
171
+ const worktree = input.intent.worktree;
172
+ if (!worktree)
173
+ return { kind: "worktree", outcome: "absent" };
174
+ const [registered, branch, checkout] = await Promise.all([
175
+ this.registeredWorktreePaths(input.root, context),
176
+ this.branchExists(input.root, worktree.branch, context),
177
+ pathExists(worktree.path),
178
+ ]);
179
+ const possible = [
180
+ ...(registered.includes(worktree.path) ? [`registered worktree ${worktree.path}`] : []),
181
+ ...(checkout ? [`checkout path ${worktree.path}`] : []),
182
+ ...(branch ? [`branch ${worktree.branch}`] : []),
183
+ ];
184
+ return possible.length
185
+ ? { kind: "worktree", outcome: "possible", failure: "A possible prior Git allocation remains and was not adopted or removed.", possibleResources: possible }
186
+ : { kind: "worktree", outcome: "absent" };
187
+ }
188
+ async runChecks(input, context) {
189
+ const cwd = await this.requireScopeIdentity(input, context);
190
+ const results = [];
191
+ for (const check of input.checks) {
192
+ context.signal.throwIfAborted();
193
+ const result = await this.execute(check.command, [...check.args], {
194
+ cwd,
195
+ signal: context.signal,
196
+ timeoutMs: context.timeoutMs,
197
+ });
198
+ results.push({ ...check, ...result });
199
+ }
200
+ const identityAfter = await this.requireScopeCurrent(input, context);
201
+ return { results, identityAfter };
202
+ }
203
+ async review(input, context) {
204
+ if (!this.executeReview)
205
+ throw new Error("Exact Judgment execution is not configured.");
206
+ if (input.scope === "final") {
207
+ if (input.phase !== "final" || input.attempt)
208
+ throw new Error("Final review received task-scoped evidence.");
209
+ }
210
+ else {
211
+ if (!input.attempt || input.phase !== "preliminary") {
212
+ throw new Error("Task review requires exact preliminary attempt evidence.");
213
+ }
214
+ const { candidateBase, candidate, preliminaryChecks, termination } = input.attempt;
215
+ if (!candidateBase || !candidate || termination || preliminaryChecks?.phase !== "preliminary"
216
+ || !preliminaryChecks.passed || !sameIdentity(preliminaryChecks.candidate, candidate)
217
+ || !sameIdentity(preliminaryChecks.identityAfter, candidate)
218
+ || !sameIdentity(input.base, candidateBase) || !sameIdentity(input.tip, candidate)) {
219
+ throw new Error("Preliminary review requires a live worker and exact passing candidate evidence.");
220
+ }
221
+ }
222
+ const cwd = await this.requireScopeIdentity({ ...input, candidate: input.tip }, context);
223
+ const evidenceWorktree = input.scope === "final"
224
+ ? await realpath(oneLine(await this.requireGit(["rev-parse", "--show-toplevel"], input.root, context), "Main worktree root"))
225
+ : worktreeIntent(input.attempt).path;
226
+ const evidence = await prepareExactReviewEvidence({
227
+ base: input.base.head,
228
+ tip: input.tip.head,
229
+ worktree: evidenceWorktree,
230
+ }, context.signal);
231
+ let reviewError;
232
+ try {
233
+ if (evidence.base !== input.base.head || evidence.tip !== input.tip.head) {
234
+ throw new Error("Exact review evidence resolved an unexpected base or tip.");
235
+ }
236
+ const handle = await input.acquireLaunch();
237
+ const reviewed = await withTransientLaunch(handle, async (launch) => await this.executeReview({
238
+ scope: input.scope,
239
+ ...(input.taskId ? { taskId: input.taskId } : {}),
240
+ criterion: input.criterion,
241
+ launch,
242
+ cwd,
243
+ packet: { base: evidence.base, tip: evidence.tip, patchPath: evidence.patchPath },
244
+ }, context));
245
+ const identityAfter = await this.requireScopeCurrent({ ...input, candidate: input.tip }, context);
246
+ return { verdict: reviewed.verdict, identityAfter };
247
+ }
248
+ catch (error) {
249
+ reviewError = error;
250
+ throw error;
251
+ }
252
+ finally {
253
+ try {
254
+ await evidence.cleanup();
255
+ }
256
+ catch (cleanupError) {
257
+ if (reviewError) {
258
+ throw new AggregateError([reviewError, cleanupError], "Judgment execution failed and exact evidence cleanup also failed.");
259
+ }
260
+ throw cleanupError;
261
+ }
262
+ }
263
+ }
264
+ async inspectTaskCandidate(input, context) {
265
+ const priorPrompt = input.attempt.prompts.at(-1);
266
+ const expected = priorPrompt
267
+ ? input.attempt.candidate ?? priorPrompt.candidate ?? priorPrompt.preCandidate
268
+ : undefined;
269
+ const candidate = await this.inspectTask(input.root, input.task, input.attempt, expected, input.attempt.waveBase.head, context, false);
270
+ if (!priorPrompt && (candidate.head !== input.attempt.waveBase.head
271
+ || candidate.index !== input.attempt.waveBase.index
272
+ || candidate.tree !== input.attempt.waveBase.tree)) {
273
+ throw new Error(`Task ${input.task.id} initial prompt no longer starts from its recorded wave base.`);
274
+ }
275
+ return candidate;
276
+ }
277
+ async inspectInFlightTaskCandidate(input, context) {
278
+ const worktree = await this.requireTaskWorktree(input.root, input.task, input.attempt, context);
279
+ const inspected = await this.inspectInFlightWorkspace(worktree.cwd, context);
280
+ const expectedBranch = `refs/heads/${worktree.branch}`;
281
+ const branchTip = inspected.candidate.branch === expectedBranch
282
+ ? await this.branchTip(worktree.cwd, worktree.branch, context)
283
+ : undefined;
284
+ const descendsFromBase = await this.isAncestor(input.attempt.waveBase.head, inspected.candidate.head, worktree.cwd, context);
285
+ return {
286
+ candidate: inspected.candidate,
287
+ clean: inspected.clean,
288
+ valid: inspected.supported
289
+ && inspected.candidate.branch === expectedBranch
290
+ && branchTip === inspected.candidate.head
291
+ && descendsFromBase,
292
+ };
293
+ }
294
+ async inspectRetainedTask(input, context) {
295
+ return await this.inspectTask(input.root, input.task, input.attempt, undefined, input.attempt.waveBase.head, context);
296
+ }
297
+ async requireScopeIdentity(input, context) {
298
+ const actual = await this.requireScopeCurrent(input, context);
299
+ if (!sameIdentity(actual, input.candidate))
300
+ throw new Error(`${input.scope} candidate drifted before the operation.`);
301
+ return input.scope === "final" ? input.root : worktreeIntent(input.attempt).cwd;
302
+ }
303
+ async requireScopeCurrent(input, context) {
304
+ if (input.scope === "final")
305
+ return await this.inspectMain({ root: input.root }, context);
306
+ if (!input.attempt)
307
+ throw new Error("Task Git operation requires exact worktree metadata.");
308
+ return await this.inspectTask(input.root, { id: "recorded-task" }, input.attempt, undefined, undefined, context);
309
+ }
310
+ async inspectTask(root, task, attempt, expected, base, context, requireChange = true) {
311
+ const worktree = await this.requireTaskWorktree(root, task, attempt, context);
312
+ const identity = await this.inspectWorkspace(worktree.cwd, false, context);
313
+ if (identity.branch !== `refs/heads/${worktree.branch}`)
314
+ throw new Error(`Task ${task.id} moved off its owned branch.`);
315
+ const branch = await this.branchTip(worktree.cwd, worktree.branch, context);
316
+ if (branch !== identity.head)
317
+ throw new Error(`Task ${task.id} branch no longer names its checked-out HEAD.`);
318
+ if (expected && !sameIdentity(identity, expected))
319
+ throw new Error(`Task ${task.id} candidate identity drifted.`);
320
+ const requiredBase = base ?? attempt.waveBase.head;
321
+ if (!await this.isAncestor(requiredBase, identity.head, worktree.cwd, context)) {
322
+ throw new Error(`Task ${task.id} tip does not descend from its recorded base ${requiredBase}.`);
323
+ }
324
+ if (requireChange) {
325
+ const count = Number.parseInt(oneLine(await this.requireGit(["rev-list", "--count", `${requiredBase}..${identity.head}`], worktree.cwd, context), "task commit count"), 10);
326
+ if (!Number.isSafeInteger(count) || count < 1)
327
+ throw new Error(`Task ${task.id} has no committed change from its recorded base.`);
328
+ }
329
+ return identity;
330
+ }
331
+ async requireTaskWorktree(root, task, attempt, context) {
332
+ const worktree = worktreeIntent(attempt);
333
+ if (worktree.baseCommit !== attempt.waveBase.head) {
334
+ throw new Error(`Task ${task.id} worktree was not created from its recorded wave base.`);
335
+ }
336
+ if (await realpath(worktree.path) !== worktree.path)
337
+ throw new Error(`Task ${task.id} worktree path changed.`);
338
+ if (!(await this.registeredWorktreePaths(root, context)).includes(worktree.path)) {
339
+ throw new Error(`Task ${task.id} worktree is not registered in the recorded repository.`);
340
+ }
341
+ return worktree;
342
+ }
343
+ async inspectInFlightWorkspace(cwd, context) {
344
+ const branch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
345
+ const head = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
346
+ const hadGitlinks = await this.hasGitlinks(cwd, context);
347
+ const inspection = await inspectWorktreeDirty(cwd, this.gitRunner(context));
348
+ if (inspection.failure)
349
+ throw new Error(`Worktree inspection failed: ${inspection.failure}`);
350
+ const index = oid(await this.requireGit(["write-tree"], cwd, context), "index tree");
351
+ const tree = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{tree}"], cwd, context), "HEAD tree");
352
+ const hasGitlinks = hadGitlinks || await this.hasGitlinks(cwd, context);
353
+ const finalBranch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
354
+ const finalHead = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
355
+ if (branch !== finalBranch || head !== finalHead)
356
+ throw new Error("Git workspace changed during in-flight identity inspection.");
357
+ return {
358
+ candidate: { branch, head, index, tree },
359
+ clean: !inspection.dirty,
360
+ supported: !hasGitlinks,
361
+ };
362
+ }
363
+ async inspectWorkspace(cwd, strictIgnored, context) {
364
+ const branch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
365
+ const head = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
366
+ await this.assertNoGitlinks(cwd, context);
367
+ if (strictIgnored) {
368
+ const inspection = await inspectWorktreeDirty(cwd, this.gitRunner(context));
369
+ if (inspection.failure)
370
+ throw new Error(`Worktree inspection failed: ${inspection.failure}`);
371
+ if (inspection.dirty)
372
+ throw new Error("Worktree contains tracked, untracked, ignored, or nested submodule changes.");
373
+ }
374
+ else {
375
+ const refreshed = await this.git(["update-index", "--really-refresh"], cwd, context);
376
+ if (refreshed.code !== 0 && refreshed.code !== 1)
377
+ throw new Error(commandFailure(["update-index", "--really-refresh"], refreshed));
378
+ const status = await this.requireGit(["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=none"], cwd, context);
379
+ if (refreshed.code === 1 || status)
380
+ throw new Error("Git workspace is not clean; no stash, reset, or discard was attempted.");
381
+ const flags = await inspectIndexFlags(cwd, this.gitRunner(context), context.signal);
382
+ if (flags.failure)
383
+ throw new Error(`Git index inspection failed: ${flags.failure}`);
384
+ if (flags.hidden)
385
+ throw new Error("Git index contains assume-unchanged or skip-worktree entries.");
386
+ }
387
+ await this.assertNoGitlinks(cwd, context);
388
+ const index = oid(await this.requireGit(["write-tree"], cwd, context), "index tree");
389
+ const tree = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{tree}"], cwd, context), "HEAD tree");
390
+ if (index !== tree)
391
+ throw new Error("Git index does not match committed HEAD.");
392
+ const finalBranch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
393
+ const finalHead = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
394
+ if (branch !== finalBranch || head !== finalHead)
395
+ throw new Error("Git workspace changed during identity inspection.");
396
+ return { branch, head, index, tree };
397
+ }
398
+ async hasGitlinks(cwd, context) {
399
+ const index = await this.requireGit(["ls-files", "--stage", "-z"], cwd, context);
400
+ return index.split("\0").some((entry) => entry.startsWith("160000 "));
401
+ }
402
+ async assertNoGitlinks(cwd, context) {
403
+ if (await this.hasGitlinks(cwd, context)) {
404
+ throw new Error("Pi Subagent does not support Git repositories containing mode-160000 gitlinks.");
405
+ }
406
+ }
407
+ async branchExists(cwd, branch, context) {
408
+ const result = await this.git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd, context);
409
+ if (result.code === 0)
410
+ return true;
411
+ if (result.code === 1 && !result.killed)
412
+ return false;
413
+ throw new Error(commandFailure(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], result));
414
+ }
415
+ async branchTip(cwd, branch, context) {
416
+ const args = ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`];
417
+ const result = await this.git(args, cwd, context);
418
+ if (result.code === 0 && !result.killed)
419
+ return oid(result.stdout, "branch tip");
420
+ if (result.code === 1 && !result.killed)
421
+ return;
422
+ throw new Error(commandFailure(args, result));
423
+ }
424
+ async isAncestor(base, tip, cwd, context) {
425
+ const result = await this.git(["merge-base", "--is-ancestor", base, tip], cwd, context);
426
+ if (result.code === 0)
427
+ return true;
428
+ if (result.code === 1 && !result.killed)
429
+ return false;
430
+ throw new Error(commandFailure(["merge-base", "--is-ancestor", base, tip], result));
431
+ }
432
+ async registeredWorktreePaths(cwd, context) {
433
+ const output = await this.requireGit(["worktree", "list", "--porcelain", "-z"], cwd, context);
434
+ const paths = output.split("\0").filter((field) => field.startsWith("worktree ")).map((field) => field.slice("worktree ".length));
435
+ return await Promise.all(paths.map(async (path) => {
436
+ try {
437
+ return await realpath(path);
438
+ }
439
+ catch (error) {
440
+ if (pathMissing(error))
441
+ return path;
442
+ throw error;
443
+ }
444
+ }));
445
+ }
446
+ gitRunner(context) {
447
+ return async (args, cwd, signal) => {
448
+ const result = await this.execute("git", [...args], {
449
+ cwd,
450
+ signal: signal ?? context.signal,
451
+ timeoutMs: Math.min(GIT_OPERATION_CAP_MS, context.timeoutMs ?? GIT_OPERATION_CAP_MS),
452
+ });
453
+ return { code: result.code, stdout: result.stdout, stderr: result.stderr };
454
+ };
455
+ }
456
+ async git(args, cwd, context) {
457
+ context.signal.throwIfAborted();
458
+ const result = await this.execute("git", [...args], {
459
+ cwd,
460
+ signal: context.signal,
461
+ timeoutMs: Math.min(GIT_OPERATION_CAP_MS, context.timeoutMs ?? GIT_OPERATION_CAP_MS),
462
+ });
463
+ context.signal.throwIfAborted();
464
+ return result;
465
+ }
466
+ async requireGit(args, cwd, context) {
467
+ const result = await this.git(args, cwd, context);
468
+ if (result.code !== 0 || result.killed)
469
+ throw new Error(commandFailure(args, result));
470
+ return result.stdout.replace(/\r?\n$/, "");
471
+ }
472
+ }
@@ -0,0 +1,137 @@
1
+ import { type ExecuteRequest, type HostAllocationIntent, type HostAllocationPlan, type TaskAttempt, type TaskRequest, type WorkspaceIdentity } from "./schema.ts";
2
+ import { type AllocationReconciliation, type HostAllocationKind, type HostAllocationResult, type HostCleanupKind, type HostRuntime, type InFlightTaskCandidateInspector, type OperationContext, type TextTaskContext, type TransientLaunchHandle, type VerifiedLaunch, type WorkerResult } from "./runner.ts";
3
+ export interface HostProcessOptions {
4
+ cwd: string;
5
+ signal: AbortSignal;
6
+ timeoutMs: number;
7
+ }
8
+ export type HostProcessRunner = (command: string, args: string[], options: HostProcessOptions) => Promise<{
9
+ code: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ killed?: boolean;
13
+ }>;
14
+ export interface HerdrHostRuntimeOptions {
15
+ inspectInFlightTaskCandidate: InFlightTaskCandidateInspector["inspectInFlightTaskCandidate"];
16
+ runProcess?: HostProcessRunner;
17
+ killProcess?: (pid: number, signal: NodeJS.Signals) => void;
18
+ delay?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
19
+ now?: () => number;
20
+ randomId?: () => string;
21
+ env?: NodeJS.ProcessEnv;
22
+ leaseDirectory?: string;
23
+ lsofCommand?: string;
24
+ }
25
+ export declare function workspaceLabel(attempt: Pick<TaskAttempt, "correlationToken">): string;
26
+ export declare class HerdrHostRuntime implements HostRuntime {
27
+ private readonly inspectInFlightCandidate;
28
+ private readonly execute;
29
+ private readonly herdr;
30
+ private readonly kill;
31
+ private readonly delay;
32
+ private readonly now;
33
+ private readonly randomId;
34
+ private readonly env;
35
+ private readonly leaseDirectory;
36
+ private readonly lsofCommand;
37
+ constructor(options: HerdrHostRuntimeOptions);
38
+ /** Fail-closed Herdr caller/capability gate. This method creates no state or host resource. */
39
+ preflightHost(input: {
40
+ root: string;
41
+ }, context: OperationContext): Promise<void>;
42
+ planHostAllocation(input: {
43
+ readonly requestId: ExecuteRequest["id"];
44
+ readonly goal: ExecuteRequest["goal"];
45
+ kind: HostAllocationKind;
46
+ task: TaskRequest;
47
+ attempt: TaskAttempt;
48
+ }, context: OperationContext): Promise<HostAllocationPlan>;
49
+ allocateHost(input: {
50
+ requestId: ExecuteRequest["id"];
51
+ intent: HostAllocationIntent;
52
+ task: TaskRequest;
53
+ attempt: TaskAttempt;
54
+ acquireLaunch?: () => Promise<TransientLaunchHandle<VerifiedLaunch>>;
55
+ }, context: OperationContext): Promise<HostAllocationResult>;
56
+ reconcileHostAllocation(input: {
57
+ requestId: ExecuteRequest["id"];
58
+ intent: HostAllocationIntent;
59
+ task: TaskRequest;
60
+ attempt: TaskAttempt;
61
+ }, context: OperationContext): Promise<AllocationReconciliation<HostAllocationKind>>;
62
+ runWorker(input: {
63
+ readonly goal: ExecuteRequest["goal"];
64
+ readonly contexts: readonly TextTaskContext[];
65
+ task: TaskRequest;
66
+ attempt: TaskAttempt;
67
+ workerId: string;
68
+ kind: "initial" | "correction" | "followup";
69
+ preCandidate: WorkspaceIdentity;
70
+ failure?: string;
71
+ instruction?: string;
72
+ }, context: OperationContext): Promise<WorkerResult>;
73
+ terminateWorker(input: {
74
+ task: TaskRequest;
75
+ attempt: TaskAttempt;
76
+ workerId: string;
77
+ candidate: WorkspaceIdentity;
78
+ }, context: OperationContext): Promise<{
79
+ outcome: "terminated";
80
+ } | {
81
+ outcome: "unknown";
82
+ failure: string;
83
+ }>;
84
+ reconcileWorkerTermination(input: {
85
+ task: TaskRequest;
86
+ attempt: TaskAttempt;
87
+ workerId: string;
88
+ candidate: WorkspaceIdentity;
89
+ }, context: OperationContext): Promise<{
90
+ outcome: "terminated" | "active";
91
+ } | {
92
+ outcome: "unknown";
93
+ failure: string;
94
+ }>;
95
+ cleanupHost(input: {
96
+ requestId: ExecuteRequest["id"];
97
+ kind: HostCleanupKind;
98
+ task: TaskRequest;
99
+ attempt: TaskAttempt;
100
+ }, context: OperationContext): Promise<{
101
+ outcome: "completed" | "absent";
102
+ } | {
103
+ outcome: "blocked";
104
+ failure: string;
105
+ }>;
106
+ private allocateWorkspace;
107
+ private allocateWorkerTab;
108
+ private allocateAgent;
109
+ private reconcileDeliveredPrompt;
110
+ private candidateResult;
111
+ private isExpectedCandidate;
112
+ private waitForSettledAgent;
113
+ private waitForAgentLifecycle;
114
+ private diagnostic;
115
+ private assertStartableAgentPane;
116
+ private createPrivateLease;
117
+ private removeLeaseArtifacts;
118
+ private assertPrivateLeaseDirectories;
119
+ private assertLeasePath;
120
+ private assertPrivateLease;
121
+ private scanLease;
122
+ private signalExactHolders;
123
+ private listTabs;
124
+ private listAgents;
125
+ private listPanes;
126
+ private waitForStableWorkspacePanes;
127
+ private getTab;
128
+ private paneAbsent;
129
+ private workspaceAbsent;
130
+ private assertWorkspaceEvidence;
131
+ private repositoryIdentity;
132
+ private assertRepositoryIdentity;
133
+ private processOptions;
134
+ private childContext;
135
+ private callTimeout;
136
+ private contextInterrupted;
137
+ }