@henryqw/pi-subagent 27.2.1 → 27.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +63 -95
- package/README.md +231 -57
- package/dist/herdr-runtime.d.ts +2 -0
- package/dist/herdr-runtime.js +35 -19
- package/dist/integration-git.d.ts +2 -0
- package/dist/integration-git.js +19 -8
- package/dist/runner.d.ts +7 -0
- package/dist/runner.js +151 -56
- package/dist/schema.js +2 -2
- package/docs/orchestration.md +2 -2
- package/extensions/isolated.ts +2 -2
- package/package.json +1 -1
package/dist/herdr-runtime.js
CHANGED
|
@@ -548,7 +548,8 @@ export class HerdrHostRuntime {
|
|
|
548
548
|
throw new Error("Worker termination identity does not match the exact saved agent.");
|
|
549
549
|
this.assertLeasePath(allocation.leasePath, allocation.token);
|
|
550
550
|
await assertPrivateLease(allocation.leasePath, false);
|
|
551
|
-
const
|
|
551
|
+
const cwd = await this.queryCwd(allocation.worktreeCwd);
|
|
552
|
+
const agents = (await this.listAgents(cwd, context)).map((agent) => ({
|
|
552
553
|
name: typeof agent.name === "string" ? agent.name : undefined,
|
|
553
554
|
paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
|
|
554
555
|
tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
|
|
@@ -559,7 +560,7 @@ export class HerdrHostRuntime {
|
|
|
559
560
|
|| related[0].paneId !== allocation.paneId || related[0].tabId !== allocation.tabId) {
|
|
560
561
|
throw new Error("Worker termination requires one exact current agent, tab, and pane match.");
|
|
561
562
|
}
|
|
562
|
-
const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(
|
|
563
|
+
const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
|
|
563
564
|
const processResult = resultRecord(processInfo, "Herdr pane process-info response");
|
|
564
565
|
if (processResult.type !== "pane_process_info")
|
|
565
566
|
throw new Error("Herdr pane process-info response has the wrong type.");
|
|
@@ -567,27 +568,27 @@ export class HerdrHostRuntime {
|
|
|
567
568
|
if (captured.pane_id !== allocation.paneId) {
|
|
568
569
|
throw new Error("Herdr pane process-info did not match the exact saved pane.");
|
|
569
570
|
}
|
|
570
|
-
const closed = await this.herdr.exec(["pane", "close", allocation.paneId], this.processOptions(
|
|
571
|
+
const closed = await this.herdr.exec(["pane", "close", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
|
|
571
572
|
if (closed.code !== 0 || closed.killed)
|
|
572
573
|
throw new Error(herdrCommandFailure(["pane", "close"], closed));
|
|
573
574
|
requireOkResponse(closed.stdout, "Herdr pane close response");
|
|
574
|
-
if (!await this.paneAbsent(allocation.paneId,
|
|
575
|
+
if (!await this.paneAbsent(allocation.paneId, cwd, context))
|
|
575
576
|
throw new Error("The exact saved pane still exists after pane close.");
|
|
576
|
-
let holders = await this.scanLease(allocation.leasePath,
|
|
577
|
+
let holders = await this.scanLease(allocation.leasePath, cwd, context);
|
|
577
578
|
if (holders.length) {
|
|
578
|
-
await this.signalExactHolders(holders, allocation, "SIGTERM", context);
|
|
579
|
+
await this.signalExactHolders(holders, { ...allocation, worktreeCwd: cwd }, "SIGTERM", context);
|
|
579
580
|
await this.delay(250, context.signal);
|
|
580
|
-
holders = await this.scanLease(allocation.leasePath,
|
|
581
|
+
holders = await this.scanLease(allocation.leasePath, cwd, context);
|
|
581
582
|
}
|
|
582
583
|
if (holders.length) {
|
|
583
|
-
await this.signalExactHolders(holders, allocation, "SIGKILL", context);
|
|
584
|
+
await this.signalExactHolders(holders, { ...allocation, worktreeCwd: cwd }, "SIGKILL", context);
|
|
584
585
|
await this.delay(100, context.signal);
|
|
585
|
-
holders = await this.scanLease(allocation.leasePath,
|
|
586
|
+
holders = await this.scanLease(allocation.leasePath, cwd, context);
|
|
586
587
|
}
|
|
587
588
|
if (holders.length)
|
|
588
589
|
throw new Error(`Exact process lease still has ${holders.length} surviving holder(s).`);
|
|
589
590
|
await this.delay(50, context.signal);
|
|
590
|
-
if ((await this.scanLease(allocation.leasePath,
|
|
591
|
+
if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
|
|
591
592
|
throw new Error("Exact process lease did not remain empty for two consecutive scans.");
|
|
592
593
|
}
|
|
593
594
|
return { outcome: "terminated" };
|
|
@@ -605,7 +606,8 @@ export class HerdrHostRuntime {
|
|
|
605
606
|
throw new Error("Saved termination worker does not match the exact owned agent.");
|
|
606
607
|
this.assertLeasePath(allocation.leasePath, allocation.token);
|
|
607
608
|
await assertPrivateLease(allocation.leasePath, false);
|
|
608
|
-
const
|
|
609
|
+
const cwd = await this.queryCwd(allocation.worktreeCwd);
|
|
610
|
+
const agents = (await this.listAgents(cwd, context)).map((agent) => ({
|
|
609
611
|
name: typeof agent.name === "string" ? agent.name : undefined,
|
|
610
612
|
paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
|
|
611
613
|
tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
|
|
@@ -617,10 +619,10 @@ export class HerdrHostRuntime {
|
|
|
617
619
|
|| related[0].paneId !== allocation.paneId || related[0].tabId !== allocation.tabId) {
|
|
618
620
|
throw new Error("Observed worker identity does not exactly match the saved owned agent and pane.");
|
|
619
621
|
}
|
|
620
|
-
if (await this.paneAbsent(allocation.paneId,
|
|
622
|
+
if (await this.paneAbsent(allocation.paneId, cwd, context)) {
|
|
621
623
|
throw new Error("Exact saved agent exists but its saved pane is absent.");
|
|
622
624
|
}
|
|
623
|
-
const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(
|
|
625
|
+
const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
|
|
624
626
|
const result = resultRecord(processInfo, "Herdr pane process-info response");
|
|
625
627
|
if (result.type !== "pane_process_info"
|
|
626
628
|
|| record(result.process_info, "Herdr pane process-info").pane_id !== allocation.paneId) {
|
|
@@ -628,14 +630,14 @@ export class HerdrHostRuntime {
|
|
|
628
630
|
}
|
|
629
631
|
return { outcome: "active" };
|
|
630
632
|
}
|
|
631
|
-
if (!await this.paneAbsent(allocation.paneId,
|
|
633
|
+
if (!await this.paneAbsent(allocation.paneId, cwd, context)) {
|
|
632
634
|
throw new Error("Saved agent is absent but the exact saved pane still exists.");
|
|
633
635
|
}
|
|
634
|
-
if ((await this.scanLease(allocation.leasePath,
|
|
636
|
+
if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
|
|
635
637
|
throw new Error("Saved worker is absent but its private process lease still has holders.");
|
|
636
638
|
}
|
|
637
639
|
await this.delay(50, context.signal);
|
|
638
|
-
if ((await this.scanLease(allocation.leasePath,
|
|
640
|
+
if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
|
|
639
641
|
throw new Error("Private process lease did not remain empty for two consecutive scans.");
|
|
640
642
|
}
|
|
641
643
|
return { outcome: "terminated" };
|
|
@@ -682,11 +684,13 @@ export class HerdrHostRuntime {
|
|
|
682
684
|
}
|
|
683
685
|
try {
|
|
684
686
|
if (input.kind === "worker_tab") {
|
|
685
|
-
const
|
|
687
|
+
const owned = ownedIntent(input.attempt, "worker_tab");
|
|
686
688
|
const workspace = ownedIntent(input.attempt, "workspace");
|
|
687
|
-
requireIntentIdentity(
|
|
689
|
+
requireIntentIdentity(owned, input.attempt);
|
|
688
690
|
requireIntentIdentity(workspace, input.attempt);
|
|
689
|
-
assertWorkerTabIntent(
|
|
691
|
+
assertWorkerTabIntent(owned, input.task, input.attempt);
|
|
692
|
+
// Validation uses the saved identity; queries run from an existing directory.
|
|
693
|
+
const allocation = { ...owned, worktreeCwd: await this.queryCwd(owned.worktreeCwd) };
|
|
690
694
|
const leasePath = exactString(allocation.leasePath, "saved worker lease path");
|
|
691
695
|
this.assertLeasePath(leasePath, input.attempt.correlationToken);
|
|
692
696
|
assertWorkspaceIntent(workspace, input.attempt);
|
|
@@ -1327,6 +1331,18 @@ export class HerdrHostRuntime {
|
|
|
1327
1331
|
throw new Error("Persisted workspace repository identity no longer matches Git.");
|
|
1328
1332
|
}
|
|
1329
1333
|
}
|
|
1334
|
+
/** Herdr and lsof queries are global, so a checkout removed outside Pi Subagent must not be their process cwd. */
|
|
1335
|
+
async queryCwd(cwd) {
|
|
1336
|
+
try {
|
|
1337
|
+
await lstat(cwd);
|
|
1338
|
+
return cwd;
|
|
1339
|
+
}
|
|
1340
|
+
catch (error) {
|
|
1341
|
+
if (error.code === "ENOENT")
|
|
1342
|
+
return this.leaseDirectory;
|
|
1343
|
+
throw error;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1330
1346
|
processOptions(cwd, context, cap = HERDR_OPERATION_CAP_MS) {
|
|
1331
1347
|
context.signal.throwIfAborted();
|
|
1332
1348
|
return { cwd, signal: context.signal, timeoutMs: this.callTimeout(context, cap) };
|
|
@@ -49,6 +49,8 @@ export declare class IntegrationGit {
|
|
|
49
49
|
to: WorkspaceIdentity;
|
|
50
50
|
};
|
|
51
51
|
}, signal: AbortSignal): Promise<GitOutcome<WorkspaceIdentity>>;
|
|
52
|
+
/** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
|
|
53
|
+
absent(root: string, info: WorktreeInfo, signal: AbortSignal): Promise<boolean>;
|
|
52
54
|
/** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
|
|
53
55
|
release(root: string, info: WorktreeInfo, expected: WorkspaceIdentity, kind: "worktree" | "branch", signal: AbortSignal): Promise<GitOutcome<"removed" | "absent">>;
|
|
54
56
|
/** Cleanup is deliberately separate per resource; callers persist each proven substep. */
|
package/dist/integration-git.js
CHANGED
|
@@ -358,15 +358,23 @@ export class IntegrationGit {
|
|
|
358
358
|
catch { /* Retain all resources when verification fails. */ }
|
|
359
359
|
return { outcome: "unknown", failure: "Promotion returned success but exact clean Main tip could not be proved.", possibleResources: [integration.path, integration.branch] };
|
|
360
360
|
}
|
|
361
|
+
/** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
|
|
362
|
+
async absent(root, info, signal) {
|
|
363
|
+
await ownershipMetadata(root, info, signal);
|
|
364
|
+
const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
|
|
365
|
+
if (registered.split("\0").includes(`worktree ${info.path}`) || await exists(info.path))
|
|
366
|
+
return false;
|
|
367
|
+
const args = ["show-ref", "--verify", "--quiet", `refs/heads/${info.branch}`];
|
|
368
|
+
const ref = await git(args, root, signal);
|
|
369
|
+
if (ref.code !== 0 && ref.code !== 1)
|
|
370
|
+
throw new Error(failure(args, ref));
|
|
371
|
+
return ref.code === 1;
|
|
372
|
+
}
|
|
361
373
|
/** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
|
|
362
374
|
async release(root, info, expected, kind, signal) {
|
|
363
375
|
const possibleResources = [info.path, info.branch];
|
|
364
376
|
try {
|
|
365
377
|
await ownershipMetadata(root, info, signal);
|
|
366
|
-
if (expected.branch !== `refs/heads/${info.branch}` || expected.index !== expected.tree
|
|
367
|
-
|| !await ancestor(info.baseCommit, expected.head, root, signal)) {
|
|
368
|
-
return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
|
|
369
|
-
}
|
|
370
378
|
const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
|
|
371
379
|
const present = registered.split("\0").includes(`worktree ${info.path}`);
|
|
372
380
|
const pathPresent = await exists(info.path);
|
|
@@ -377,9 +385,14 @@ export class IntegrationGit {
|
|
|
377
385
|
const branch = await git(["rev-parse", "--verify", ref], root, signal);
|
|
378
386
|
if (branch.code !== 0 && branch.code !== 128)
|
|
379
387
|
throw new Error("Owned branch tip could not be inspected.");
|
|
388
|
+
// Absence needs no lineage proof: nothing remains to remove, even after Git pruned the commits.
|
|
389
|
+
if (!present && !pathPresent && (kind === "worktree" || branch.code === 128))
|
|
390
|
+
return { outcome: "ready", value: "absent" };
|
|
391
|
+
if (expected.branch !== ref || expected.index !== expected.tree
|
|
392
|
+
|| !await ancestor(info.baseCommit, expected.head, root, signal)) {
|
|
393
|
+
return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
|
|
394
|
+
}
|
|
380
395
|
if (kind === "worktree") {
|
|
381
|
-
if (!present && !pathPresent)
|
|
382
|
-
return { outcome: "ready", value: "absent" };
|
|
383
396
|
if (!present || !pathPresent || branch.stdout.trim() !== expected.head
|
|
384
397
|
|| !sameIdentity(await current(root, info, signal), expected)) {
|
|
385
398
|
return { outcome: "blocked", failure: "Owned checkout, registration, branch or clean candidate changed." };
|
|
@@ -398,8 +411,6 @@ export class IntegrationGit {
|
|
|
398
411
|
}
|
|
399
412
|
if (present || pathPresent)
|
|
400
413
|
return { outcome: "blocked", failure: "Release the exact checkout before branch deletion." };
|
|
401
|
-
if (branch.code === 128)
|
|
402
|
-
return { outcome: "ready", value: "absent" };
|
|
403
414
|
if (branch.stdout.trim() !== expected.head)
|
|
404
415
|
return { outcome: "blocked", failure: "Owned branch differs from the approved tip." };
|
|
405
416
|
const args = ["update-ref", "-d", ref, expected.head];
|
package/dist/runner.d.ts
CHANGED
|
@@ -347,6 +347,8 @@ export declare class IsolatedRunner {
|
|
|
347
347
|
private cleanupPromoted;
|
|
348
348
|
private requireExactIntegration;
|
|
349
349
|
abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
350
|
+
/** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
|
|
351
|
+
private retainedWorkAbsent;
|
|
350
352
|
private cleanupAborted;
|
|
351
353
|
status(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
352
354
|
private run;
|
|
@@ -356,6 +358,11 @@ export declare class IsolatedRunner {
|
|
|
356
358
|
private driveWorkerSafely;
|
|
357
359
|
private driveWorker;
|
|
358
360
|
private terminationCandidate;
|
|
361
|
+
/**
|
|
362
|
+
* Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
|
|
363
|
+
* only an observed live worker without saved termination intent is closed.
|
|
364
|
+
*/
|
|
365
|
+
private proveTermination;
|
|
359
366
|
private terminateWithSafety;
|
|
360
367
|
private runCheckBatch;
|
|
361
368
|
private runReview;
|
package/dist/runner.js
CHANGED
|
@@ -286,7 +286,27 @@ function affectedDependents(state, taskId) {
|
|
|
286
286
|
}
|
|
287
287
|
}
|
|
288
288
|
}
|
|
289
|
-
return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0
|
|
289
|
+
return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0
|
|
290
|
+
// Refresh or an earlier rejection already invalidated this attempt; it needs no second invalidation.
|
|
291
|
+
&& !(task.status === "pending" && (task.kind === "changeset" ? task.attempts.at(-1).superseded
|
|
292
|
+
: task.attempts.at(-1).status === "superseded")));
|
|
293
|
+
}
|
|
294
|
+
/** Freezes a generation as read-only. The correction stays recorded because it names the checkout's exact tip. */
|
|
295
|
+
function supersedeGeneration(generation, failure) {
|
|
296
|
+
if (generation.status === "promotion_failed") {
|
|
297
|
+
generation.supersededPromotion = generation.promotion;
|
|
298
|
+
delete generation.promotion;
|
|
299
|
+
}
|
|
300
|
+
generation.supersededFrom = generation.status;
|
|
301
|
+
generation.status = "superseded";
|
|
302
|
+
generation.failure = failure;
|
|
303
|
+
delete generation.combinedTip;
|
|
304
|
+
delete generation.checks;
|
|
305
|
+
delete generation.review;
|
|
306
|
+
}
|
|
307
|
+
/** The exact tip a retained integration checkout holds: its correction, last staged merge, or base. */
|
|
308
|
+
function retainedGenerationTip(generation) {
|
|
309
|
+
return generation.correction?.to ?? generation.stages.at(-1)?.tip ?? generation.integrationBase;
|
|
290
310
|
}
|
|
291
311
|
/** Every changeset predecessor must be present in this exact committed snapshot. */
|
|
292
312
|
function stagedDependencySnapshot(state, taskId, tip) {
|
|
@@ -950,13 +970,7 @@ export class IsolatedRunner {
|
|
|
950
970
|
}
|
|
951
971
|
if (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status))
|
|
952
972
|
throw new Error("Cannot supersede an unresolved promotion or validation.");
|
|
953
|
-
generation.
|
|
954
|
-
generation.status = "superseded";
|
|
955
|
-
generation.failure = `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`;
|
|
956
|
-
delete generation.combinedTip;
|
|
957
|
-
delete generation.checks;
|
|
958
|
-
delete generation.review;
|
|
959
|
-
delete generation.correction;
|
|
973
|
+
supersedeGeneration(generation, `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`);
|
|
960
974
|
}
|
|
961
975
|
candidate.decision = "rejected";
|
|
962
976
|
for (const dependent of affected) {
|
|
@@ -1164,20 +1178,22 @@ export class IsolatedRunner {
|
|
|
1164
1178
|
const candidates = state.integration.candidates.filter((item) => item.taskId === action.taskId && item.attempt === action.attempt);
|
|
1165
1179
|
const candidate = candidates.find((item) => sameIdentity(item.tip, action.expectedTip));
|
|
1166
1180
|
const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1181
|
+
const tip = attempt?.termination?.candidate ?? attempt?.candidate;
|
|
1182
|
+
// A rejected task or an invalidated dependent attempt never runs again; its worker can stop.
|
|
1183
|
+
if (!candidate || candidate.decision !== "rejected" || !attempt || (task.status !== "rejected" && !attempt.superseded)
|
|
1184
|
+
|| !tip || !sameIdentity(tip, action.expectedTip) || !worker
|
|
1170
1185
|
|| candidates.some((item) => !item.decision)
|
|
1171
1186
|
|| state.integration.generations.some((item) => item.stages.some((stage) => stage.taskId === action.taskId
|
|
1172
1187
|
&& stage.attempt === action.attempt && item.cleanup?.every((step) => step.status === "completed") !== true))) {
|
|
1173
|
-
throw new Error("Release requires a rejected
|
|
1188
|
+
throw new Error("Release requires a rejected candidate of a stopped or invalidated worker and no retained staged generation using its checkout.");
|
|
1174
1189
|
}
|
|
1190
|
+
await this.proveTermination(handle, task, attempt, action.expectedTip, async () => await this.saveProductive(handle), scope.signal);
|
|
1175
1191
|
await this.releaseRejected(handle, task, attempt, worker, scope);
|
|
1176
1192
|
return this.response(state);
|
|
1177
1193
|
}
|
|
1178
1194
|
const superseded = state.integration.generations[action.generation - 1];
|
|
1179
1195
|
if (!superseded?.worktree || superseded.status !== "superseded"
|
|
1180
|
-
|| !sameIdentity(action.expectedTip,
|
|
1196
|
+
|| !sameIdentity(action.expectedTip, retainedGenerationTip(superseded))
|
|
1181
1197
|
|| superseded.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
|
|
1182
1198
|
throw new Error("Release requires an exact superseded integration checkout without an uncertain stage.");
|
|
1183
1199
|
}
|
|
@@ -1234,18 +1250,7 @@ export class IsolatedRunner {
|
|
|
1234
1250
|
throw new Error("A dependent has unproved resources or exhausted fresh attempts; refresh is blocked.");
|
|
1235
1251
|
}
|
|
1236
1252
|
if (generation && generation.status !== "superseded") {
|
|
1237
|
-
|
|
1238
|
-
generation.supersededPromotion = generation.promotion;
|
|
1239
|
-
delete generation.promotion;
|
|
1240
|
-
}
|
|
1241
|
-
generation.supersededFrom = generation.status;
|
|
1242
|
-
generation.status = "superseded";
|
|
1243
|
-
generation.failure = `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`;
|
|
1244
|
-
delete generation.combinedTip;
|
|
1245
|
-
delete generation.checks;
|
|
1246
|
-
delete generation.review;
|
|
1247
|
-
if (!generation.supersededPromotion)
|
|
1248
|
-
delete generation.correction;
|
|
1253
|
+
supersedeGeneration(generation, `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`);
|
|
1249
1254
|
}
|
|
1250
1255
|
for (const task of stale) {
|
|
1251
1256
|
if (task.kind === "text") {
|
|
@@ -1659,10 +1664,23 @@ export class IsolatedRunner {
|
|
|
1659
1664
|
const state = handle.state;
|
|
1660
1665
|
if (terminal(state) && state.status !== "aborted")
|
|
1661
1666
|
return this.response(state);
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1667
|
+
const candidates = state.integration.candidates.filter((candidate) => candidate.worker !== "released");
|
|
1668
|
+
const generations = state.integration.generations.filter((generation) => generation.worktree
|
|
1669
|
+
&& !generation.cleanup?.every((step) => step.status === "completed"));
|
|
1670
|
+
// Abort removes only unchanged resources: an empty refreshed generation, or candidate work proved already gone.
|
|
1671
|
+
if ((candidates.length || generations.length) && (lifecycle.productiveRunLeaseActive
|
|
1672
|
+
|| generations.some((generation) => generation.status === "promotion_failed" ? generation.promotion?.outcome !== "drift"
|
|
1673
|
+
: !["staging", "conflict", "validation_failed", "ready", "superseded"].includes(generation.status))
|
|
1674
|
+
|| ((candidates.length || generations.some((generation) => generation.stages.length))
|
|
1675
|
+
&& !await this.retainedWorkAbsent(state, candidates, generations, outerSignal)))) {
|
|
1676
|
+
throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort, unless their checkouts and branches are proved absent.");
|
|
1677
|
+
}
|
|
1678
|
+
for (const candidate of candidates.filter((item) => !item.decision)) {
|
|
1679
|
+
candidate.decision = "rejected";
|
|
1680
|
+
this.attention(changesetTaskState(state, candidate.taskId), "Abort rejected this candidate after proving its checkout and branch absent.");
|
|
1681
|
+
}
|
|
1682
|
+
for (const generation of generations.filter((item) => item.status !== "superseded")) {
|
|
1683
|
+
supersedeGeneration(generation, "Request aborted; the integration checkout is released.");
|
|
1666
1684
|
}
|
|
1667
1685
|
const activeControls = this.activeControls(root, id);
|
|
1668
1686
|
for (const { control } of activeControls)
|
|
@@ -1679,30 +1697,11 @@ export class IsolatedRunner {
|
|
|
1679
1697
|
for (const attempt of task.attempts) {
|
|
1680
1698
|
if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
|
|
1681
1699
|
continue;
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
try {
|
|
1685
|
-
const result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination({
|
|
1686
|
-
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1687
|
-
workerId: attempt.termination.workerId, candidate: attempt.termination.candidate,
|
|
1688
|
-
}, context));
|
|
1689
|
-
if (result.outcome !== "terminated")
|
|
1690
|
-
throw new Error(result.outcome === "unknown"
|
|
1691
|
-
? result.failure : "Exact worker remains active; termination was not replayed.");
|
|
1692
|
-
attempt.termination = { ...attempt.termination, status: "terminated", at: this.coordinatorRuntime.now() };
|
|
1693
|
-
delete attempt.termination.failure;
|
|
1694
|
-
}
|
|
1695
|
-
catch (error) {
|
|
1696
|
-
attempt.termination.status = "unknown";
|
|
1697
|
-
attempt.termination.failure = errorText(error);
|
|
1698
|
-
this.attention(task, attempt.termination.failure);
|
|
1699
|
-
}
|
|
1700
|
-
finally {
|
|
1701
|
-
safety.close();
|
|
1702
|
-
}
|
|
1700
|
+
try {
|
|
1701
|
+
await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), outerSignal, safetyDeadline);
|
|
1703
1702
|
}
|
|
1704
|
-
|
|
1705
|
-
|
|
1703
|
+
catch (error) {
|
|
1704
|
+
this.attention(task, errorText(error));
|
|
1706
1705
|
}
|
|
1707
1706
|
}
|
|
1708
1707
|
}
|
|
@@ -1717,6 +1716,24 @@ export class IsolatedRunner {
|
|
|
1717
1716
|
return this.response(state);
|
|
1718
1717
|
}, { purpose: "abort" });
|
|
1719
1718
|
}
|
|
1719
|
+
/** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
|
|
1720
|
+
async retainedWorkAbsent(state, candidates, generations, signal) {
|
|
1721
|
+
const checkouts = [
|
|
1722
|
+
...candidates.map((candidate) => allocationByKind(changesetTaskState(state, candidate.taskId).attempts[candidate.attempt - 1], "worktree")?.worktree),
|
|
1723
|
+
...generations.filter((generation) => generation.stages.length).map((generation) => generation.worktree),
|
|
1724
|
+
];
|
|
1725
|
+
const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
|
|
1726
|
+
try {
|
|
1727
|
+
for (const checkout of checkouts) {
|
|
1728
|
+
if (!checkout || !await scope.call((context) => this.integrationGit.absent(state.root, checkout, context.signal)))
|
|
1729
|
+
return false;
|
|
1730
|
+
}
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1733
|
+
finally {
|
|
1734
|
+
scope.close();
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1720
1737
|
async cleanupAborted(handle, signal) {
|
|
1721
1738
|
const state = handle.state;
|
|
1722
1739
|
const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
|
|
@@ -1737,7 +1754,8 @@ export class IsolatedRunner {
|
|
|
1737
1754
|
if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated") {
|
|
1738
1755
|
throw new Error("Exact worker termination remains unproved.");
|
|
1739
1756
|
}
|
|
1740
|
-
if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head
|
|
1757
|
+
if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head
|
|
1758
|
+
&& !await scope.call((context) => this.integrationGit.absent(state.root, worker, context.signal))) {
|
|
1741
1759
|
throw new Error("Aborted worker may contain committed work; automatic cleanup preserves it.");
|
|
1742
1760
|
}
|
|
1743
1761
|
for (const step of attempt.cleanup) {
|
|
@@ -1773,6 +1791,45 @@ export class IsolatedRunner {
|
|
|
1773
1791
|
}
|
|
1774
1792
|
}
|
|
1775
1793
|
}
|
|
1794
|
+
// Abort admitted retained candidates only after proving their checkouts absent.
|
|
1795
|
+
for (const candidate of state.integration.candidates) {
|
|
1796
|
+
const task = changesetTaskState(state, candidate.taskId);
|
|
1797
|
+
const attempt = task.attempts[candidate.attempt - 1];
|
|
1798
|
+
if (candidate.worker === "released" || candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
|
|
1799
|
+
|| attempt.cleanup.some((step) => step.status !== "completed"))
|
|
1800
|
+
continue;
|
|
1801
|
+
candidate.worker = "released";
|
|
1802
|
+
if (task.status === "needs_attention" && attempt === task.attempts.at(-1) && !attempt.superseded) {
|
|
1803
|
+
task.status = "rejected";
|
|
1804
|
+
delete task.failure;
|
|
1805
|
+
}
|
|
1806
|
+
await handle.save();
|
|
1807
|
+
}
|
|
1808
|
+
for (const generation of state.integration.generations) {
|
|
1809
|
+
if (!generation.worktree || generation.status !== "superseded")
|
|
1810
|
+
continue;
|
|
1811
|
+
const steps = generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
|
|
1812
|
+
try {
|
|
1813
|
+
for (const step of steps) {
|
|
1814
|
+
if (step.status === "completed")
|
|
1815
|
+
continue;
|
|
1816
|
+
step.status = "running";
|
|
1817
|
+
delete step.failure;
|
|
1818
|
+
await handle.save();
|
|
1819
|
+
const result = await scope.call((context) => this.integrationGit.release(state.root, generation.worktree, retainedGenerationTip(generation), step.kind, context.signal));
|
|
1820
|
+
if (result.outcome !== "ready")
|
|
1821
|
+
throw new Error(result.failure);
|
|
1822
|
+
step.status = "completed";
|
|
1823
|
+
await handle.save();
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
catch (error) {
|
|
1827
|
+
const pending = steps.find((step) => step.status === "running");
|
|
1828
|
+
if (pending)
|
|
1829
|
+
pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
|
|
1830
|
+
await handle.save();
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1776
1833
|
}
|
|
1777
1834
|
finally {
|
|
1778
1835
|
scope.close();
|
|
@@ -2434,6 +2491,43 @@ export class IsolatedRunner {
|
|
|
2434
2491
|
?? attempt.prompts.at(-1)?.preCandidate
|
|
2435
2492
|
?? attempt.waveBase;
|
|
2436
2493
|
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
|
|
2496
|
+
* only an observed live worker without saved termination intent is closed.
|
|
2497
|
+
*/
|
|
2498
|
+
async proveTermination(handle, task, attempt, candidate, save, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
|
|
2499
|
+
if (attempt.termination?.status === "terminated")
|
|
2500
|
+
return;
|
|
2501
|
+
const workerId = attempt.termination?.workerId ?? allocationByKind(attempt, "agent")?.agentName;
|
|
2502
|
+
if (!workerId)
|
|
2503
|
+
throw new Error("Worker termination requires an exact durably owned agent ID.");
|
|
2504
|
+
const input = { task: changesetTaskRequest(handle.state, task.taskId), attempt, workerId,
|
|
2505
|
+
candidate: attempt.termination?.candidate ?? candidate };
|
|
2506
|
+
const safety = new DeadlineScope(deadline, () => this.coordinatorRuntime.now(), signal);
|
|
2507
|
+
try {
|
|
2508
|
+
let result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination(input, context));
|
|
2509
|
+
if (result.outcome === "active" && !attempt.termination) {
|
|
2510
|
+
attempt.termination = { status: "terminating", workerId, candidate: input.candidate };
|
|
2511
|
+
await save(); // Intent before the exact pane close.
|
|
2512
|
+
result = await safety.call((context) => this.hostRuntime.terminateWorker(input, context));
|
|
2513
|
+
}
|
|
2514
|
+
if (result.outcome !== "terminated") {
|
|
2515
|
+
throw new Error(result.outcome === "unknown" ? result.failure : "Exact worker remains active; termination was not replayed.");
|
|
2516
|
+
}
|
|
2517
|
+
attempt.termination = { status: "terminated", workerId, candidate: input.candidate, at: this.coordinatorRuntime.now() };
|
|
2518
|
+
}
|
|
2519
|
+
catch (error) {
|
|
2520
|
+
const failure = bounded(`Worker termination is unproved: ${errorText(error)}`);
|
|
2521
|
+
// Without saved intent, nothing was closed; a later proof may still close the exact live worker.
|
|
2522
|
+
if (attempt.termination)
|
|
2523
|
+
attempt.termination = { status: "unknown", workerId, candidate: input.candidate, failure };
|
|
2524
|
+
throw new Error(failure, { cause: error });
|
|
2525
|
+
}
|
|
2526
|
+
finally {
|
|
2527
|
+
safety.close();
|
|
2528
|
+
await save();
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2437
2531
|
async terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS, lifecycleLocked = false) {
|
|
2438
2532
|
if (!lifecycleLocked && this.productiveLifecycles.has(handle.state.root)) {
|
|
2439
2533
|
return await this.withLifecycleLock(handle, async () => await this.terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline, true));
|
|
@@ -2985,8 +3079,9 @@ export class IsolatedRunner {
|
|
|
2985
3079
|
text: bounded([
|
|
2986
3080
|
`Pi Subagent ${state.request.id}: ${state.status}.`,
|
|
2987
3081
|
`Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
|
|
2988
|
-
...(state.status === "aborted" && state.tasks.some((task) => task.kind === "changeset"
|
|
3082
|
+
...(state.status === "aborted" && (state.tasks.some((task) => task.kind === "changeset"
|
|
2989
3083
|
&& task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
|
|
3084
|
+
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")))
|
|
2990
3085
|
? ["Aborted resources remain retained. Inspect cleanup evidence; retry subagent_abort after active work settles or cleanup blockers are resolved. Unique commits and uncertain work are never discarded."] : []),
|
|
2991
3086
|
...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
|
|
2992
3087
|
...(main?.status === "drifted" ? [
|
|
@@ -3009,10 +3104,10 @@ export class IsolatedRunner {
|
|
|
3009
3104
|
] : []),
|
|
3010
3105
|
...(state.integration.generations.some((item) => item.status === "superseded" && item.worktree
|
|
3011
3106
|
&& !item.cleanup?.every((step) => step.status === "completed")) ? [
|
|
3012
|
-
"Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and last staged tip; dirty or conflicted work stays retained.",
|
|
3107
|
+
"Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and recorded tip (correction tip, else last staged tip, else integration base); dirty or conflicted work stays retained.",
|
|
3013
3108
|
] : []),
|
|
3014
3109
|
...(state.integration.candidates.some((item) => item.decision === "rejected" && item.worker !== "released") ? [
|
|
3015
|
-
"Rejected candidate resources remain retained.
|
|
3110
|
+
"Rejected candidate resources remain retained. Release every generation that staged it, then use subagent_integrate release with the exact task, attempt and candidate tip; release first proves or performs exact worker termination.",
|
|
3016
3111
|
] : []),
|
|
3017
3112
|
...(state.final.failure ? [`Final: ${state.final.failure}`] : []),
|
|
3018
3113
|
...(continuation ? [`Continuation: ${JSON.stringify(continuation)}`] : []),
|
package/dist/schema.js
CHANGED
|
@@ -923,7 +923,7 @@ export function parseIntegrationState(value, request) {
|
|
|
923
923
|
if (generation.status === "superseded") {
|
|
924
924
|
if (!generation.supersededFrom || !generation.failure?.trim() || generation.promotion
|
|
925
925
|
|| generation.checks || generation.review || generation.combinedTip
|
|
926
|
-
|| (generation.supersededFrom === "promotion_failed" ? !generation.supersededPromotion : generation.
|
|
926
|
+
|| (generation.supersededFrom === "promotion_failed" ? !generation.supersededPromotion : generation.supersededPromotion)) {
|
|
927
927
|
throw new Error(`Superseded generation ${generation.number} retains usable evidence or lacks its reason.`);
|
|
928
928
|
}
|
|
929
929
|
}
|
|
@@ -1256,7 +1256,7 @@ export function parseRunState(value) {
|
|
|
1256
1256
|
const selected = state.integration.generations.at(-1);
|
|
1257
1257
|
const releasedWithRevisedAttempt = selected?.status === "promoted" && selected.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt && !sameIdentity(stage.source, candidate.tip))
|
|
1258
1258
|
&& attempt?.cleanup.every((step) => step.status === "completed");
|
|
1259
|
-
const releasedAfterRejection = task?.status === "rejected" && attempt?.termination?.status === "terminated"
|
|
1259
|
+
const releasedAfterRejection = (task?.status === "rejected" || attempt?.superseded) && attempt?.termination?.status === "terminated"
|
|
1260
1260
|
&& (candidate.worker === "release_pending" || candidate.worker === "released"
|
|
1261
1261
|
&& attempt.cleanup.every((step) => step.status === "completed"));
|
|
1262
1262
|
if (!attempt || (candidate.worker !== "retained" && !releasedAfterRejection
|
package/docs/orchestration.md
CHANGED
|
@@ -117,9 +117,9 @@ The user's request can authorize the declared local graph, checks, review, and M
|
|
|
117
117
|
- `subagent_stage`: `{ id, action: "stage" | "resolve" | "reject" | "revise", generation, taskId, attempt, candidate, expectedTip, instruction? }`. `candidate` is the ready candidate's `tip`; `expectedTip` is Main's recorded identity for the first stage or the previous stage's exact tip. `resolve` reconciles a pending/conflicted merge; `reject` and `revise` arbitrate a candidate without changing Main.
|
|
118
118
|
- `subagent_integrate`: `{ id, action: "advance" | "validate" | "correct" | "promote" | "reconcile" | "cleanup", generation, expectedTip }`. Use the generation's exact `combinedTip`. `advance` dispatches ready dependents on that snapshot, `correct` records the single clean committed correction after a definitive failed check/review, `reconcile` inspects an interrupted validation or promotion without replaying an uncertain promotion, and `cleanup` completes proved post-promotion cleanup.
|
|
119
119
|
- `subagent_integrate refresh`: `{ id, action: "refresh", generation, expectedTip, expectedMain, newMain }`. If Main advanced to a clean same-branch descendant, this freezes the old generation and allocates a new integration checkout from `newMain`. Restage selected immutable candidates in explicit order, resolve fresh conflicts, then rerun combined validation. Dirty, divergent, or uncertain Main cannot refresh. For a reconciled `validation_failed` generation without checks, review, or a prior combined correction, identical `expectedMain` and `newMain` instead rebuild on the unchanged base. This requires the exact clean retained integration tip, leaves the old checkout retained, and never resumes its uncertain checks. Prove old coordinator/check processes stopped before recovery; a live productive lease blocks it. Restage explicitly and rerun all final gates; no correction allowance is replenished.
|
|
120
|
-
- `subagent_integrate release`: `{ id, action: "release", generation, expectedTip, taskId?, attempt? }`. With no task, release an explicitly superseded checkout using that generation's last staged tip
|
|
120
|
+
- `subagent_integrate release`: `{ id, action: "release", generation, expectedTip, taskId?, attempt? }`. With no task, release an explicitly superseded checkout using that generation's recorded tip: its correction tip, else its last staged tip, else its integration base. Supersession keeps the correction record for this proof. With both `taskId` and `attempt`, release the rejected candidate of a rejected task or of a dependent attempt that refresh or rejection invalidated, using its exact candidate tip and the latest generation number (or 1 when none exists); release every generation that staged it first. Release proves exact worker termination before cleanup: reconciliation records an absent worker after pane absence and two empty lease scans, and only an observed live worker without saved termination intent is closed with the same proof. An uncertain termination is never replayed. Cleanup steps are persisted separately, and a retry reconciles already-removed resources by proving absence. Release refuses dirty/conflicted work, changed refs, unproved termination, and still-needed checkouts.
|
|
121
121
|
|
|
122
|
-
A rejection or revision of a staged candidate supersedes that generation, leaves its checkout read-only, and requires explicit restaging of chosen candidates into a new generation. At most two unreleased owned integration checkouts can coexist per request. Superseded integration checkouts and rejected candidate resources are **retained until Main explicitly releases them**. Inspect exact status first; dirty or unresolved conflicts require manual recovery without force deletion. Rejection invalidates dependent attempts; if the required fresh dependent work cannot run within the bounded attempt/lineage constraints, start a new request. `subagent_abort` cannot discard retained resources, but can finish after all candidates are rejected and all owned checkouts are released.
|
|
122
|
+
A rejection or revision of a staged candidate supersedes that generation, leaves its checkout read-only, and requires explicit restaging of chosen candidates into a new generation. At most two unreleased owned integration checkouts can coexist per request. Superseded integration checkouts and rejected candidate resources are **retained until Main explicitly releases them**. Inspect exact status first; dirty or unresolved conflicts require manual recovery without force deletion. Rejection invalidates dependent attempts; if the required fresh dependent work cannot run within the bounded attempt/lineage constraints, start a new request. `subagent_abort` cannot discard retained resources, but can finish after all candidates are rejected and all owned checkouts are released. It also releases an empty refreshed generation that is clean at its base. If retained candidate checkouts and staged integration checkouts are proved absent (unregistered, path missing, branch ref missing) and their workers are proved absent, abort records those candidates as rejected and released and completes their cleanup; any present resource fails abort without state changes. Rejection and revision ignore dependents that refresh or an earlier rejection already invalidated.
|
|
123
123
|
|
|
124
124
|
### Recovery and state
|
|
125
125
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -439,7 +439,7 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
|
|
|
439
439
|
&& !generation.promotion?.failure && !generation.supersededPromotion?.failure
|
|
440
440
|
&& generation !== state.integration.generations.at(-1)) continue;
|
|
441
441
|
const stagedTip = [...generation.stages].reverse().find((stage) => stage.tip)?.tip ?? generation.integrationBase;
|
|
442
|
-
lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
|
|
442
|
+
lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.correction ? `; correction tip: ${JSON.stringify(generation.correction.to)}` : ""}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
|
|
443
443
|
for (const stage of generation.stages.filter((item) => item.status === "conflict" || item.failure)) lines.push(`Integration generation ${generation.number} stage ${stage.taskId} attempt ${stage.attempt}: ${stage.status}${stage.failure ? `; blockage: ${boundedPublicText(stage.failure)}` : ""}.`);
|
|
444
444
|
for (const promotion of [generation.promotion, generation.supersededPromotion]) if (promotion?.failure) lines.push(`Integration generation ${generation.number} promotion ${promotion.status}: ${boundedPublicText(promotion.failure)}`);
|
|
445
445
|
if (generation.cleanup?.some((step) => step.status !== "completed")) lines.push(`Integration generation ${generation.number} cleanup pending: ${JSON.stringify(generation.cleanup.filter((step) => step.status !== "completed").map((step) => ({ kind: step.kind, status: step.status, failure: step.failure ? boundedPublicText(step.failure) : undefined })))}`);
|
|
@@ -766,7 +766,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
766
766
|
pi.registerTool({
|
|
767
767
|
name: "subagent_abort",
|
|
768
768
|
label: "Subagent abort",
|
|
769
|
-
description: "Abort an isolated request; reject and release retained candidates and integration checkouts first. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged
|
|
769
|
+
description: "Abort an isolated request; reject and release retained candidates and integration checkouts first unless their checkouts and branches are proved absent. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged allocations; preserve dirty or committed work.",
|
|
770
770
|
parameters: IdOnlySchema,
|
|
771
771
|
prepareArguments: parseIdOnly,
|
|
772
772
|
exposure: "model-only",
|