pi-orchestrator-config 4.8.1 → 4.8.2
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 +5 -6
- package/extensions/coms/coms-p2p.ts +33 -4
- package/extensions/coms/coms-shared.ts +75 -0
- package/extensions/orchestrator/async-agents.ts +246 -58
- package/extensions/orchestrator/async-kill-signal.ts +43 -0
- package/extensions/orchestrator/async-result-format.ts +3 -1
- package/extensions/orchestrator/async-runner.ts +1 -1
- package/extensions/orchestrator/async-status-ui.ts +105 -3
- package/extensions/orchestrator/extended-autocomplete.ts +0 -7
- package/extensions/orchestrator/overlay-dashboard.ts +70 -5
- package/extensions/orchestrator/subagent-tool.ts +5 -3
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,11 +14,10 @@ Single extension that provides:
|
|
|
14
14
|
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
15
|
| **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async modes); optional `model` param for model override |
|
|
16
16
|
| **`list_models`** | List available models and providers for subagent model override |
|
|
17
|
-
| **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status
|
|
17
|
+
| **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`: `x` kills, `Space` multi-selects, `a` kills all). On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `dev-docs/cli-provider.md`) |
|
|
18
18
|
| **CLI / ACPX providers** | Optional `cli_agents` / `acpx_agents` register `cli-*` / `acpx-*` via native `createProvider` (pi ≥ 0.84): `/login cli-<agent>` or `/login acpx-<agent>`, model refresh, filter when unavailable. Missing context window / maxTokens / cost filled from models.dev (`~/.pi/pi-config/models.dev.json`, 1-day refresh). Cold-start default restore (#753): gates `startup`\|`new`, empty `enabledModels`, trusted project merge — see `dev-docs/cli-provider.md`. Headless Cursor `--approve-mcps` when `CLI_APPROVE_MCPS` is set (`false` opts out) or the process is a sidecar (`SIDECAR_PORT`; `startSidecar()` stamps it while running and restores it on `close()`). Executable sidecar consumers should call `bindSidecarListenExit()` so bind/`stopped` fatals exit 1. Headless Gemini defaults `GEMINI_CLI_TRUST_WORKSPACE=true` and preserves an explicit parent value (including `false`). |
|
|
19
19
|
| **`/btw` command** | Quick side questions without polluting conversation history — ephemeral overlay |
|
|
20
|
-
| **`/async-status` command** | Show status of background agents — select one for live output streaming
|
|
21
|
-
| **`/async-kill` command** | Kill async agents (overlay picker or by name/id) |
|
|
20
|
+
| **`/async-status` command** | Show status of background agents — select one for live output streaming, multi-select and kill (`x`), or kill everything (`a`) |
|
|
22
21
|
| **`ask_user` tool** | Structured user input with options and free-text — used by workflows |
|
|
23
22
|
| **Python/pip enforcement** | Auto-fixes `python`/`python3` → `uv run python`; blocks `pip`/`pip3` — requires `uv` |
|
|
24
23
|
| **Git protection** | Blocks commits/pushes to main/master, merged branches, `--no-verify`, `git add .` |
|
|
@@ -37,7 +36,7 @@ Single extension that provides:
|
|
|
37
36
|
| **Task tracking** | Structured task lists for multi-step workflows — live widget, progress tracking, reminder nudges (owned, based on [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) MIT) |
|
|
38
37
|
| **Neovim integration** | Send changed files and review findings to nvim's quickfix list — only active when running inside nvim |
|
|
39
38
|
| **Inter-agent communication** | P2P (`/coms`) agent communication — on-demand activation via slash command |
|
|
40
|
-
| **Slash commands** | `/pr-review`, `/issue-review`, `/release`, `/review-local`, `/review-status`, `/query-db`, `/btw`, `/async-status`, `/
|
|
39
|
+
| **Slash commands** | `/pr-review`, `/issue-review`, `/release`, `/review-local`, `/review-status`, `/query-db`, `/btw`, `/async-status`, `/status`, `/dream`, `/remember`, `/coms`, `/pi-config-settings` — with autocomplete argument hints |
|
|
41
40
|
| **GitHub autocomplete** | Type `#` in the editor to get issue/PR suggestions from the current repo — lazy-loaded, 5min cache |
|
|
42
41
|
| **Command arg completions** | Tab-complete arguments for slash commands — providers and models for `/external-ai`, branches for `/review-local`, PR numbers for `/pr-review`, and more |
|
|
43
42
|
| **Discord bot** | Control pi sessions from your phone via Discord DMs — send prompts, answer ask_user dialogs, switch sessions |
|
|
@@ -83,7 +82,7 @@ for `git-expert` to commit.
|
|
|
83
82
|
| `/remember <what>` | Save a memory for future sessions |
|
|
84
83
|
| `/dream-auto` | Toggle automatic memory dreaming (every 3h + session end) |
|
|
85
84
|
| `/cron add\|list\|list-all\|remove [--persist]` | Schedule recurring tasks. Session jobs end with the session; `--persist` keeps a job across Pi sessions in this project. Add/remove mutations still work when leader election fails closed on an unsupported platform. For example: `/cron add every 2h check for new issues --persist` or `/cron remove persist:<uuid>`. Pin one job's model by naming it in the request: `/cron add every 2h check for new issues using openrouter/anthropic/claude-opus-5`. A bare `model-id` (e.g. `claude-opus-5`) keeps the normal provider resolution. An unpinned job uses the worker's normal resolution chain (`agent_overrides[worker]`, worker frontmatter, then `agent_provider`/`agent_model`) — cron does not pass the session model down, so a worker-level setting wins over the session model. An `acpx-*` provider cannot be pinned here: async children cannot register those providers, so the job runs on `internal_operations_provider`/`internal_operations_model` when both are set to a non-`acpx-*` pair, and is skipped with a logged error when they are not. `/cron list` opens an overlay for the selected jobs (or both when omitted). Persistent delivery is local at-least-once: a crash or leader recovery can repeat a run. Project cron writes use a stable OS `flock` (included in Docker via `util-linux`) across the entire transaction; native installations need `flock` and a shared filesystem that supports advisory locks. A busy or unavailable lock fails closed after two seconds. **Upgrade gate (enforced):** stop and drain every old Pi process writing crons on the shared mount before starting a version with this kernel lock; old directory-lock writers do not honor `flock` and must not resume. If a legacy `crons.json.mutation.lock` directory exists, new writes fail closed even if its PID is invisible or its heartbeat expired. Only after verifying every old writer cannot resume may an operator remove that stale directory (not `crons.json` or cron tasks). Never remove it merely because a write timed out. |
|
|
86
|
-
| `/async-
|
|
85
|
+
| `/async-status` | Async agent dashboard — live output, multi-select + `x` kill, `X` kill all |
|
|
87
86
|
| `/pi-config-settings [project\|global]` | Interactive TUI settings editor — browse all pi-config settings with scope indicators, smart pickers for providers/models, and immediate save |
|
|
88
87
|
| `/status` | Unified session snapshot — async agents, cron tasks, git branch, context usage |
|
|
89
88
|
| `/nvim-changed-files` | Send git changed files to nvim's quickfix list (only inside nvim) |
|
|
@@ -751,7 +750,7 @@ PI_PIDIFF_ENABLE=false pi
|
|
|
751
750
|
| `jq` | JSON processing |
|
|
752
751
|
| `rg` (ripgrep) | Fast recursive search |
|
|
753
752
|
| `cr` | CodeRabbit CLI — local AI code reviews |
|
|
754
|
-
| `qodo` | Qodo CLI — local code reviews (`qodo --version`; mount `~/.qodo` as above and authenticate inside the container if needed) |
|
|
753
|
+
| `qodo` | Qodo CLI (npm package `@qodo/command`) — local code reviews (`qodo --version`; mount `~/.qodo` as above and authenticate inside the container if needed — `--version` exits non-zero when logged out) |
|
|
755
754
|
| `curl` | HTTP requests |
|
|
756
755
|
|
|
757
756
|
#### `myk-pi-tools` subcommands
|
|
@@ -19,7 +19,7 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
|
|
|
19
19
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { applyExtensionDefaults } from "./themeMap.js";
|
|
22
|
-
import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, formatComsResponseType, formatComsResponseBody, formatComsInboundType, sanitizeComsName, createComsInboundTasks, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
|
|
22
|
+
import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, formatComsResponseType, formatComsResponseBody, formatComsInboundType, sanitizeComsName, createComsInboundTasks, shouldEvictOnLeaving, shouldAnnouncePeerLeft, isSessionStillRegistered, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
|
|
23
23
|
import { openListDetailOverlay, OverlayScrollDetail } from "../orchestrator/overlay-dashboard.js";
|
|
24
24
|
import * as net from "node:net";
|
|
25
25
|
import * as fs from "node:fs";
|
|
@@ -965,13 +965,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
965
|
function handlePresence(socket: net.Socket, env: PresenceEnvelope): void {
|
|
966
966
|
ackOk(socket, env.msg_id);
|
|
967
967
|
if (env.status === "leaving" && env.sender_name) {
|
|
968
|
+
// Match the session that is actually leaving, not every session sharing that name.
|
|
969
|
+
// On /reload the outgoing session broadcasts this ~200ms AFTER the incoming one has
|
|
970
|
+
// already registered, so matching by name alone evicted the live session: the peer
|
|
971
|
+
// showed as disconnected until something forced it to re-register.
|
|
968
972
|
for (const [sid, card] of peerCards.entries()) {
|
|
969
|
-
|
|
973
|
+
const sameSession = shouldEvictOnLeaving(sid, card.name, env.sender_name, env.sender_session);
|
|
974
|
+
if (sameSession) {
|
|
970
975
|
peerCards.delete(sid);
|
|
971
976
|
// Also clean up knownPeerSessions and fire notification
|
|
972
977
|
if (knownPeerSessions.has(sid)) {
|
|
973
978
|
knownPeerSessions.delete(sid);
|
|
974
|
-
log.
|
|
979
|
+
log.info("presence_leaving_received", "from", env.sender_name, "sid", sid);
|
|
980
|
+
// Evicting only the departing session is not enough: on /reload the
|
|
981
|
+
// replacement session may already hold a live card under the same name, and
|
|
982
|
+
// announcing a departure would then tell the user and every browser event
|
|
983
|
+
// consumer that a still-connected peer is gone. The registry-removal path
|
|
984
|
+
// below already suppresses this; mirror it here.
|
|
985
|
+
if (!shouldAnnouncePeerLeft(peerCards.values(), card.name)) {
|
|
986
|
+
maybeRefreshWidget();
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
975
989
|
try {
|
|
976
990
|
pi.sendMessage({ customType: "coms-peer-left", content: `📡 Peer left: ${card.name} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false });
|
|
977
991
|
try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-left", content: `📡 Peer left: ${card.name} [${new Date().toISOString()}]` }); } catch {}
|
|
@@ -1511,11 +1525,26 @@ Do not respond to this message.`;
|
|
|
1511
1525
|
} else {
|
|
1512
1526
|
if (peerCards.has(sessionId)) {
|
|
1513
1527
|
const card = peerCards.get(sessionId);
|
|
1528
|
+
const name = knownPeerSessions.get(sessionId) ?? card?.name ?? sessionId;
|
|
1529
|
+
// A peer can reload with a STABLE coms_session_id, so the outgoing session's
|
|
1530
|
+
// cleanup unlinks the very file the incoming session just re-created. Deleting on
|
|
1531
|
+
// that unlink evicted the live peer and announced a peer-left ~200ms after the
|
|
1532
|
+
// join. Re-read the registry and match on the SESSION's own id, never the
|
|
1533
|
+
// name: two sessions may legitimately share a name, and a name-only check
|
|
1534
|
+
// would let the surviving twin keep a card whose session is genuinely gone.
|
|
1535
|
+
const stillRegistered = isSessionStillRegistered(
|
|
1536
|
+
readAllRegistryEntries(identity.project),
|
|
1537
|
+
sessionId,
|
|
1538
|
+
);
|
|
1539
|
+
if (stillRegistered) {
|
|
1540
|
+
log.info("fs_watch_peer_unlink_ignored", name, sessionId);
|
|
1541
|
+
maybeRefreshWidget();
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1514
1544
|
peerCards.delete(sessionId);
|
|
1515
1545
|
log.info("fs_watch_peer_removed", card?.name ?? sessionId);
|
|
1516
1546
|
maybeRefreshWidget();
|
|
1517
1547
|
if (knownPeerSessions.has(sessionId)) {
|
|
1518
|
-
const name = knownPeerSessions.get(sessionId) ?? sessionId;
|
|
1519
1548
|
knownPeerSessions.delete(sessionId);
|
|
1520
1549
|
const sameNameStillExists = [...peerCards.values()].some(c => c.name === name);
|
|
1521
1550
|
if (!sameNameStillExists) {
|
|
@@ -732,3 +732,78 @@ export async function getComsOriginTasks(): Promise<Array<{ taskId: string; subj
|
|
|
732
732
|
}));
|
|
733
733
|
} catch { return []; }
|
|
734
734
|
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Whether a "peer left" notification should evict this peer card.
|
|
738
|
+
*
|
|
739
|
+
* A peer's leaving broadcast is matched by SESSION id, never by name alone. On
|
|
740
|
+
* /reload the outgoing session announces itself gone roughly 200ms AFTER the
|
|
741
|
+
* incoming session has already registered under the same name; matching by name
|
|
742
|
+
* therefore removed the live session and the peer showed as disconnected until
|
|
743
|
+
* something forced it to re-register.
|
|
744
|
+
*
|
|
745
|
+
* `senderSession` is absent only for older peers that predate the field, in which
|
|
746
|
+
* case the name check stands alone as before.
|
|
747
|
+
*/
|
|
748
|
+
export function shouldEvictOnLeaving(
|
|
749
|
+
cardSession: string,
|
|
750
|
+
cardName: string,
|
|
751
|
+
senderName: string,
|
|
752
|
+
senderSession?: string,
|
|
753
|
+
): boolean {
|
|
754
|
+
const nameMatches = cardName === senderName;
|
|
755
|
+
if (!nameMatches) {
|
|
756
|
+
log.debug("peer_leaving_no_name_match", { card: cardName, sender: senderName });
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
// A sender with no session id predates the field, so the name check stands alone.
|
|
760
|
+
const evict = !senderSession || cardSession === senderSession;
|
|
761
|
+
log.debug("peer_leaving_match", {
|
|
762
|
+
card: cardName,
|
|
763
|
+
cardSession,
|
|
764
|
+
senderSession: senderSession ?? "<none>",
|
|
765
|
+
evict,
|
|
766
|
+
});
|
|
767
|
+
return evict;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Whether a session is still present in the on-disk coms registry.
|
|
772
|
+
*
|
|
773
|
+
* A peer can reload with a STABLE coms_session_id, so the outgoing session's cleanup
|
|
774
|
+
* unlinks the very registry file the incoming session just re-created. The fs-watch
|
|
775
|
+
* unlink handler therefore re-checks the registry before evicting a card.
|
|
776
|
+
*
|
|
777
|
+
* Matching is on the SESSION id, never the name: two sessions may legitimately share
|
|
778
|
+
* a name, and a name-only check would let the surviving twin keep a card whose own
|
|
779
|
+
* session had genuinely gone.
|
|
780
|
+
*/
|
|
781
|
+
export function isSessionStillRegistered(
|
|
782
|
+
entries: ReadonlyArray<{ coms_session_id?: string }>,
|
|
783
|
+
sessionId: string,
|
|
784
|
+
): boolean {
|
|
785
|
+
const present = entries.some((e) => e.coms_session_id === sessionId);
|
|
786
|
+
log.debug("peer_unlink_registry_check", { session: sessionId, present });
|
|
787
|
+
return present;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Whether a departing session should produce a user-visible "peer left" notice.
|
|
792
|
+
*
|
|
793
|
+
* Evicting only the departing session is not enough on its own: after a /reload the
|
|
794
|
+
* replacement session may already hold a live card under the same name, and announcing
|
|
795
|
+
* a departure then tells the user — and every browser event consumer — that a peer
|
|
796
|
+
* which is still connected has gone. The registry-removal path applies the same guard.
|
|
797
|
+
*/
|
|
798
|
+
export function shouldAnnouncePeerLeft(
|
|
799
|
+
cards: Iterable<{ name: string }>,
|
|
800
|
+
departedName: string,
|
|
801
|
+
): boolean {
|
|
802
|
+
for (const card of cards) {
|
|
803
|
+
if (card.name === departedName) {
|
|
804
|
+
log.debug("peer_left_announcement_suppressed", { peer: departedName });
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Async agent infrastructure — background agent spawning, polling, result watching.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { parseAsyncOutputLine } from "./async-status-parse.js";
|
|
6
|
+
import { isProcessAlive, signalAndReportSurvivors } from "./async-kill-signal.js";
|
|
5
7
|
import { createLogger } from "../shared/logger.js";
|
|
6
8
|
import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
7
9
|
import * as fs from "node:fs";
|
|
@@ -22,7 +24,7 @@ import {
|
|
|
22
24
|
getMainBranch,
|
|
23
25
|
} from "./git-helpers.js";
|
|
24
26
|
import { waitForResultFiles } from "./async-wait.js";
|
|
25
|
-
import { formatAsyncResultOutput, reviewerOutputArchivePath } from "./async-result-format.js";
|
|
27
|
+
import { formatAsyncResultOutput, reviewerOutputArchivePath, MAX_OUTPUT_CHARS } from "./async-result-format.js";
|
|
26
28
|
import { cleanupReviewerOutputArchives } from "./reviewer-output-archive.js";
|
|
27
29
|
const log = createLogger("async_agents");
|
|
28
30
|
|
|
@@ -72,8 +74,26 @@ export interface AsyncJob {
|
|
|
72
74
|
sessionId?: string;
|
|
73
75
|
model?: string;
|
|
74
76
|
restoredPid?: number;
|
|
77
|
+
/** Who killed this job, if it was killed. Drives the attribution suffix in delivery headers. */
|
|
78
|
+
killOrigin?: AsyncKillOrigin;
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Who requested an async-agent kill.
|
|
83
|
+
* - `user` — a human action: the async-status overlay `x`/`a`, or the pidash browser UI
|
|
84
|
+
* - `orchestrator` — the LLM via `subagent(asyncKill=...)`
|
|
85
|
+
* - `task-system` — pitasks TaskExecute via `subagents:rpc:stop`
|
|
86
|
+
* The pidash browser UI is a human clicking, so it is a `user` kill.
|
|
87
|
+
*/
|
|
88
|
+
export type AsyncKillOrigin = "user" | "orchestrator" | "task-system";
|
|
89
|
+
|
|
90
|
+
/** Human-readable attribution per origin. The single source for every surface. */
|
|
91
|
+
export const KILL_ORIGIN_LABELS: Record<AsyncKillOrigin, string> = {
|
|
92
|
+
user: "Killed by user",
|
|
93
|
+
orchestrator: "Killed by orchestrator",
|
|
94
|
+
"task-system": "Killed by task system",
|
|
95
|
+
};
|
|
96
|
+
|
|
77
97
|
/** Get the effective working directory for a job. */
|
|
78
98
|
function jobCwd(job: { cwd?: string; projectCwd?: string }): string {
|
|
79
99
|
return job.cwd || job.projectCwd || process.cwd();
|
|
@@ -126,7 +146,7 @@ export function registerAsyncAgents(
|
|
|
126
146
|
} = {},
|
|
127
147
|
): {
|
|
128
148
|
spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string };
|
|
129
|
-
killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
|
|
149
|
+
killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] };
|
|
130
150
|
getAsyncJobs: () => Array<{ id: string; agent: string; name?: string; task: string; status: string; startedAt: number }>;
|
|
131
151
|
} {
|
|
132
152
|
let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
|
|
@@ -141,6 +161,155 @@ export function registerAsyncAgents(
|
|
|
141
161
|
return outputPath;
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Readable text a killed worker had produced, recovered from its output.log.
|
|
166
|
+
*
|
|
167
|
+
* output.log is a JSONL event stream, so it is run through the same
|
|
168
|
+
* parseAsyncOutputLine the async-status overlay uses — reading it raw ships the
|
|
169
|
+
* raw event JSON to the AI instead of the work the agent actually did. Lines the
|
|
170
|
+
* parser does not recognise are dropped, matching overlay behaviour.
|
|
171
|
+
*
|
|
172
|
+
* Capped so a chatty agent cannot push a megabyte into the delivery message and
|
|
173
|
+
* the AI's context. The budget is sized so the composed kill output (label plus
|
|
174
|
+
* this text) still fits inside formatAsyncResultOutput's MAX_OUTPUT_CHARS: that
|
|
175
|
+
* formatter keeps the HEAD of its budget, so a tail kept here and then re-trimmed
|
|
176
|
+
* from the head would hand the AI the OLDEST part of the newest context — exactly
|
|
177
|
+
* the lines immediately before the kill that explain why it was stopped.
|
|
178
|
+
*/
|
|
179
|
+
const TRUNCATION_MARKER = "…(earlier output truncated)\n";
|
|
180
|
+
// Only a bounded tail is ever read: the runner appends output with no size cap, so
|
|
181
|
+
// reading the whole log made killing a chatty agent block the orchestrator and cost
|
|
182
|
+
// memory proportional to that log — for a tail we are about to discard anyway.
|
|
183
|
+
const MAX_TAIL_BYTES = 256 * 1024;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Last-resort text recovery from a JSONL record the tail boundary cut in half.
|
|
187
|
+
*
|
|
188
|
+
* The read runs to EOF, so a record larger than the tail keeps its END and loses only its
|
|
189
|
+
* opening — the delta field itself is usually intact. Pulling that field out directly is
|
|
190
|
+
* what lets such an event still contribute its text instead of the delivery carrying only
|
|
191
|
+
* the kill label. The second form covers a record cut mid-delta, whose closing quote never
|
|
192
|
+
* made it into the tail.
|
|
193
|
+
*/
|
|
194
|
+
function recoverTruncatedDelta(line: string, firstOfBoundedRead: boolean): string | null {
|
|
195
|
+
// A COMPLETE record that parseAsyncOutputLine deliberately rejects is not truncated
|
|
196
|
+
// output. toolcall_delta carries a `delta` field, and lifting it would ship a killed
|
|
197
|
+
// worker's raw tool arguments into the persisted output and the AI-facing delivery.
|
|
198
|
+
try {
|
|
199
|
+
JSON.parse(line);
|
|
200
|
+
return null;
|
|
201
|
+
} catch {
|
|
202
|
+
/* not a complete record — continue */
|
|
203
|
+
}
|
|
204
|
+
// A truncated record is recognised by what it CONTAINS, not by where it sits. The type
|
|
205
|
+
// markers precede the value, so a record cut mid-write still carries them; stderr never
|
|
206
|
+
// does. Positioning was the previous guard and it was wrong twice over: it discarded a
|
|
207
|
+
// legitimate partial final event — exactly what a worker killed mid-write leaves behind —
|
|
208
|
+
// whenever the log was read whole or the event followed other lines.
|
|
209
|
+
// The INNER event type, not the envelope: toolcall_delta is also a message_update, so
|
|
210
|
+
// matching the envelope would admit every truncated tool-call update as agent text.
|
|
211
|
+
const looksLikeTextEvent = line.includes('"text_delta"');
|
|
212
|
+
if (looksLikeTextEvent) {
|
|
213
|
+
const closed = /"delta"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
|
|
214
|
+
let last: RegExpExecArray | null = null;
|
|
215
|
+
let m: RegExpExecArray | null;
|
|
216
|
+
while ((m = closed.exec(line)) !== null) last = m;
|
|
217
|
+
if (last) {
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(`"${last[1]}"`);
|
|
220
|
+
} catch {
|
|
221
|
+
/* fall through to the open-ended form */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const open = line.match(/"delta"\s*:\s*"((?:[^"\\]|\\.)*)$/);
|
|
225
|
+
if (open) {
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(`"${open[1]}"`);
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// The record is larger than the whole window, so even its type markers sit before the
|
|
234
|
+
// tail and the fragment is recognisable only by position and shape: it must be the first
|
|
235
|
+
// line of a BOUNDED read and must end in a closing quote followed by closing braces.
|
|
236
|
+
// A diagnostic like `Error: cannot open "/tmp/x"` contains a quote but neither.
|
|
237
|
+
if (!firstOfBoundedRead) return null;
|
|
238
|
+
if (!/"\s*}\s*}\s*$/.test(line)) return null;
|
|
239
|
+
const q = line.lastIndexOf('"');
|
|
240
|
+
if (q <= 0) return null;
|
|
241
|
+
const fragment = line.slice(0, q);
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(`"${fragment}"`);
|
|
244
|
+
} catch {
|
|
245
|
+
return fragment;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function readPartialWorkerOutput(job: Pick<AsyncJob, "id" | "workerDir">, budget: number): string {
|
|
249
|
+
const outputPath = path.join(job.workerDir, "output.log");
|
|
250
|
+
try {
|
|
251
|
+
if (!fs.existsSync(outputPath)) {
|
|
252
|
+
log.debug("kill_output_absent", job.id);
|
|
253
|
+
return "";
|
|
254
|
+
}
|
|
255
|
+
const size = fs.statSync(outputPath).size;
|
|
256
|
+
let raw: string;
|
|
257
|
+
const usedTailRead = size > MAX_TAIL_BYTES;
|
|
258
|
+
if (!usedTailRead) {
|
|
259
|
+
raw = fs.readFileSync(outputPath, "utf-8");
|
|
260
|
+
} else {
|
|
261
|
+
const fd = fs.openSync(outputPath, "r");
|
|
262
|
+
try {
|
|
263
|
+
const buf = Buffer.alloc(MAX_TAIL_BYTES);
|
|
264
|
+
const read = fs.readSync(fd, buf, 0, MAX_TAIL_BYTES, size - MAX_TAIL_BYTES);
|
|
265
|
+
raw = buf.toString("utf-8", 0, read);
|
|
266
|
+
} finally {
|
|
267
|
+
fs.closeSync(fd);
|
|
268
|
+
}
|
|
269
|
+
// The tail may start mid-record, but that is NOT a reason to discard anything: the
|
|
270
|
+
// record might be complete, and even a partial one can still yield its text. Blanking
|
|
271
|
+
// the first line unconditionally lost a complete event whenever the offset happened to
|
|
272
|
+
// land on a record boundary. Unparseable lines are recovered leniently below instead.
|
|
273
|
+
log.debug("kill_output_tail_read", { job: job.id, size, read: MAX_TAIL_BYTES });
|
|
274
|
+
}
|
|
275
|
+
// Concatenate each parsed value VERBATIM. parseAsyncOutputLine returns text deltas
|
|
276
|
+
// unchanged and prefixes tool events with a newline, so appending preserves both the
|
|
277
|
+
// fragment boundaries of a streamed word ("Hel" + "lo") and the event boundaries.
|
|
278
|
+
// Splitting each value into lines and rejoining with "\n" turned "Hel" "lo" into
|
|
279
|
+
// "Hel\nlo" — corrupting the recovered text of every killed agent.
|
|
280
|
+
let text = "";
|
|
281
|
+
// Truncated-record recovery runs on any line that fails to parse, but recoverTruncatedDelta
|
|
282
|
+
// decides on what the line CONTAINS: a partial assistant event keeps its type markers,
|
|
283
|
+
// while stderr and complete-but-rejected events never qualify. `usedTailRead && i === 0`
|
|
284
|
+
// is passed separately because an oversized record is recognisable only by position —
|
|
285
|
+
// its markers fall before the window.
|
|
286
|
+
const lines = raw.split("\n");
|
|
287
|
+
for (let i = 0; i < lines.length; i++) {
|
|
288
|
+
const line = lines[i];
|
|
289
|
+
if (!line.trim()) continue;
|
|
290
|
+
const parsed = parseAsyncOutputLine(line)
|
|
291
|
+
?? recoverTruncatedDelta(line, usedTailRead && i === 0);
|
|
292
|
+
if (parsed === null) continue;
|
|
293
|
+
text += parsed;
|
|
294
|
+
}
|
|
295
|
+
text = text.trim();
|
|
296
|
+
if (!text) {
|
|
297
|
+
log.debug("kill_output_no_readable_events", job.id);
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
const out = text.length > budget
|
|
301
|
+
? `${TRUNCATION_MARKER}${text.slice(-budget)}`
|
|
302
|
+
: text;
|
|
303
|
+
log.info("kill_output_recovered", { job: job.id, chars: out.length, truncated: out !== text });
|
|
304
|
+
return out;
|
|
305
|
+
} catch (e: any) {
|
|
306
|
+
// Handled loss: the kill path has no other output to fall back on, so the agent's
|
|
307
|
+
// partial work never reaches the result delivered to the AI.
|
|
308
|
+
log.warn("kill_output_recovery_failed", { job: job.id, error: e?.message || String(e) });
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
144
313
|
function preserveReviewerOutput(job: Pick<AsyncJob, "agent" | "id">, output: string): void {
|
|
145
314
|
if (!job.agent.startsWith("code-reviewer-")) return;
|
|
146
315
|
const outputPath = reviewerOutputArchivePath(PROJECT_TMP_DIR, job.id);
|
|
@@ -596,7 +765,10 @@ export function registerAsyncAgents(
|
|
|
596
765
|
}
|
|
597
766
|
}
|
|
598
767
|
const duration = j.durationMs || (j.updatedAt ? j.updatedAt - j.startedAt : 0);
|
|
599
|
-
|
|
768
|
+
// Killed members carry the same attribution suffix the non-grouped kill path uses, so a
|
|
769
|
+
// group result says who stopped the job instead of only reporting that it failed.
|
|
770
|
+
const killSuffix = j.killOrigin ? ` — ${KILL_ORIGIN_LABELS[j.killOrigin]}` : "";
|
|
771
|
+
sections.push(`## Async Agent Result: ${displayName} ${resultStatus}${killSuffix}\n\nTask: ${j.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`);
|
|
600
772
|
deliverableJobs.push(j);
|
|
601
773
|
}
|
|
602
774
|
|
|
@@ -1376,9 +1548,7 @@ export function registerAsyncAgents(
|
|
|
1376
1548
|
log.debug("opening_async_status_overlay", { jobs: asyncState.jobs.size });
|
|
1377
1549
|
await openAsyncStatusOverlay(ctx, {
|
|
1378
1550
|
listJobs: () => Array.from(asyncState.jobs.values()),
|
|
1379
|
-
killJob: (id) =>
|
|
1380
|
-
killAsyncAgent(id);
|
|
1381
|
-
},
|
|
1551
|
+
killJob: (id) => killAsyncAgent(id, "user"),
|
|
1382
1552
|
formatDuration,
|
|
1383
1553
|
readLiveStatus: (workerDir) => readAsyncStatus(workerDir),
|
|
1384
1554
|
});
|
|
@@ -1391,7 +1561,13 @@ export function registerAsyncAgents(
|
|
|
1391
1561
|
});
|
|
1392
1562
|
|
|
1393
1563
|
// Kill an async agent by name, id prefix, or "all"
|
|
1394
|
-
function killAsyncAgent(target: string): { killed: string[]; errors: string[] } {
|
|
1564
|
+
function killAsyncAgent(target: string, origin: AsyncKillOrigin = "user"): { killed: string[]; errors: string[] } {
|
|
1565
|
+
// Runtime guard: callers outside the type system still get a safe, non-false label.
|
|
1566
|
+
// Object.hasOwn, not a plain lookup — inherited keys such as `toString` would otherwise
|
|
1567
|
+
// pass the truthiness check and make the label a function instead of a string.
|
|
1568
|
+
const killOrigin: AsyncKillOrigin = Object.hasOwn(KILL_ORIGIN_LABELS, origin) ? origin : "user";
|
|
1569
|
+
const killLabel = KILL_ORIGIN_LABELS[killOrigin];
|
|
1570
|
+
log.info("async_kill_requested", { target, origin: killOrigin, label: killLabel });
|
|
1395
1571
|
const killed: string[] = [];
|
|
1396
1572
|
const errors: string[] = [];
|
|
1397
1573
|
const running = Array.from(asyncState.jobs.values()).filter(
|
|
@@ -1416,26 +1592,74 @@ export function registerAsyncAgents(
|
|
|
1416
1592
|
return { killed, errors };
|
|
1417
1593
|
}
|
|
1418
1594
|
|
|
1595
|
+
// The failed-pid set is per-target: declared outside the loop, a pid that survived one
|
|
1596
|
+
// target's failed signal was re-checked for every later target, and a later agent that
|
|
1597
|
+
// had been signalled successfully would skip its state and result updates and stay
|
|
1598
|
+
// marked running.
|
|
1419
1599
|
for (const job of targets) {
|
|
1420
1600
|
const status = readAsyncStatus(job.workerDir);
|
|
1601
|
+
const pidsToSignal: number[] = [];
|
|
1421
1602
|
if (status?.pid) {
|
|
1422
1603
|
try {
|
|
1423
1604
|
const tree = execFileSync("pstree", ["-p", String(status.pid)], { encoding: "utf-8", timeout: 3000 });
|
|
1424
1605
|
const matches = tree.match(/\((\d+)\)/g);
|
|
1425
|
-
|
|
1426
|
-
for (const pid of allPids) {
|
|
1427
|
-
try { process.kill(pid, "SIGKILL"); } catch {}
|
|
1428
|
-
}
|
|
1606
|
+
pidsToSignal.push(...(matches ? [...new Set(matches.map((m: string) => parseInt(m.slice(1, -1), 10)))] : [status.pid]));
|
|
1429
1607
|
} catch {
|
|
1430
|
-
|
|
1431
|
-
if (status.childPid)
|
|
1608
|
+
pidsToSignal.push(status.pid);
|
|
1609
|
+
if (status.childPid) pidsToSignal.push(status.childPid);
|
|
1432
1610
|
}
|
|
1433
1611
|
}
|
|
1612
|
+
const stillAlive = signalAndReportSurvivors(
|
|
1613
|
+
[...new Set(pidsToSignal)],
|
|
1614
|
+
(pid) => { process.kill(pid, "SIGKILL"); },
|
|
1615
|
+
isProcessAlive,
|
|
1616
|
+
);
|
|
1434
1617
|
const label = job.name || job.agent;
|
|
1618
|
+
// A swallowed signal failure used to be reported as a successful kill: the job was
|
|
1619
|
+
// marked failed and counted as killed while its process kept running. Only claim a
|
|
1620
|
+
// kill when nothing we signalled is still alive.
|
|
1621
|
+
if (stillAlive.length > 0) {
|
|
1622
|
+
errors.push(`Could not stop ${label} (pid ${stillAlive.join(", ")} still running).`);
|
|
1623
|
+
log.error("async_kill_signal_failed", { label, pids: stillAlive, target });
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1435
1626
|
killed.push(label);
|
|
1436
1627
|
job.status = "failed";
|
|
1437
1628
|
job.updatedAt = Date.now();
|
|
1438
1629
|
job.durationMs = Date.now() - job.startedAt;
|
|
1630
|
+
// Resolve the agent's prior output BEFORE writing either destination.
|
|
1631
|
+
// Prefer the in-memory value, then what status.json already held, then the worker's
|
|
1632
|
+
// output.log — a job killed mid-run has neither of the first two, because `output`
|
|
1633
|
+
// only lands in status.json on the completion path and job.output stays undefined
|
|
1634
|
+
// until then. Reading output.log is what stops the AI receiving a kill label and
|
|
1635
|
+
// none of the work the agent had actually done.
|
|
1636
|
+
let persistedPriorOutput = "";
|
|
1637
|
+
try {
|
|
1638
|
+
const statusPath = path.join(job.workerDir, "status.json");
|
|
1639
|
+
if (fs.existsSync(statusPath)) {
|
|
1640
|
+
persistedPriorOutput = typeof JSON.parse(fs.readFileSync(statusPath, "utf-8"))?.output === "string"
|
|
1641
|
+
? JSON.parse(fs.readFileSync(statusPath, "utf-8")).output
|
|
1642
|
+
: "";
|
|
1643
|
+
}
|
|
1644
|
+
} catch (e: any) {
|
|
1645
|
+
log.debug(`kill: could not read prior output for ${job.id}: ${e?.message}`);
|
|
1646
|
+
}
|
|
1647
|
+
const inMemoryOutput = typeof job.output === "string" ? job.output : "";
|
|
1648
|
+
const partialFromLog = inMemoryOutput || persistedPriorOutput ? "" : readPartialWorkerOutput(
|
|
1649
|
+
job,
|
|
1650
|
+
// Budget the label, the newline joining them, and the truncation marker itself —
|
|
1651
|
+
// otherwise the composed string still overruns MAX_OUTPUT_CHARS and
|
|
1652
|
+
// formatAsyncResultOutput head-trims it, which drops the newest lines.
|
|
1653
|
+
Math.max(0, MAX_OUTPUT_CHARS - killLabel.length - TRUNCATION_MARKER.length - 1),
|
|
1654
|
+
);
|
|
1655
|
+
if (partialFromLog) {
|
|
1656
|
+
log.debug("kill: recovered partial output from output.log", { jobId: job.id, chars: partialFromLog.length });
|
|
1657
|
+
}
|
|
1658
|
+
const priorOutput = inMemoryOutput || persistedPriorOutput || partialFromLog;
|
|
1659
|
+
const composedOutput = priorOutput ? `${killLabel}\n${priorOutput}` : killLabel;
|
|
1660
|
+
job.output = composedOutput;
|
|
1661
|
+
job.killOrigin = killOrigin;
|
|
1662
|
+
|
|
1439
1663
|
// Persist killed state to disk — prevents stale re-delivery on reload
|
|
1440
1664
|
try {
|
|
1441
1665
|
const statusPath = path.join(job.workerDir, "status.json");
|
|
@@ -1443,7 +1667,10 @@ export function registerAsyncAgents(
|
|
|
1443
1667
|
existing.state = "failed";
|
|
1444
1668
|
existing.exitCode = -9;
|
|
1445
1669
|
existing.endedAt = Date.now();
|
|
1446
|
-
existing.
|
|
1670
|
+
existing.killOrigin = killOrigin;
|
|
1671
|
+
// Same composed text as job.output, so the overlay and pidash read exactly what
|
|
1672
|
+
// the AI receives instead of losing the partial output.
|
|
1673
|
+
existing.output = composedOutput;
|
|
1447
1674
|
fs.writeFileSync(statusPath, JSON.stringify(existing), { mode: 0o600 });
|
|
1448
1675
|
} catch (e: any) { log.error(`kill: status.json update failed for ${job.id}: ${e?.message}`); }
|
|
1449
1676
|
// Delete result file if it exists — prevent re-ingestion on reload
|
|
@@ -1465,13 +1692,12 @@ export function registerAsyncAgents(
|
|
|
1465
1692
|
// Non-grouped killed job — deliver immediately so AI knows it was killed
|
|
1466
1693
|
const displayName = job.name || job.agent;
|
|
1467
1694
|
const duration = job.durationMs || (Date.now() - job.startedAt);
|
|
1468
|
-
const rawOutput = typeof job.output === "string" ? job.output : "Killed by user";
|
|
1469
1695
|
const output = formatAsyncResultOutput(
|
|
1470
1696
|
job.agent,
|
|
1471
|
-
|
|
1697
|
+
job.output,
|
|
1472
1698
|
resultOutputPath(job),
|
|
1473
1699
|
);
|
|
1474
|
-
const killContent = `## Async Agent Result: ${displayName} ❌ failed\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
|
|
1700
|
+
const killContent = `## Async Agent Result: ${displayName} ❌ failed — ${killLabel}\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
|
|
1475
1701
|
if (wasAlreadyDelivered(job.id)) {
|
|
1476
1702
|
job.delivered = true;
|
|
1477
1703
|
log.debug(`kill: skipping already-delivered result for ${job.id}`);
|
|
@@ -1502,49 +1728,11 @@ export function registerAsyncAgents(
|
|
|
1502
1728
|
return { killed, errors };
|
|
1503
1729
|
}
|
|
1504
1730
|
|
|
1505
|
-
// /async-kill handler — extracted for readability (closure access preserved)
|
|
1506
|
-
async function handleAsyncKill(args: string, ctx: any): Promise<void> {
|
|
1507
|
-
// If arg provided, kill directly without interactive selection
|
|
1508
|
-
if (args) {
|
|
1509
|
-
const { killed, errors } = killAsyncAgent(args);
|
|
1510
|
-
if (killed.length > 0) {
|
|
1511
|
-
ctx.ui.notify(`Killed: ${killed.join(", ")}`, "info");
|
|
1512
|
-
}
|
|
1513
|
-
if (errors.length > 0) {
|
|
1514
|
-
ctx.ui.notify(errors.join("\n"), "warning");
|
|
1515
|
-
}
|
|
1516
|
-
return;
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
if (!ctx.hasUI) return;
|
|
1520
|
-
// Same overlay as /async-status, scoped to running/queued
|
|
1521
|
-
await openAsyncStatusOverlay(ctx, {
|
|
1522
|
-
title: "Kill async agents",
|
|
1523
|
-
emptyMessage: "No running async agents.",
|
|
1524
|
-
footerHints: "↑↓/jk select · Enter view · x kill · Esc close",
|
|
1525
|
-
listJobs: () =>
|
|
1526
|
-
Array.from(asyncState.jobs.values()).filter(
|
|
1527
|
-
(j) => j.status === "running" || j.status === "queued",
|
|
1528
|
-
),
|
|
1529
|
-
killJob: (id) => {
|
|
1530
|
-
killAsyncAgent(id);
|
|
1531
|
-
},
|
|
1532
|
-
formatDuration,
|
|
1533
|
-
readLiveStatus: (workerDir) => readAsyncStatus(workerDir),
|
|
1534
|
-
});
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// /async-kill command — accepts name/id/"all" or interactive overlay
|
|
1538
|
-
pi.registerCommand("async-kill", {
|
|
1539
|
-
description:
|
|
1540
|
-
"Kill async agent(s) — /async-kill <name|id|all> or overlay picker",
|
|
1541
|
-
handler: async (_args, ctx) => handleAsyncKill((_args || "").trim(), ctx),
|
|
1542
|
-
});
|
|
1543
|
-
|
|
1544
1731
|
// Handle async-kill from pidash browser UI
|
|
1545
1732
|
pi.events.on("pidash:async-kill", (target: unknown) => {
|
|
1546
1733
|
if (typeof target === "string") {
|
|
1547
|
-
|
|
1734
|
+
// A human clicking in the browser UI — still a user kill.
|
|
1735
|
+
killAsyncAgent(target, "user");
|
|
1548
1736
|
}
|
|
1549
1737
|
});
|
|
1550
1738
|
|
|
@@ -1612,7 +1800,7 @@ export function registerAsyncAgents(
|
|
|
1612
1800
|
// Stop — kill a running async agent
|
|
1613
1801
|
handleRpc<{ requestId: string; agentId: string }>(
|
|
1614
1802
|
"subagents:rpc:stop", ({ agentId }) => {
|
|
1615
|
-
const { killed, errors } = killAsyncAgent(agentId);
|
|
1803
|
+
const { killed, errors } = killAsyncAgent(agentId, "task-system");
|
|
1616
1804
|
if (killed.length === 0) throw new Error(errors[0] || "Agent not found");
|
|
1617
1805
|
},
|
|
1618
1806
|
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure signalling helpers for killing async workers.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of pi/TUI deps so the kill decision is unit-testable: a failed signal must
|
|
5
|
+
* never be mistaken for a successful one, and one target's failure must not be charged
|
|
6
|
+
* to the next.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Signal each pid, returning those still alive afterwards. */
|
|
10
|
+
export function signalAndReportSurvivors(
|
|
11
|
+
pids: ReadonlyArray<number>,
|
|
12
|
+
signal: (pid: number) => void,
|
|
13
|
+
isAlive: (pid: number) => boolean,
|
|
14
|
+
): number[] {
|
|
15
|
+
const unsignalled: number[] = [];
|
|
16
|
+
for (const pid of pids) {
|
|
17
|
+
try {
|
|
18
|
+
signal(pid);
|
|
19
|
+
} catch {
|
|
20
|
+
unsignalled.push(pid);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Only the pids we FAILED to signal need a liveness check. A pid that was signalled
|
|
24
|
+
// successfully is gone, and re-probing it would let one target's leftovers leak into
|
|
25
|
+
// the next target's verdict.
|
|
26
|
+
return unsignalled.filter(isAlive);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a pid is still running.
|
|
31
|
+
*
|
|
32
|
+
* Only an explicit ESRCH proves the process is gone. Any other failure — EPERM above all —
|
|
33
|
+
* means we were denied, not that the process exited, and treating it as "gone" reports a
|
|
34
|
+
* live worker as killed.
|
|
35
|
+
*/
|
|
36
|
+
export function isProcessAlive(pid: number): boolean {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0);
|
|
39
|
+
return true;
|
|
40
|
+
} catch (e: any) {
|
|
41
|
+
return e?.code !== "ESRCH";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -5,7 +5,9 @@ import * as path from "node:path";
|
|
|
5
5
|
import { createLogger } from "../shared/logger.js";
|
|
6
6
|
|
|
7
7
|
const log = createLogger("async_agents");
|
|
8
|
-
|
|
8
|
+
/** Delivery budget for a single result's output. Exported so callers can size their own
|
|
9
|
+
* contribution against it — formatAsyncResultOutput keeps the HEAD of this budget. */
|
|
10
|
+
export const MAX_OUTPUT_CHARS = 3000;
|
|
9
11
|
|
|
10
12
|
/** Return the completed-result archive path, outside cleanup-prone worker directories. */
|
|
11
13
|
export function reviewerOutputArchivePath(projectTmpDir: string, jobId: string): string {
|
|
@@ -120,7 +120,7 @@ async function run(config: RunConfig): Promise<void> {
|
|
|
120
120
|
stdio: ["ignore", "pipe", "pipe"],
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
// Store child PID in status file so
|
|
123
|
+
// Store child PID in status file so kill paths can find it
|
|
124
124
|
if (proc.pid) {
|
|
125
125
|
status.childPid = proc.pid;
|
|
126
126
|
writeJson(statusPath, status);
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
openListDetailOverlay,
|
|
11
11
|
OverlayScrollDetail,
|
|
12
12
|
} from "./overlay-dashboard.js";
|
|
13
|
+
import { createLogger } from "../shared/logger.js";
|
|
14
|
+
import { KILL_ORIGIN_LABELS, type AsyncKillOrigin } from "./async-agents.js";
|
|
15
|
+
|
|
16
|
+
const log = createLogger("async-status");
|
|
13
17
|
|
|
14
18
|
export { parseAsyncOutputLine } from "./async-status-parse.js";
|
|
15
19
|
|
|
@@ -27,10 +31,10 @@ export interface AsyncStatusJobView {
|
|
|
27
31
|
|
|
28
32
|
export interface AsyncStatusUiDeps {
|
|
29
33
|
listJobs: () => AsyncStatusJobView[];
|
|
30
|
-
killJob: (id: string) => void;
|
|
34
|
+
killJob: (id: string) => { killed: string[]; errors: string[] } | void;
|
|
31
35
|
formatDuration: (ms: number) => string;
|
|
32
36
|
/** Optional live status.json reader for detail header. */
|
|
33
|
-
readLiveStatus?: (workerDir: string) => { state?: string } | null;
|
|
37
|
+
readLiveStatus?: (workerDir: string) => { state?: string; killOrigin?: string } | null;
|
|
34
38
|
title?: string;
|
|
35
39
|
emptyMessage?: string;
|
|
36
40
|
footerHints?: string;
|
|
@@ -40,6 +44,36 @@ function jobTitle(job: AsyncStatusJobView): string {
|
|
|
40
44
|
return job.name || job.agent;
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Attribution suffix for the detail header: " · killed by <origin>" when the
|
|
49
|
+
* live status.json records who killed the job, otherwise nothing.
|
|
50
|
+
*
|
|
51
|
+
* Uses KILL_ORIGIN_LABELS rather than echoing the stored key, so the overlay reads
|
|
52
|
+
* "killed by task system" like every other surface instead of "killed by task-system".
|
|
53
|
+
* An unrecognised value is ignored rather than rendered, so a hand-edited
|
|
54
|
+
* status.json cannot inject arbitrary text into the header.
|
|
55
|
+
*
|
|
56
|
+
* Extracted so it can be tested without driving the interactive overlay loop.
|
|
57
|
+
*/
|
|
58
|
+
export function formatKillOriginSuffix(
|
|
59
|
+
killOrigin: string | undefined,
|
|
60
|
+
theme: { fg: (color: string, value: string) => string },
|
|
61
|
+
): string {
|
|
62
|
+
if (!killOrigin) {
|
|
63
|
+
log.debug("kill_origin_header: no origin recorded");
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
const label = Object.hasOwn(KILL_ORIGIN_LABELS, killOrigin)
|
|
67
|
+
? KILL_ORIGIN_LABELS[killOrigin as AsyncKillOrigin]
|
|
68
|
+
: null;
|
|
69
|
+
if (!label) {
|
|
70
|
+
log.warn("kill_origin_header: unrecognised origin ignored", { killOrigin });
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
log.debug("kill_origin_header: rendering attribution", { killOrigin });
|
|
74
|
+
return theme.fg("dim", ` · ${label.charAt(0).toLowerCase()}${label.slice(1)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
function isActive(status: string): boolean {
|
|
44
78
|
return status === "running" || status === "queued";
|
|
45
79
|
}
|
|
@@ -78,6 +112,61 @@ function elapsedMs(job: AsyncStatusJobView): number {
|
|
|
78
112
|
return Date.now() - job.startedAt;
|
|
79
113
|
}
|
|
80
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Kill every running/queued job. Destructive and immediate, so gated behind an
|
|
117
|
+
* explicit confirm; a no-op when the host has no confirm dialog.
|
|
118
|
+
*/
|
|
119
|
+
/**
|
|
120
|
+
* Kill every running/queued job. No confirmation prompt: `X` is a deliberate,
|
|
121
|
+
* capitalised key, and gating it on `ctx.ui.confirm` left the overlay hanging on
|
|
122
|
+
* hosts where that dialog does not resolve — the key simply appeared dead.
|
|
123
|
+
*/
|
|
124
|
+
export function killAllJobs(
|
|
125
|
+
ctx: ExtensionCommandContext,
|
|
126
|
+
deps: AsyncStatusUiDeps,
|
|
127
|
+
): void {
|
|
128
|
+
const targets = deps.listJobs().filter((j) => isActive(j.status));
|
|
129
|
+
if (targets.length === 0) {
|
|
130
|
+
log.debug("kill_all: nothing active");
|
|
131
|
+
ctx.ui?.notify?.("No running async agents to kill.", "info");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Each kill is attempted independently: a failure on one job must not leave the
|
|
135
|
+
// remaining agents running after the user asked for all of them to die. A callback that
|
|
136
|
+
// returns errors (rather than throwing) counts as a failure too — signalling can fail
|
|
137
|
+
// while the worker is still alive, and reporting that as "killed" would be a lie.
|
|
138
|
+
const failed: string[] = [];
|
|
139
|
+
let killed = 0;
|
|
140
|
+
for (const job of targets) {
|
|
141
|
+
try {
|
|
142
|
+
const result = deps.killJob(job.id);
|
|
143
|
+
if (result?.errors?.length) {
|
|
144
|
+
failed.push(...result.errors);
|
|
145
|
+
log.error("kill_all: job kill reported errors", { job: job.id, errors: result.errors });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
killed++;
|
|
149
|
+
} catch (e: any) {
|
|
150
|
+
const message = e?.message || String(e);
|
|
151
|
+
failed.push(`${job.id}: ${message}`);
|
|
152
|
+
log.error("kill_all: job kill failed", { job: job.id, error: message });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (failed.length === 0) {
|
|
156
|
+
log.info("kill_all: killed", { count: killed });
|
|
157
|
+
ctx.ui?.notify?.(
|
|
158
|
+
`Killed ${killed} async agent${killed === 1 ? "" : "s"}.`,
|
|
159
|
+
"info",
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
log.error("kill_all: partially failed", { killed, failed: failed.length });
|
|
164
|
+
ctx.ui?.notify?.(
|
|
165
|
+
`Killed ${killed} of ${targets.length} async agents — ${failed.length} failed. See logs.`,
|
|
166
|
+
"warning",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
81
170
|
/**
|
|
82
171
|
* Open fullscreen async-status picker. Loops list → detail until user closes list.
|
|
83
172
|
*/
|
|
@@ -96,7 +185,8 @@ export async function openAsyncStatusOverlay(
|
|
|
96
185
|
return `jobs · ${active} active / ${jobs.length}`;
|
|
97
186
|
},
|
|
98
187
|
footerHints:
|
|
99
|
-
deps.footerHints ??
|
|
188
|
+
deps.footerHints ??
|
|
189
|
+
"↑↓/jk navigate · Space select · x kill selection · X kill all · Esc close",
|
|
100
190
|
listItems: () => deps.listJobs(),
|
|
101
191
|
rowParts: (job, theme) => {
|
|
102
192
|
const shortId = job.id.length > 8 ? job.id.slice(-8) : job.id;
|
|
@@ -118,6 +208,15 @@ export async function openAsyncStatusOverlay(
|
|
|
118
208
|
onX: (job) => {
|
|
119
209
|
if (isActive(job.status)) deps.killJob(job.id);
|
|
120
210
|
},
|
|
211
|
+
selectable: true,
|
|
212
|
+
onXSelected: (jobs) => {
|
|
213
|
+
const targets = jobs.filter((j) => isActive(j.status));
|
|
214
|
+
log.debug("kill_selection", { selected: jobs.length, killed: targets.length });
|
|
215
|
+
for (const job of targets) deps.killJob(job.id);
|
|
216
|
+
},
|
|
217
|
+
onKillAll: () => {
|
|
218
|
+
void killAllJobs(ctx, deps);
|
|
219
|
+
},
|
|
121
220
|
},
|
|
122
221
|
createDetail: (job, tui, theme, done) => {
|
|
123
222
|
const lines: string[] = [];
|
|
@@ -182,6 +281,8 @@ export async function openAsyncStatusOverlay(
|
|
|
182
281
|
const current = deps.listJobs().find((j) => j.id === jobId) || job;
|
|
183
282
|
const state = live?.state || current.status;
|
|
184
283
|
const dur = deps.formatDuration(elapsedMs(current));
|
|
284
|
+
// Show who killed a failed job rather than guessing "user".
|
|
285
|
+
const origin = formatKillOriginSuffix(live?.killOrigin, t);
|
|
185
286
|
return (
|
|
186
287
|
`${statusGlyph(state, t)} ` +
|
|
187
288
|
t.fg(
|
|
@@ -189,6 +290,7 @@ export async function openAsyncStatusOverlay(
|
|
|
189
290
|
t.bold(`${jobTitle(current)} · ${current.id.slice(-8)}`),
|
|
190
291
|
) +
|
|
191
292
|
t.fg("muted", ` · ${state} · ${dur}`) +
|
|
293
|
+
origin +
|
|
192
294
|
t.fg("dim", ` · ${current.task.slice(0, 40)}`)
|
|
193
295
|
);
|
|
194
296
|
},
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
* /create-skill <Tab> → (free-text name)
|
|
20
20
|
* /cron <Tab> → add, list, remove
|
|
21
21
|
* /dream-auto <Tab> → on, off
|
|
22
|
-
* /async-kill <Tab> → all (or type name / id prefix)
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
24
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -301,12 +300,6 @@ function registerCompletions(
|
|
|
301
300
|
], prefix);
|
|
302
301
|
},
|
|
303
302
|
|
|
304
|
-
"async-kill": (prefix: string) => {
|
|
305
|
-
return filter([
|
|
306
|
-
{ value: "all", label: "all", description: "Kill all running/queued async agents" },
|
|
307
|
-
], prefix);
|
|
308
|
-
},
|
|
309
|
-
|
|
310
303
|
"cron": (prefix: string) => {
|
|
311
304
|
const parts = prefix.split(/\s+/);
|
|
312
305
|
const lastPart = parts[parts.length - 1] || "";
|
|
@@ -16,18 +16,26 @@ import {
|
|
|
16
16
|
visibleWidth,
|
|
17
17
|
wrapTextWithAnsi,
|
|
18
18
|
} from "@earendil-works/pi-tui";
|
|
19
|
+
import { createLogger } from "../shared/logger.js";
|
|
19
20
|
import {
|
|
20
21
|
reconcileSelection,
|
|
21
22
|
type OverlayId,
|
|
22
23
|
type OverlaySelection,
|
|
23
24
|
} from "./overlay-dashboard-utils.js";
|
|
24
25
|
|
|
26
|
+
const log = createLogger("overlay-dashboard");
|
|
27
|
+
|
|
25
28
|
export {
|
|
26
29
|
reconcileSelection,
|
|
27
30
|
type OverlayId,
|
|
28
31
|
type OverlaySelection,
|
|
29
32
|
} from "./overlay-dashboard-utils.js";
|
|
30
33
|
|
|
34
|
+
/** Footer hint shown when a list spec does not provide its own. */
|
|
35
|
+
export const DEFAULT_LIST_HINTS = "↑↓/jk select · Enter view · x kill · Esc close";
|
|
36
|
+
/** Appended to the default hints only for `selectable` specs. */
|
|
37
|
+
export const SELECT_HINT = "Space multi-select";
|
|
38
|
+
|
|
31
39
|
export const OVERLAY_OPTS = {
|
|
32
40
|
overlay: true as const,
|
|
33
41
|
overlayOptions: {
|
|
@@ -55,11 +63,18 @@ export interface OverlayListSpec<
|
|
|
55
63
|
title: string;
|
|
56
64
|
countLabel: (items: readonly TItem[]) => string;
|
|
57
65
|
borderTitle: (items: readonly TItem[]) => string;
|
|
58
|
-
|
|
66
|
+
/** Defaults to DEFAULT_LIST_HINTS (+ "Space multi-select" when selectable). */
|
|
67
|
+
footerHints?: string;
|
|
59
68
|
listItems: () => TItem[];
|
|
60
69
|
rowParts: (item: TItem, theme: Theme) => OverlayRowParts;
|
|
61
70
|
/** Optional destructive/action key (x). */
|
|
62
71
|
onX?: (item: TItem) => void;
|
|
72
|
+
/** Opt-in multi-select: Space toggles the focused row; `x` then acts on the whole selection. */
|
|
73
|
+
selectable?: boolean;
|
|
74
|
+
/** Called instead of onX while the multi-select set is non-empty. */
|
|
75
|
+
onXSelected?: (items: TItem[]) => void;
|
|
76
|
+
/** Optional bulk-action key (a) — destructive, so callers should confirm first. */
|
|
77
|
+
onKillAll?: () => void;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
export interface OverlayDetailSpec {
|
|
@@ -111,6 +126,8 @@ export class OverlayListDashboard<
|
|
|
111
126
|
private ticker: ReturnType<typeof setInterval>;
|
|
112
127
|
private cachedWidth: number | undefined;
|
|
113
128
|
private cachedLines: string[] | undefined;
|
|
129
|
+
/** Multi-selected ids — empty unless spec.selectable is set. */
|
|
130
|
+
private readonly multi = new Set<TId>();
|
|
114
131
|
|
|
115
132
|
constructor(
|
|
116
133
|
private tui: TUI,
|
|
@@ -189,7 +206,43 @@ export class OverlayListDashboard<
|
|
|
189
206
|
}
|
|
190
207
|
return;
|
|
191
208
|
}
|
|
209
|
+
if (this.spec.selectable && (matchesKey(data, Key.space) || data === " ")) {
|
|
210
|
+
const focused = items[this.selection.index];
|
|
211
|
+
if (focused) {
|
|
212
|
+
if (this.multi.has(focused.id)) this.multi.delete(focused.id);
|
|
213
|
+
else this.multi.add(focused.id);
|
|
214
|
+
log.debug("list_multi_select_toggle", { id: focused.id, size: this.multi.size });
|
|
215
|
+
this.invalidate();
|
|
216
|
+
this.tui.requestRender(true);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if ((data === "a" || data === "A" || data === "X") && this.spec.onKillAll) {
|
|
221
|
+
log.debug("list_kill_all_key", { key: data, selected: this.multi.size });
|
|
222
|
+
// Cleared for the same reason the x path clears it: killing marks jobs failed, so the
|
|
223
|
+
// stale ids would survive and a following `x` would act on them (or on nothing) instead
|
|
224
|
+
// of on the newly focused running row.
|
|
225
|
+
this.multi.clear();
|
|
226
|
+
this.spec.onKillAll();
|
|
227
|
+
this.invalidate();
|
|
228
|
+
this.tui.requestRender(true);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
192
231
|
if (data === "x") {
|
|
232
|
+
if (this.multi.size > 0) {
|
|
233
|
+
const selected = items.filter((i) => this.multi.has(i.id));
|
|
234
|
+
// Cleared unconditionally: killed jobs disappear, and stale ids would leak.
|
|
235
|
+
this.multi.clear();
|
|
236
|
+
log.debug("list_multi_x", { count: selected.length });
|
|
237
|
+
if (selected.length > 0) this.spec.onXSelected?.(selected);
|
|
238
|
+
if (this.items().length === 0) {
|
|
239
|
+
this.close(null);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.invalidate();
|
|
243
|
+
this.tui.requestRender(true);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
193
246
|
const item = items[this.selection.index];
|
|
194
247
|
if (item) {
|
|
195
248
|
this.spec.onX?.(item);
|
|
@@ -209,6 +262,7 @@ export class OverlayListDashboard<
|
|
|
209
262
|
const theme = this.theme;
|
|
210
263
|
const items = this.items();
|
|
211
264
|
reconcileSelection(this.selection, items);
|
|
265
|
+
log.debug("list_render", { items: items.length, focused: this.selection.index, multi: this.multi.size });
|
|
212
266
|
|
|
213
267
|
const rows = this.tui.terminal.rows || 30;
|
|
214
268
|
const bodyHeight = Math.max(6, rows - 5);
|
|
@@ -246,15 +300,21 @@ export class OverlayListDashboard<
|
|
|
246
300
|
theme.fg("border", "╯"),
|
|
247
301
|
);
|
|
248
302
|
|
|
249
|
-
lines.push(
|
|
250
|
-
truncateToWidth(theme.fg("dim", ` ${this.spec.footerHints}`), width),
|
|
251
|
-
);
|
|
303
|
+
lines.push(truncateToWidth(theme.fg("dim", ` ${this.footerText()}`), width));
|
|
252
304
|
|
|
253
305
|
this.cachedLines = lines;
|
|
254
306
|
this.cachedWidth = width;
|
|
255
307
|
return lines;
|
|
256
308
|
}
|
|
257
309
|
|
|
310
|
+
/** Spec hints, falling back to the shared defaults (+ toggle hint when selectable). */
|
|
311
|
+
private footerText(): string {
|
|
312
|
+
if (this.spec.footerHints) return this.spec.footerHints;
|
|
313
|
+
if (!this.spec.selectable) return DEFAULT_LIST_HINTS;
|
|
314
|
+
log.debug("list_footer_hints", { source: "default", selectable: true });
|
|
315
|
+
return `${DEFAULT_LIST_HINTS} · ${SELECT_HINT}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
258
318
|
private renderRows(
|
|
259
319
|
items: ReadonlyArray<TItem>,
|
|
260
320
|
width: number,
|
|
@@ -263,6 +323,8 @@ export class OverlayListDashboard<
|
|
|
263
323
|
const theme = this.theme;
|
|
264
324
|
const out: string[] = [];
|
|
265
325
|
|
|
326
|
+
log.debug("list_render_rows", { items: items.length, height, multi: this.multi.size });
|
|
327
|
+
|
|
266
328
|
let start = 0;
|
|
267
329
|
if (items.length > height) {
|
|
268
330
|
start = Math.min(
|
|
@@ -282,7 +344,10 @@ export class OverlayListDashboard<
|
|
|
282
344
|
const title = isSelected
|
|
283
345
|
? theme.fg("accent", parts.title)
|
|
284
346
|
: theme.fg("text", parts.title);
|
|
285
|
-
const
|
|
347
|
+
const picked = this.spec.selectable
|
|
348
|
+
? this.multi.has(item.id) ? theme.fg("accent", "▸") : " "
|
|
349
|
+
: "";
|
|
350
|
+
const left = `${picked} ${marker} ${parts.glyph} ${title} ${parts.idLabel}`;
|
|
286
351
|
const right = `${parts.rightParts.join(dot)} `;
|
|
287
352
|
out.push(splitRow(left, right, width));
|
|
288
353
|
}
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
decideAsyncLlmDispatch,
|
|
42
42
|
supportsAsyncLlm,
|
|
43
43
|
} from "./async-capability.js";
|
|
44
|
-
import { autoMarkInProgress, autoCompleteTask } from "./async-agents.js";
|
|
44
|
+
import { autoMarkInProgress, autoCompleteTask, type AsyncKillOrigin } from "./async-agents.js";
|
|
45
45
|
import { getSetting } from "./project-settings.js";
|
|
46
46
|
import { checkSyncLimit } from "./sync-limit.js";
|
|
47
47
|
import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
|
|
@@ -596,7 +596,7 @@ function validateTaskId(taskId: string, _cwd: string, _sessionId?: string): stri
|
|
|
596
596
|
export function registerSubagentTool(
|
|
597
597
|
pi: ExtensionAPI,
|
|
598
598
|
spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string },
|
|
599
|
-
killAsyncAgent: (target: string) => { killed: string[]; errors: string[] },
|
|
599
|
+
killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] },
|
|
600
600
|
): void {
|
|
601
601
|
// Only the orchestrator (top-level pi) can spawn subagents.
|
|
602
602
|
// Child processes set PI_SUBAGENT_CHILD=1 to prevent infinite recursion.
|
|
@@ -604,11 +604,13 @@ export function registerSubagentTool(
|
|
|
604
604
|
|
|
605
605
|
// ── Extracted execute helpers (closure over pi, spawnAsyncAgent, killAsyncAgent) ──
|
|
606
606
|
|
|
607
|
+
// asyncKill is an LLM-issued decision, not a user action.
|
|
607
608
|
function executeAsyncKill(
|
|
608
609
|
params: { asyncKill: string },
|
|
609
610
|
mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
|
|
610
611
|
) {
|
|
611
|
-
const { killed, errors } = killAsyncAgent(params.asyncKill);
|
|
612
|
+
const { killed, errors } = killAsyncAgent(params.asyncKill, "orchestrator");
|
|
613
|
+
log.info("subagent_async_kill", { target: params.asyncKill, origin: "orchestrator", killed: killed.length });
|
|
612
614
|
const lines: string[] = [];
|
|
613
615
|
if (killed.length > 0) lines.push(`Killed: ${killed.join(", ")}`);
|
|
614
616
|
if (errors.length > 0) lines.push(errors.join("\n"));
|