@llblab/pi-actors 0.20.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKLOG.md +34 -80
- package/CHANGELOG.md +29 -0
- package/README.md +7 -1
- package/dist/index.js +11 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +33 -1
- package/dist/lib/async-runs.d.ts +35 -1
- package/dist/lib/async-runs.js +318 -36
- package/dist/lib/command-templates.js +8 -1
- package/dist/lib/observability.d.ts +15 -0
- package/dist/lib/observability.js +103 -18
- package/dist/lib/recipe-discovery.js +13 -5
- package/dist/lib/recipe-references.js +137 -11
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +18 -9
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +8 -3
- package/docs/async-runs.md +7 -5
- package/docs/recipe-library.md +25 -8
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +46 -1
- package/lib/async-runs.ts +433 -49
- package/lib/command-templates.ts +8 -1
- package/lib/observability.ts +133 -20
- package/lib/recipe-discovery.ts +21 -6
- package/lib/recipe-references.ts +141 -17
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +36 -13
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +276 -135
- package/scripts/locker.mjs +87 -28
- package/scripts/music-player.mjs +401 -94
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
package/dist/lib/async-runs.js
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* Zones: async runtime, lifecycle, state files
|
|
4
4
|
* Owns detached run state, observation, log tailing, listing, and cancellation safety
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
8
|
import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
|
|
9
|
+
import { createConnection } from "node:net";
|
|
8
10
|
import { platform } from "node:os";
|
|
9
11
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
10
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -13,7 +15,9 @@ import { writeJsonAtomic } from "./file-state.js";
|
|
|
13
15
|
import * as Paths from "./paths.js";
|
|
14
16
|
import * as RecipeReferences from "./recipe-references.js";
|
|
15
17
|
import * as RecipeUsage from "./recipe-usage.js";
|
|
18
|
+
import { notifyRuntimeWake } from "./runtime-notifier.js";
|
|
16
19
|
const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
20
|
+
const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
|
|
17
21
|
const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
|
|
18
22
|
const DEFAULT_RECIPE_ROOT = Paths.getRecipeRoot();
|
|
19
23
|
function packageRoot() {
|
|
@@ -89,7 +93,8 @@ function assertNoActiveRunState(stateDir) {
|
|
|
89
93
|
throw new Error(`Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`);
|
|
90
94
|
}
|
|
91
95
|
function resolveRecipeFile(file) {
|
|
92
|
-
return RecipeReferences.
|
|
96
|
+
return (RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
|
|
97
|
+
RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT));
|
|
93
98
|
}
|
|
94
99
|
function isMutableUsageRecipeFile(file) {
|
|
95
100
|
const userRoot = resolve(DEFAULT_RECIPE_ROOT);
|
|
@@ -300,6 +305,7 @@ export function startRun(params, cwd) {
|
|
|
300
305
|
template: resolved.template,
|
|
301
306
|
values,
|
|
302
307
|
...(artifacts ? { artifacts } : {}),
|
|
308
|
+
...(startParams.control ? { control: startParams.control } : {}),
|
|
303
309
|
...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
|
|
304
310
|
...(recipeContextRecords && recipeContextRecords.length > 0
|
|
305
311
|
? { recipe_context_records: recipeContextRecords }
|
|
@@ -476,6 +482,145 @@ export function readRunEvents(runOrDir, lines = 40) {
|
|
|
476
482
|
.map((line, index) => parseRunOutboxEventLine(line, run, stateDir, index))
|
|
477
483
|
.filter((event) => Boolean(event));
|
|
478
484
|
}
|
|
485
|
+
function runInboxFile(stateDir) {
|
|
486
|
+
return join(stateDir, "inbox.jsonl");
|
|
487
|
+
}
|
|
488
|
+
function sleepSync(ms) {
|
|
489
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
490
|
+
}
|
|
491
|
+
function acquireRunInboxLock(stateDir) {
|
|
492
|
+
const lockDir = join(stateDir, ".inbox.lock");
|
|
493
|
+
const started = Date.now();
|
|
494
|
+
while (true) {
|
|
495
|
+
try {
|
|
496
|
+
mkdirSync(lockDir, { recursive: false });
|
|
497
|
+
writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf8");
|
|
498
|
+
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
try {
|
|
502
|
+
const stat = statSync(lockDir);
|
|
503
|
+
if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
|
|
504
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
|
|
512
|
+
throw new Error("Run inbox lock timed out.", { cause: error });
|
|
513
|
+
}
|
|
514
|
+
sleepSync(10);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function parseRunInboxLine(line) {
|
|
519
|
+
try {
|
|
520
|
+
return JSON.parse(line);
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function readRunInboxMessagesFromStateDir(stateDir) {
|
|
527
|
+
const file = runInboxFile(stateDir);
|
|
528
|
+
if (!existsSync(file))
|
|
529
|
+
return [];
|
|
530
|
+
return readFileSync(file, "utf8")
|
|
531
|
+
.split("\n")
|
|
532
|
+
.filter((line) => line.trim())
|
|
533
|
+
.map(parseRunInboxLine)
|
|
534
|
+
.filter((message) => Boolean(message));
|
|
535
|
+
}
|
|
536
|
+
function writeRunInboxMessages(stateDir, messages) {
|
|
537
|
+
writeFileSync(runInboxFile(stateDir), messages.length
|
|
538
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
539
|
+
: "", "utf8");
|
|
540
|
+
}
|
|
541
|
+
export function readRunInboxMessages(runOrDir, lines = 40) {
|
|
542
|
+
const status = getRunStatus(runOrDir);
|
|
543
|
+
const stateDir = String(status.state_dir);
|
|
544
|
+
return tailLines(runInboxFile(stateDir), lines)
|
|
545
|
+
.map(parseRunInboxLine)
|
|
546
|
+
.filter((message) => Boolean(message));
|
|
547
|
+
}
|
|
548
|
+
export function updateRunInboxMessageStatus(runOrDir, id, nextStatus, metadata = {}) {
|
|
549
|
+
const status = getRunStatus(runOrDir);
|
|
550
|
+
const stateDir = String(status.state_dir);
|
|
551
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
552
|
+
try {
|
|
553
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
554
|
+
const timestampKey = `${nextStatus}_at`;
|
|
555
|
+
let changed = false;
|
|
556
|
+
const updated = messages.map((message) => {
|
|
557
|
+
if (message.id !== id)
|
|
558
|
+
return message;
|
|
559
|
+
changed = true;
|
|
560
|
+
return {
|
|
561
|
+
...message,
|
|
562
|
+
...metadata,
|
|
563
|
+
[timestampKey]: new Date().toISOString(),
|
|
564
|
+
status: nextStatus,
|
|
565
|
+
};
|
|
566
|
+
});
|
|
567
|
+
if (changed)
|
|
568
|
+
writeRunInboxMessages(stateDir, updated);
|
|
569
|
+
return changed;
|
|
570
|
+
}
|
|
571
|
+
finally {
|
|
572
|
+
releaseLock();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
export function claimRunInboxMessage(runOrDir, owner = "runtime", statuses = ["queued"]) {
|
|
576
|
+
const status = getRunStatus(runOrDir);
|
|
577
|
+
const stateDir = String(status.state_dir);
|
|
578
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
579
|
+
try {
|
|
580
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
581
|
+
const index = messages.findIndex((message) => statuses.includes(String(message.status ?? "queued")));
|
|
582
|
+
if (index < 0)
|
|
583
|
+
return undefined;
|
|
584
|
+
const claimed = {
|
|
585
|
+
...messages[index],
|
|
586
|
+
claimed_at: new Date().toISOString(),
|
|
587
|
+
claimed_by: owner,
|
|
588
|
+
id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
|
|
589
|
+
status: "claimed",
|
|
590
|
+
};
|
|
591
|
+
messages[index] = claimed;
|
|
592
|
+
writeRunInboxMessages(stateDir, messages);
|
|
593
|
+
return claimed;
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
releaseLock();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
export async function processRunInboxMessages(runOrDir, handler, options = {}) {
|
|
600
|
+
const result = { claimed: 0, failed: 0, handled: 0 };
|
|
601
|
+
const limit = Math.max(1, Number(options.limit ?? 1));
|
|
602
|
+
const owner = options.owner ?? "runtime";
|
|
603
|
+
for (let index = 0; index < limit; index += 1) {
|
|
604
|
+
const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
|
|
605
|
+
if (!message?.id)
|
|
606
|
+
break;
|
|
607
|
+
result.claimed += 1;
|
|
608
|
+
try {
|
|
609
|
+
await handler(message);
|
|
610
|
+
if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
|
|
611
|
+
result.handled += 1;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
|
|
616
|
+
error: error instanceof Error ? error.message : String(error),
|
|
617
|
+
})) {
|
|
618
|
+
result.failed += 1;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
479
624
|
export function appendRunOutboxEvent(runOrDir, event) {
|
|
480
625
|
const status = getRunStatus(runOrDir);
|
|
481
626
|
const stateDir = String(status.state_dir);
|
|
@@ -507,10 +652,115 @@ export function appendRunOutboxEvent(runOrDir, event) {
|
|
|
507
652
|
state_dir: stateDir,
|
|
508
653
|
};
|
|
509
654
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
655
|
+
function getRunControlEndpoint(status, stateDir) {
|
|
656
|
+
const control = status.control;
|
|
657
|
+
if (control && typeof control === "object" && !Array.isArray(control)) {
|
|
658
|
+
const record = control;
|
|
659
|
+
if ((record.type === "fifo" ||
|
|
660
|
+
record.type === "mailbox" ||
|
|
661
|
+
record.type === "named-pipe") &&
|
|
662
|
+
typeof record.path === "string" &&
|
|
663
|
+
record.path.trim()) {
|
|
664
|
+
return { path: record.path, type: record.type };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { path: join(stateDir, "control.fifo"), type: "fifo" };
|
|
668
|
+
}
|
|
669
|
+
function appendRunInboxMessage(stateDir, message) {
|
|
670
|
+
const id = randomUUID();
|
|
671
|
+
const ts = new Date().toISOString();
|
|
672
|
+
let record;
|
|
673
|
+
try {
|
|
674
|
+
const parsed = JSON.parse(message);
|
|
675
|
+
record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
676
|
+
? parsed
|
|
677
|
+
: { body: parsed, type: "run.message" };
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
record = { body: message, type: "run.message" };
|
|
681
|
+
}
|
|
682
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
683
|
+
try {
|
|
684
|
+
writeFileSync(runInboxFile(stateDir), `${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`, { flag: "a" });
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
releaseLock();
|
|
688
|
+
}
|
|
689
|
+
return id;
|
|
690
|
+
}
|
|
691
|
+
function writeRunMessageReceipt(stateDir, message, bytes, inboxId) {
|
|
692
|
+
const trimmedMessage = message.trim().toLowerCase();
|
|
693
|
+
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
|
|
694
|
+
const ts = new Date().toISOString();
|
|
695
|
+
writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`, { flag: "a" });
|
|
696
|
+
updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
|
|
697
|
+
if (terminalMessage) {
|
|
698
|
+
markTerminalHandled(stateDir, {
|
|
699
|
+
event: "run.message",
|
|
700
|
+
message: trimmedMessage,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function sendRunMessageToFifo(endpoint, payload) {
|
|
705
|
+
if (!existsSync(endpoint.path))
|
|
706
|
+
throw new Error(`Run control FIFO not found: ${endpoint.path}`);
|
|
707
|
+
const stat = statSync(endpoint.path);
|
|
708
|
+
if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
|
|
709
|
+
throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
|
|
710
|
+
}
|
|
711
|
+
let fd;
|
|
712
|
+
try {
|
|
713
|
+
fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
714
|
+
return writeSync(fd, payload);
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
if (fd !== undefined)
|
|
718
|
+
closeSync(fd);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function notifyRunMessageWake(stateDir, run, bytes, endpoint, inboxId) {
|
|
722
|
+
try {
|
|
723
|
+
const event = notifyRuntimeWake(stateDir, {
|
|
724
|
+
actor: `run:${run}`,
|
|
725
|
+
metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
|
|
726
|
+
reason: "run.message",
|
|
727
|
+
});
|
|
728
|
+
return { wake: "wake.jsonl", wake_id: event.id };
|
|
513
729
|
}
|
|
730
|
+
catch {
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function sendRunMessageToNamedPipe(endpoint, payload, send) {
|
|
735
|
+
if (send)
|
|
736
|
+
return send(endpoint.path, payload);
|
|
737
|
+
return new Promise((resolve, reject) => {
|
|
738
|
+
const socket = createConnection(endpoint.path);
|
|
739
|
+
let settled = false;
|
|
740
|
+
const timeout = setTimeout(() => {
|
|
741
|
+
if (settled)
|
|
742
|
+
return;
|
|
743
|
+
settled = true;
|
|
744
|
+
socket.destroy();
|
|
745
|
+
reject(new Error("named pipe connection timed out"));
|
|
746
|
+
}, 5000);
|
|
747
|
+
const finish = (error) => {
|
|
748
|
+
if (settled)
|
|
749
|
+
return;
|
|
750
|
+
settled = true;
|
|
751
|
+
clearTimeout(timeout);
|
|
752
|
+
if (error)
|
|
753
|
+
reject(error);
|
|
754
|
+
else
|
|
755
|
+
resolve(Buffer.byteLength(payload));
|
|
756
|
+
};
|
|
757
|
+
socket.on("error", finish);
|
|
758
|
+
socket.on("connect", () => {
|
|
759
|
+
socket.end(payload, () => finish());
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
export async function sendRunMessage(runOrDir, message, options = {}) {
|
|
514
764
|
const status = getRunStatus(runOrDir);
|
|
515
765
|
const stateDir = String(status.state_dir);
|
|
516
766
|
const run = String(status.run ?? runOrDir);
|
|
@@ -521,52 +771,84 @@ export function sendRunMessage(runOrDir, message) {
|
|
|
521
771
|
throw new Error(`Run pid is not alive: ${run}`);
|
|
522
772
|
if (!pidMatchesRun(pid, String(status.cwd), stateDir))
|
|
523
773
|
throw new Error(`Run pid owner mismatch: ${run}`);
|
|
524
|
-
const
|
|
525
|
-
if (!existsSync(controlPath))
|
|
526
|
-
throw new Error(`Run control FIFO not found: ${controlPath}`);
|
|
527
|
-
const stat = statSync(controlPath);
|
|
528
|
-
if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
|
|
529
|
-
throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
|
|
530
|
-
}
|
|
774
|
+
const endpoint = getRunControlEndpoint(status, stateDir);
|
|
531
775
|
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
532
|
-
|
|
776
|
+
const payloadBytes = Buffer.byteLength(payload);
|
|
777
|
+
const inboxId = appendRunInboxMessage(stateDir, message);
|
|
778
|
+
const wake = notifyRunMessageWake(stateDir, run, payloadBytes, endpoint, inboxId);
|
|
779
|
+
const runtimePlatform = options.platform ?? process.platform;
|
|
780
|
+
if (endpoint.type === "mailbox") {
|
|
781
|
+
return {
|
|
782
|
+
...(wake ?? {}),
|
|
783
|
+
bytes: payloadBytes,
|
|
784
|
+
control: "inbox.jsonl",
|
|
785
|
+
control_path: endpoint.path,
|
|
786
|
+
control_type: endpoint.type,
|
|
787
|
+
queued: true,
|
|
788
|
+
run,
|
|
789
|
+
sent: true,
|
|
790
|
+
state_dir: stateDir,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
533
793
|
try {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
message: trimmedMessage,
|
|
551
|
-
});
|
|
794
|
+
if (endpoint.type === "fifo") {
|
|
795
|
+
if (runtimePlatform === "win32") {
|
|
796
|
+
throw new Error("run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.");
|
|
797
|
+
}
|
|
798
|
+
const bytes = sendRunMessageToFifo(endpoint, payload);
|
|
799
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
800
|
+
return {
|
|
801
|
+
...(wake ?? {}),
|
|
802
|
+
bytes,
|
|
803
|
+
control: "control.fifo",
|
|
804
|
+
control_path: endpoint.path,
|
|
805
|
+
control_type: endpoint.type,
|
|
806
|
+
run,
|
|
807
|
+
sent: true,
|
|
808
|
+
state_dir: stateDir,
|
|
809
|
+
};
|
|
552
810
|
}
|
|
811
|
+
const bytes = await sendRunMessageToNamedPipe(endpoint, payload, options.namedPipeSend);
|
|
812
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
553
813
|
return {
|
|
814
|
+
...(wake ?? {}),
|
|
554
815
|
bytes,
|
|
555
|
-
control:
|
|
816
|
+
control: endpoint.path,
|
|
817
|
+
control_path: endpoint.path,
|
|
818
|
+
control_type: endpoint.type,
|
|
556
819
|
run,
|
|
557
820
|
sent: true,
|
|
558
821
|
state_dir: stateDir,
|
|
559
822
|
};
|
|
560
823
|
}
|
|
561
824
|
catch (error) {
|
|
562
|
-
throw new Error(`Run control
|
|
825
|
+
throw new Error(`Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
563
826
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
827
|
+
}
|
|
828
|
+
export function getRunProcessSignalPlan(pid, signal, runtimePlatform = process.platform) {
|
|
829
|
+
if (runtimePlatform === "win32") {
|
|
830
|
+
return {
|
|
831
|
+
args: [
|
|
832
|
+
"/PID",
|
|
833
|
+
String(pid),
|
|
834
|
+
"/T",
|
|
835
|
+
...(signal === "SIGKILL" ? ["/F"] : []),
|
|
836
|
+
],
|
|
837
|
+
command: "taskkill",
|
|
838
|
+
signalTarget: "processTree",
|
|
839
|
+
};
|
|
567
840
|
}
|
|
841
|
+
return { signalTarget: "processGroup" };
|
|
568
842
|
}
|
|
569
843
|
function signalOwnedRunProcess(pid, signal) {
|
|
844
|
+
const plan = getRunProcessSignalPlan(pid, signal);
|
|
845
|
+
if (plan.command && plan.args) {
|
|
846
|
+
const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
|
|
847
|
+
if (result.status !== 0) {
|
|
848
|
+
throw new Error(result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`);
|
|
849
|
+
}
|
|
850
|
+
return plan;
|
|
851
|
+
}
|
|
570
852
|
try {
|
|
571
853
|
process.kill(-pid, signal);
|
|
572
854
|
return { signalTarget: "processGroup" };
|
|
@@ -74,8 +74,15 @@ function getExecutableName(command) {
|
|
|
74
74
|
return "";
|
|
75
75
|
return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
|
76
76
|
}
|
|
77
|
+
function matchesFlag(arg, flag) {
|
|
78
|
+
if (arg === flag)
|
|
79
|
+
return true;
|
|
80
|
+
if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
|
|
81
|
+
return arg.slice(1).includes(flag.slice(1));
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
77
84
|
function hasAnyFlag(args, flags) {
|
|
78
|
-
return args.some((arg) => flags.
|
|
85
|
+
return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
|
|
79
86
|
}
|
|
80
87
|
function hasRiskyPathArg(args) {
|
|
81
88
|
return args.some((arg) => arg === "/" ||
|
|
@@ -37,9 +37,23 @@ export interface RunSummary {
|
|
|
37
37
|
}
|
|
38
38
|
export interface RunRetirementCandidate {
|
|
39
39
|
activeSubagents: number;
|
|
40
|
+
childRuns: number;
|
|
40
41
|
descendantSubagents: number;
|
|
41
42
|
run: string;
|
|
42
43
|
stateDir: string;
|
|
44
|
+
terminalChildRuns: number;
|
|
45
|
+
}
|
|
46
|
+
export interface RunRetirementExecution {
|
|
47
|
+
action: "stop" | "cancel" | "skip" | "failed";
|
|
48
|
+
error?: string;
|
|
49
|
+
run: string;
|
|
50
|
+
stateDir: string;
|
|
51
|
+
}
|
|
52
|
+
export interface RunRetirementExecutorOptions {
|
|
53
|
+
attempted?: Set<string>;
|
|
54
|
+
cancelRun: (candidate: RunRetirementCandidate) => Record<string, unknown>;
|
|
55
|
+
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
56
|
+
sendStop: (candidate: RunRetirementCandidate) => Promise<unknown>;
|
|
43
57
|
}
|
|
44
58
|
export interface RunTransition {
|
|
45
59
|
from: RunObservedStatus;
|
|
@@ -72,6 +86,7 @@ export declare function countRunningSubagents(stateRoot?: string, ownerId?: stri
|
|
|
72
86
|
export declare function renderSubagentStatus(count: number, frame?: number): string | undefined;
|
|
73
87
|
export declare function renderRunStatus(summary: RunSummary, frame?: number): string | undefined;
|
|
74
88
|
export declare function findRunRetirementCandidates(summary: RunSummary): RunRetirementCandidate[];
|
|
89
|
+
export declare function executeRunRetirements(summary: RunSummary, options: RunRetirementExecutorOptions): Promise<RunRetirementExecution[]>;
|
|
75
90
|
export declare function detectRunTransitions(previous: Map<string, RunObservedStatus>, summary: RunSummary): RunTransition[];
|
|
76
91
|
export declare function pruneRunObservationState(previousStatuses: Map<string, RunObservedStatus>, previousLineCounts: Map<string, number>, summary: RunSummary, terminalRuns?: Iterable<string>): void;
|
|
77
92
|
export declare function detectRunOutboxEvents(previousLineCounts: Map<string, number>, summary: RunSummary): RunOutboxEvent[];
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns ambient summaries, terminal events, and run outbox delivery for detached command-template runs
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
8
|
import * as AsyncRuns from "./async-runs.js";
|
|
9
9
|
import * as Paths from "./paths.js";
|
|
10
10
|
const TERMINAL = new Set([
|
|
@@ -15,6 +15,7 @@ const TERMINAL = new Set([
|
|
|
15
15
|
"killed",
|
|
16
16
|
]);
|
|
17
17
|
const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
|
|
18
|
+
const RUN_STATE_DISCOVERY_MAX_DEPTH = 8;
|
|
18
19
|
const procDescendantScanCache = new Map();
|
|
19
20
|
function toNumber(value) {
|
|
20
21
|
const number = Number(value);
|
|
@@ -34,6 +35,29 @@ function getUpdatedAt(status) {
|
|
|
34
35
|
? status.createdAt
|
|
35
36
|
: undefined;
|
|
36
37
|
}
|
|
38
|
+
function listRunStateDirs(stateRoot, depth = 0, seen = new Set()) {
|
|
39
|
+
if (!existsSync(stateRoot) || seen.has(stateRoot))
|
|
40
|
+
return [];
|
|
41
|
+
seen.add(stateRoot);
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = readdirSync(stateRoot, { withFileTypes: true });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const result = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
const child = join(stateRoot, entry.name);
|
|
54
|
+
if (existsSync(join(child, "run.json")))
|
|
55
|
+
result.push(child);
|
|
56
|
+
if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
|
|
57
|
+
result.push(...listRunStateDirs(child, depth + 1, seen));
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
37
61
|
function observeRun(stateDir) {
|
|
38
62
|
try {
|
|
39
63
|
const status = AsyncRuns.getRunStatus(stateDir);
|
|
@@ -90,9 +114,8 @@ export function summarizeRuns(stateRoot = Paths.getRunStateRoot(), ownerId) {
|
|
|
90
114
|
total: 0,
|
|
91
115
|
};
|
|
92
116
|
}
|
|
93
|
-
const runs =
|
|
94
|
-
.
|
|
95
|
-
.map((entry) => observeRun(join(stateRoot, entry.name)))
|
|
117
|
+
const runs = listRunStateDirs(stateRoot)
|
|
118
|
+
.map((stateDir) => observeRun(stateDir))
|
|
96
119
|
.filter((run) => Boolean(run))
|
|
97
120
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
98
121
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
@@ -148,7 +171,7 @@ function getRunningRunPidMap(stateRoot, ownerId) {
|
|
|
148
171
|
for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
|
|
149
172
|
if (run.status !== "running")
|
|
150
173
|
continue;
|
|
151
|
-
const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
|
|
174
|
+
const status = AsyncRuns.getRunStatus(run.stateDir ?? join(stateRoot, run.run));
|
|
152
175
|
const pid = Number(status.pid || 0);
|
|
153
176
|
if (pid > 0)
|
|
154
177
|
pids.set(String(pid), run.run);
|
|
@@ -167,9 +190,8 @@ function summarizeRunsWithoutSubagents(stateRoot, ownerId) {
|
|
|
167
190
|
runs: [],
|
|
168
191
|
total: 0,
|
|
169
192
|
};
|
|
170
|
-
const runs =
|
|
171
|
-
.
|
|
172
|
-
.map((entry) => observeRun(join(stateRoot, entry.name)))
|
|
193
|
+
const runs = listRunStateDirs(stateRoot)
|
|
194
|
+
.map((stateDir) => observeRun(stateDir))
|
|
173
195
|
.filter((run) => Boolean(run))
|
|
174
196
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
175
197
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
@@ -260,23 +282,86 @@ export function renderSubagentStatus(count, frame = 0) {
|
|
|
260
282
|
export function renderRunStatus(summary, frame = 0) {
|
|
261
283
|
return renderSubagentStatus(summary.runningSubagents, frame);
|
|
262
284
|
}
|
|
285
|
+
function isNestedStateDir(parent, child) {
|
|
286
|
+
const path = relative(parent, child);
|
|
287
|
+
return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
|
|
288
|
+
}
|
|
263
289
|
export function findRunRetirementCandidates(summary) {
|
|
264
290
|
return summary.runs
|
|
265
|
-
.
|
|
291
|
+
.map((run) => {
|
|
266
292
|
const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
|
|
267
293
|
const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
294
|
+
const childRuns = run.stateDir
|
|
295
|
+
? summary.runs.filter((child) => child.stateDir !== undefined &&
|
|
296
|
+
child.stateDir !== run.stateDir &&
|
|
297
|
+
isNestedStateDir(run.stateDir, child.stateDir))
|
|
298
|
+
: [];
|
|
299
|
+
const runningChildRuns = childRuns.filter((child) => child.status === "running").length;
|
|
300
|
+
return {
|
|
301
|
+
activeSubagents,
|
|
302
|
+
childRuns: childRuns.length,
|
|
303
|
+
descendantSubagents,
|
|
304
|
+
ready: run.status === "running" &&
|
|
305
|
+
run.retireWhen === "children_terminal" &&
|
|
306
|
+
run.stateDir !== undefined &&
|
|
307
|
+
!run.terminalHandled &&
|
|
308
|
+
activeSubagents + descendantSubagents + runningChildRuns <= 0,
|
|
309
|
+
run,
|
|
310
|
+
terminalChildRuns: childRuns.filter((child) => TERMINAL.has(child.status)).length,
|
|
311
|
+
};
|
|
272
312
|
})
|
|
273
|
-
.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
313
|
+
.filter((item) => item.ready)
|
|
314
|
+
.map((item) => ({
|
|
315
|
+
activeSubagents: item.activeSubagents,
|
|
316
|
+
childRuns: item.childRuns,
|
|
317
|
+
descendantSubagents: item.descendantSubagents,
|
|
318
|
+
run: item.run.run,
|
|
319
|
+
stateDir: item.run.stateDir,
|
|
320
|
+
terminalChildRuns: item.terminalChildRuns,
|
|
278
321
|
}));
|
|
279
322
|
}
|
|
323
|
+
export async function executeRunRetirements(summary, options) {
|
|
324
|
+
const results = [];
|
|
325
|
+
for (const candidate of findRunRetirementCandidates(summary)) {
|
|
326
|
+
if (options.attempted?.has(candidate.stateDir)) {
|
|
327
|
+
results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
options.attempted?.add(candidate.stateDir);
|
|
331
|
+
try {
|
|
332
|
+
await options.sendStop(candidate);
|
|
333
|
+
options.notify?.(`Retiring actor ${candidate.run} after child runs reached terminal state`, "info");
|
|
334
|
+
results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
try {
|
|
339
|
+
const cancelResult = options.cancelRun(candidate);
|
|
340
|
+
const cancelled = Boolean(cancelResult.cancelled);
|
|
341
|
+
options.notify?.(cancelled
|
|
342
|
+
? `Retiring actor ${candidate.run} by cancellation after graceful stop failed`
|
|
343
|
+
: `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`, cancelled ? "warning" : "error");
|
|
344
|
+
results.push({
|
|
345
|
+
action: cancelled ? "cancel" : "skip",
|
|
346
|
+
...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
|
|
347
|
+
run: candidate.run,
|
|
348
|
+
stateDir: candidate.stateDir,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (cancelError) {
|
|
352
|
+
const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
|
|
353
|
+
options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
|
|
354
|
+
results.push({
|
|
355
|
+
action: "failed",
|
|
356
|
+
error: message,
|
|
357
|
+
run: candidate.run,
|
|
358
|
+
stateDir: candidate.stateDir,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return results;
|
|
364
|
+
}
|
|
280
365
|
export function detectRunTransitions(previous, summary) {
|
|
281
366
|
const transitions = [];
|
|
282
367
|
for (const run of summary.runs) {
|