@llblab/pi-actors 0.20.1 → 0.21.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 +0 -52
- package/CHANGELOG.md +25 -0
- package/README.md +7 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +385 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +28 -11
- package/dist/lib/async-runs.d.ts +17 -1
- package/dist/lib/async-runs.js +127 -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/tools.js +5 -5
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +4 -1
- package/docs/async-runs.md +3 -3
- package/docs/recipe-library.md +14 -4
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.js +19 -0
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +35 -9
- package/lib/async-runs.ts +186 -48
- 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/tools.ts +10 -8
- package/package.json +5 -3
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +60 -32
- package/scripts/locker.mjs +61 -23
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
package/lib/async-runs.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns detached run state, observation, log tailing, listing, and cancellation safety
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import {
|
|
9
9
|
closeSync,
|
|
10
10
|
constants,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
writeFileSync,
|
|
20
20
|
writeSync,
|
|
21
21
|
} from "node:fs";
|
|
22
|
+
import { createConnection } from "node:net";
|
|
22
23
|
import { platform } from "node:os";
|
|
23
24
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -37,8 +38,14 @@ const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
|
37
38
|
|
|
38
39
|
export type AsyncRunLaunchSource = "spawn" | "tool";
|
|
39
40
|
|
|
41
|
+
export interface AsyncRunControlEndpoint {
|
|
42
|
+
path: string;
|
|
43
|
+
type: "fifo" | "named-pipe";
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
export interface AsyncRunStartParams {
|
|
41
47
|
async?: boolean;
|
|
48
|
+
control?: AsyncRunControlEndpoint;
|
|
42
49
|
file?: string;
|
|
43
50
|
launch_source?: AsyncRunLaunchSource;
|
|
44
51
|
name?: string;
|
|
@@ -112,6 +119,7 @@ export interface AsyncRunMeta {
|
|
|
112
119
|
template: CommandTemplateValue;
|
|
113
120
|
values: Record<string, unknown>;
|
|
114
121
|
artifacts?: Record<string, string>;
|
|
122
|
+
control?: AsyncRunControlEndpoint;
|
|
115
123
|
mailbox?: RecipeReferences.TemplateRecipeMailbox;
|
|
116
124
|
recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
|
|
117
125
|
retire_when?: "children_terminal";
|
|
@@ -207,7 +215,10 @@ function assertNoActiveRunState(stateDir: string): void {
|
|
|
207
215
|
}
|
|
208
216
|
|
|
209
217
|
function resolveRecipeFile(file: string): string {
|
|
210
|
-
return
|
|
218
|
+
return (
|
|
219
|
+
RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
|
|
220
|
+
RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
|
|
221
|
+
);
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
function isMutableUsageRecipeFile(file: string): boolean {
|
|
@@ -446,6 +457,7 @@ export function startRun(
|
|
|
446
457
|
template: resolved.template,
|
|
447
458
|
values,
|
|
448
459
|
...(artifacts ? { artifacts } : {}),
|
|
460
|
+
...(startParams.control ? { control: startParams.control } : {}),
|
|
449
461
|
...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
|
|
450
462
|
...(recipeContextRecords && recipeContextRecords.length > 0
|
|
451
463
|
? { recipe_context_records: recipeContextRecords }
|
|
@@ -700,15 +712,115 @@ export function appendRunOutboxEvent(
|
|
|
700
712
|
};
|
|
701
713
|
}
|
|
702
714
|
|
|
703
|
-
export
|
|
704
|
-
|
|
715
|
+
export interface SendRunMessageOptions {
|
|
716
|
+
namedPipeSend?: (path: string, payload: string) => Promise<number>;
|
|
717
|
+
platform?: NodeJS.Platform;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function getRunControlEndpoint(
|
|
721
|
+
status: Record<string, unknown>,
|
|
722
|
+
stateDir: string,
|
|
723
|
+
): AsyncRunControlEndpoint {
|
|
724
|
+
const control = status.control;
|
|
725
|
+
if (control && typeof control === "object" && !Array.isArray(control)) {
|
|
726
|
+
const record = control as Record<string, unknown>;
|
|
727
|
+
if (
|
|
728
|
+
(record.type === "fifo" || record.type === "named-pipe") &&
|
|
729
|
+
typeof record.path === "string" &&
|
|
730
|
+
record.path.trim()
|
|
731
|
+
) {
|
|
732
|
+
return { path: record.path, type: record.type };
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return { path: join(stateDir, "control.fifo"), type: "fifo" };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function writeRunMessageReceipt(
|
|
739
|
+
stateDir: string,
|
|
705
740
|
message: string,
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
741
|
+
bytes: number,
|
|
742
|
+
): void {
|
|
743
|
+
const trimmedMessage = message.trim().toLowerCase();
|
|
744
|
+
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
|
|
745
|
+
trimmedMessage,
|
|
746
|
+
);
|
|
747
|
+
const ts = new Date().toISOString();
|
|
748
|
+
writeFileSync(
|
|
749
|
+
join(stateDir, "events.jsonl"),
|
|
750
|
+
`${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`,
|
|
751
|
+
{ flag: "a" },
|
|
752
|
+
);
|
|
753
|
+
try {
|
|
754
|
+
const envelope = JSON.parse(message) as Record<string, unknown>;
|
|
755
|
+
writeFileSync(
|
|
756
|
+
join(stateDir, "inbox.jsonl"),
|
|
757
|
+
`${JSON.stringify({ ...envelope, received_at: ts })}\n`,
|
|
758
|
+
{ flag: "a" },
|
|
710
759
|
);
|
|
760
|
+
} catch {
|
|
761
|
+
// Plain control lines are already represented in events.jsonl.
|
|
762
|
+
}
|
|
763
|
+
if (terminalMessage) {
|
|
764
|
+
markTerminalHandled(stateDir, {
|
|
765
|
+
event: "run.message",
|
|
766
|
+
message: trimmedMessage,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function sendRunMessageToFifo(
|
|
772
|
+
endpoint: AsyncRunControlEndpoint,
|
|
773
|
+
payload: string,
|
|
774
|
+
): number {
|
|
775
|
+
if (!existsSync(endpoint.path))
|
|
776
|
+
throw new Error(`Run control FIFO not found: ${endpoint.path}`);
|
|
777
|
+
const stat = statSync(endpoint.path);
|
|
778
|
+
if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
|
|
779
|
+
throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
|
|
711
780
|
}
|
|
781
|
+
let fd: number | undefined;
|
|
782
|
+
try {
|
|
783
|
+
fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
784
|
+
return writeSync(fd, payload);
|
|
785
|
+
} finally {
|
|
786
|
+
if (fd !== undefined) closeSync(fd);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function sendRunMessageToNamedPipe(
|
|
791
|
+
endpoint: AsyncRunControlEndpoint,
|
|
792
|
+
payload: string,
|
|
793
|
+
send?: (path: string, payload: string) => Promise<number>,
|
|
794
|
+
): Promise<number> {
|
|
795
|
+
if (send) return send(endpoint.path, payload);
|
|
796
|
+
return new Promise((resolve, reject) => {
|
|
797
|
+
const socket = createConnection(endpoint.path);
|
|
798
|
+
let settled = false;
|
|
799
|
+
const timeout = setTimeout(() => {
|
|
800
|
+
if (settled) return;
|
|
801
|
+
settled = true;
|
|
802
|
+
socket.destroy();
|
|
803
|
+
reject(new Error("named pipe connection timed out"));
|
|
804
|
+
}, 5000);
|
|
805
|
+
const finish = (error?: Error): void => {
|
|
806
|
+
if (settled) return;
|
|
807
|
+
settled = true;
|
|
808
|
+
clearTimeout(timeout);
|
|
809
|
+
if (error) reject(error);
|
|
810
|
+
else resolve(Buffer.byteLength(payload));
|
|
811
|
+
};
|
|
812
|
+
socket.on("error", finish);
|
|
813
|
+
socket.on("connect", () => {
|
|
814
|
+
socket.end(payload, () => finish());
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export async function sendRunMessage(
|
|
820
|
+
runOrDir: string,
|
|
821
|
+
message: string,
|
|
822
|
+
options: SendRunMessageOptions = {},
|
|
823
|
+
): Promise<Record<string, unknown>> {
|
|
712
824
|
const status = getRunStatus(runOrDir);
|
|
713
825
|
const stateDir = String(status.state_dir);
|
|
714
826
|
const run = String(status.run ?? runOrDir);
|
|
@@ -718,64 +830,90 @@ export function sendRunMessage(
|
|
|
718
830
|
if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
|
|
719
831
|
if (!pidMatchesRun(pid, String(status.cwd), stateDir))
|
|
720
832
|
throw new Error(`Run pid owner mismatch: ${run}`);
|
|
721
|
-
const
|
|
722
|
-
if (!existsSync(controlPath))
|
|
723
|
-
throw new Error(`Run control FIFO not found: ${controlPath}`);
|
|
724
|
-
const stat = statSync(controlPath);
|
|
725
|
-
if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
|
|
726
|
-
throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
|
|
727
|
-
}
|
|
833
|
+
const endpoint = getRunControlEndpoint(status, stateDir);
|
|
728
834
|
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
729
|
-
|
|
835
|
+
const runtimePlatform = options.platform ?? process.platform;
|
|
730
836
|
try {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
{ flag: "a" },
|
|
749
|
-
);
|
|
750
|
-
} catch {
|
|
751
|
-
// Plain control lines are already represented in events.jsonl.
|
|
752
|
-
}
|
|
753
|
-
if (terminalMessage) {
|
|
754
|
-
markTerminalHandled(stateDir, {
|
|
755
|
-
event: "run.message",
|
|
756
|
-
message: trimmedMessage,
|
|
757
|
-
});
|
|
837
|
+
if (endpoint.type === "fifo") {
|
|
838
|
+
if (runtimePlatform === "win32") {
|
|
839
|
+
throw new Error(
|
|
840
|
+
"run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
const bytes = sendRunMessageToFifo(endpoint, payload);
|
|
844
|
+
writeRunMessageReceipt(stateDir, message, bytes);
|
|
845
|
+
return {
|
|
846
|
+
bytes,
|
|
847
|
+
control: "control.fifo",
|
|
848
|
+
control_path: endpoint.path,
|
|
849
|
+
control_type: endpoint.type,
|
|
850
|
+
run,
|
|
851
|
+
sent: true,
|
|
852
|
+
state_dir: stateDir,
|
|
853
|
+
};
|
|
758
854
|
}
|
|
855
|
+
const bytes = await sendRunMessageToNamedPipe(
|
|
856
|
+
endpoint,
|
|
857
|
+
payload,
|
|
858
|
+
options.namedPipeSend,
|
|
859
|
+
);
|
|
860
|
+
writeRunMessageReceipt(stateDir, message, bytes);
|
|
759
861
|
return {
|
|
760
862
|
bytes,
|
|
761
|
-
control:
|
|
863
|
+
control: endpoint.path,
|
|
864
|
+
control_path: endpoint.path,
|
|
865
|
+
control_type: endpoint.type,
|
|
762
866
|
run,
|
|
763
867
|
sent: true,
|
|
764
868
|
state_dir: stateDir,
|
|
765
869
|
};
|
|
766
870
|
} catch (error) {
|
|
767
871
|
throw new Error(
|
|
768
|
-
`Run control
|
|
872
|
+
`Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
769
873
|
);
|
|
770
|
-
} finally {
|
|
771
|
-
if (fd !== undefined) closeSync(fd);
|
|
772
874
|
}
|
|
773
875
|
}
|
|
774
876
|
|
|
877
|
+
export interface RunProcessSignalPlan {
|
|
878
|
+
args?: string[];
|
|
879
|
+
command?: string;
|
|
880
|
+
signalTarget: "processGroup" | "process" | "processTree";
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export function getRunProcessSignalPlan(
|
|
884
|
+
pid: number,
|
|
885
|
+
signal: NodeJS.Signals,
|
|
886
|
+
runtimePlatform: NodeJS.Platform = process.platform,
|
|
887
|
+
): RunProcessSignalPlan {
|
|
888
|
+
if (runtimePlatform === "win32") {
|
|
889
|
+
return {
|
|
890
|
+
args: [
|
|
891
|
+
"/PID",
|
|
892
|
+
String(pid),
|
|
893
|
+
"/T",
|
|
894
|
+
...(signal === "SIGKILL" ? ["/F"] : []),
|
|
895
|
+
],
|
|
896
|
+
command: "taskkill",
|
|
897
|
+
signalTarget: "processTree",
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
return { signalTarget: "processGroup" };
|
|
901
|
+
}
|
|
902
|
+
|
|
775
903
|
function signalOwnedRunProcess(
|
|
776
904
|
pid: number,
|
|
777
905
|
signal: NodeJS.Signals,
|
|
778
|
-
):
|
|
906
|
+
): RunProcessSignalPlan {
|
|
907
|
+
const plan = getRunProcessSignalPlan(pid, signal);
|
|
908
|
+
if (plan.command && plan.args) {
|
|
909
|
+
const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
|
|
910
|
+
if (result.status !== 0) {
|
|
911
|
+
throw new Error(
|
|
912
|
+
result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
return plan;
|
|
916
|
+
}
|
|
779
917
|
try {
|
|
780
918
|
process.kill(-pid, signal);
|
|
781
919
|
return { signalTarget: "processGroup" };
|
package/lib/command-templates.ts
CHANGED
|
@@ -157,8 +157,15 @@ function getExecutableName(command: string | undefined): string {
|
|
|
157
157
|
return command.split(/[\\/]/).pop()?.toLowerCase() ?? "";
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
function matchesFlag(arg: string, flag: string): boolean {
|
|
161
|
+
if (arg === flag) return true;
|
|
162
|
+
if (/^-[A-Za-z]$/.test(flag) && /^-[A-Za-z]+$/.test(arg))
|
|
163
|
+
return arg.slice(1).includes(flag.slice(1));
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
160
167
|
function hasAnyFlag(args: string[], flags: string[]): boolean {
|
|
161
|
-
return args.some((arg) => flags.
|
|
168
|
+
return args.some((arg) => flags.some((flag) => matchesFlag(arg, flag)));
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
function hasRiskyPathArg(args: string[]): boolean {
|
package/lib/observability.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
9
|
|
|
10
10
|
import * as AsyncRuns from "./async-runs.ts";
|
|
11
11
|
import * as Paths from "./paths.ts";
|
|
@@ -52,9 +52,25 @@ export interface RunSummary {
|
|
|
52
52
|
|
|
53
53
|
export interface RunRetirementCandidate {
|
|
54
54
|
activeSubagents: number;
|
|
55
|
+
childRuns: number;
|
|
55
56
|
descendantSubagents: number;
|
|
56
57
|
run: string;
|
|
57
58
|
stateDir: string;
|
|
59
|
+
terminalChildRuns: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RunRetirementExecution {
|
|
63
|
+
action: "stop" | "cancel" | "skip" | "failed";
|
|
64
|
+
error?: string;
|
|
65
|
+
run: string;
|
|
66
|
+
stateDir: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RunRetirementExecutorOptions {
|
|
70
|
+
attempted?: Set<string>;
|
|
71
|
+
cancelRun: (candidate: RunRetirementCandidate) => Record<string, unknown>;
|
|
72
|
+
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
73
|
+
sendStop: (candidate: RunRetirementCandidate) => Promise<unknown>;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
export interface RunTransition {
|
|
@@ -93,6 +109,7 @@ const TERMINAL = new Set<RunObservedStatus>([
|
|
|
93
109
|
"killed",
|
|
94
110
|
]);
|
|
95
111
|
const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
|
|
112
|
+
const RUN_STATE_DISCOVERY_MAX_DEPTH = 8;
|
|
96
113
|
|
|
97
114
|
const procDescendantScanCache = new Map<
|
|
98
115
|
string,
|
|
@@ -120,6 +137,30 @@ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
|
|
|
120
137
|
: undefined;
|
|
121
138
|
}
|
|
122
139
|
|
|
140
|
+
function listRunStateDirs(
|
|
141
|
+
stateRoot: string,
|
|
142
|
+
depth = 0,
|
|
143
|
+
seen = new Set<string>(),
|
|
144
|
+
): string[] {
|
|
145
|
+
if (!existsSync(stateRoot) || seen.has(stateRoot)) return [];
|
|
146
|
+
seen.add(stateRoot);
|
|
147
|
+
let entries: import("node:fs").Dirent[];
|
|
148
|
+
try {
|
|
149
|
+
entries = readdirSync(stateRoot, { withFileTypes: true });
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const result: string[] = [];
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!entry.isDirectory()) continue;
|
|
156
|
+
const child = join(stateRoot, entry.name);
|
|
157
|
+
if (existsSync(join(child, "run.json"))) result.push(child);
|
|
158
|
+
if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
|
|
159
|
+
result.push(...listRunStateDirs(child, depth + 1, seen));
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
function observeRun(stateDir: string): RunObservation | undefined {
|
|
124
165
|
try {
|
|
125
166
|
const status = AsyncRuns.getRunStatus(stateDir);
|
|
@@ -178,9 +219,8 @@ export function summarizeRuns(
|
|
|
178
219
|
total: 0,
|
|
179
220
|
};
|
|
180
221
|
}
|
|
181
|
-
const runs =
|
|
182
|
-
.
|
|
183
|
-
.map((entry) => observeRun(join(stateRoot, entry.name)))
|
|
222
|
+
const runs = listRunStateDirs(stateRoot)
|
|
223
|
+
.map((stateDir) => observeRun(stateDir))
|
|
184
224
|
.filter((run): run is RunObservation => Boolean(run))
|
|
185
225
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
186
226
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
@@ -242,7 +282,7 @@ function getRunningRunPidMap(stateRoot: string, ownerId?: string): Map<string, s
|
|
|
242
282
|
const pids = new Map<string, string>();
|
|
243
283
|
for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
|
|
244
284
|
if (run.status !== "running") continue;
|
|
245
|
-
const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
|
|
285
|
+
const status = AsyncRuns.getRunStatus(run.stateDir ?? join(stateRoot, run.run));
|
|
246
286
|
const pid = Number(status.pid || 0);
|
|
247
287
|
if (pid > 0) pids.set(String(pid), run.run);
|
|
248
288
|
}
|
|
@@ -264,9 +304,8 @@ function summarizeRunsWithoutSubagents(
|
|
|
264
304
|
runs: [],
|
|
265
305
|
total: 0,
|
|
266
306
|
};
|
|
267
|
-
const runs =
|
|
268
|
-
.
|
|
269
|
-
.map((entry) => observeRun(join(stateRoot, entry.name)))
|
|
307
|
+
const runs = listRunStateDirs(stateRoot)
|
|
308
|
+
.map((stateDir) => observeRun(stateDir))
|
|
270
309
|
.filter((run): run is RunObservation => Boolean(run))
|
|
271
310
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
272
311
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
@@ -370,28 +409,102 @@ export function renderRunStatus(
|
|
|
370
409
|
return renderSubagentStatus(summary.runningSubagents, frame);
|
|
371
410
|
}
|
|
372
411
|
|
|
412
|
+
function isNestedStateDir(parent: string, child: string): boolean {
|
|
413
|
+
const path = relative(parent, child);
|
|
414
|
+
return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
|
|
415
|
+
}
|
|
416
|
+
|
|
373
417
|
export function findRunRetirementCandidates(
|
|
374
418
|
summary: RunSummary,
|
|
375
419
|
): RunRetirementCandidate[] {
|
|
376
420
|
return summary.runs
|
|
377
|
-
.
|
|
421
|
+
.map((run) => {
|
|
378
422
|
const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
|
|
379
423
|
const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
424
|
+
const childRuns = run.stateDir
|
|
425
|
+
? summary.runs.filter(
|
|
426
|
+
(child) =>
|
|
427
|
+
child.stateDir !== undefined &&
|
|
428
|
+
child.stateDir !== run.stateDir &&
|
|
429
|
+
isNestedStateDir(run.stateDir!, child.stateDir),
|
|
430
|
+
)
|
|
431
|
+
: [];
|
|
432
|
+
const runningChildRuns = childRuns.filter((child) => child.status === "running").length;
|
|
433
|
+
return {
|
|
434
|
+
activeSubagents,
|
|
435
|
+
childRuns: childRuns.length,
|
|
436
|
+
descendantSubagents,
|
|
437
|
+
ready:
|
|
438
|
+
run.status === "running" &&
|
|
439
|
+
run.retireWhen === "children_terminal" &&
|
|
440
|
+
run.stateDir !== undefined &&
|
|
441
|
+
!run.terminalHandled &&
|
|
442
|
+
activeSubagents + descendantSubagents + runningChildRuns <= 0,
|
|
443
|
+
run,
|
|
444
|
+
terminalChildRuns: childRuns.filter((child) => TERMINAL.has(child.status)).length,
|
|
445
|
+
};
|
|
386
446
|
})
|
|
387
|
-
.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
447
|
+
.filter((item) => item.ready)
|
|
448
|
+
.map((item) => ({
|
|
449
|
+
activeSubagents: item.activeSubagents,
|
|
450
|
+
childRuns: item.childRuns,
|
|
451
|
+
descendantSubagents: item.descendantSubagents,
|
|
452
|
+
run: item.run.run,
|
|
453
|
+
stateDir: item.run.stateDir!,
|
|
454
|
+
terminalChildRuns: item.terminalChildRuns,
|
|
392
455
|
}));
|
|
393
456
|
}
|
|
394
457
|
|
|
458
|
+
export async function executeRunRetirements(
|
|
459
|
+
summary: RunSummary,
|
|
460
|
+
options: RunRetirementExecutorOptions,
|
|
461
|
+
): Promise<RunRetirementExecution[]> {
|
|
462
|
+
const results: RunRetirementExecution[] = [];
|
|
463
|
+
for (const candidate of findRunRetirementCandidates(summary)) {
|
|
464
|
+
if (options.attempted?.has(candidate.stateDir)) {
|
|
465
|
+
results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
options.attempted?.add(candidate.stateDir);
|
|
469
|
+
try {
|
|
470
|
+
await options.sendStop(candidate);
|
|
471
|
+
options.notify?.(
|
|
472
|
+
`Retiring actor ${candidate.run} after child runs reached terminal state`,
|
|
473
|
+
"info",
|
|
474
|
+
);
|
|
475
|
+
results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
|
|
476
|
+
continue;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
try {
|
|
479
|
+
const cancelResult = options.cancelRun(candidate);
|
|
480
|
+
const cancelled = Boolean((cancelResult as { cancelled?: unknown }).cancelled);
|
|
481
|
+
options.notify?.(
|
|
482
|
+
cancelled
|
|
483
|
+
? `Retiring actor ${candidate.run} by cancellation after graceful stop failed`
|
|
484
|
+
: `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
+
cancelled ? "warning" : "error",
|
|
486
|
+
);
|
|
487
|
+
results.push({
|
|
488
|
+
action: cancelled ? "cancel" : "skip",
|
|
489
|
+
...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
|
|
490
|
+
run: candidate.run,
|
|
491
|
+
stateDir: candidate.stateDir,
|
|
492
|
+
});
|
|
493
|
+
} catch (cancelError) {
|
|
494
|
+
const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
|
|
495
|
+
options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
|
|
496
|
+
results.push({
|
|
497
|
+
action: "failed",
|
|
498
|
+
error: message,
|
|
499
|
+
run: candidate.run,
|
|
500
|
+
stateDir: candidate.stateDir,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return results;
|
|
506
|
+
}
|
|
507
|
+
|
|
395
508
|
export function detectRunTransitions(
|
|
396
509
|
previous: Map<string, RunObservedStatus>,
|
|
397
510
|
summary: RunSummary,
|
package/lib/recipe-discovery.ts
CHANGED
|
@@ -88,11 +88,11 @@ function listRecipeFiles(root: string): string[] {
|
|
|
88
88
|
.filter(
|
|
89
89
|
(entry) =>
|
|
90
90
|
entry.isFile() &&
|
|
91
|
-
entry.name.endsWith(".json") &&
|
|
91
|
+
(entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
|
|
92
92
|
entry.name !== "legacy-tool-registry-migration-report.json",
|
|
93
93
|
)
|
|
94
94
|
.map((entry) => join(root, entry.name))
|
|
95
|
-
.sort();
|
|
95
|
+
.sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function getRecipeConfigDiagnostics(
|
|
@@ -100,9 +100,15 @@ function getRecipeConfigDiagnostics(
|
|
|
100
100
|
config: TemplateRecipeConfig | undefined,
|
|
101
101
|
): string[] {
|
|
102
102
|
if (!config) return [`Invalid recipe: ${file}`];
|
|
103
|
+
const commandTemplateConfig =
|
|
104
|
+
typeof config.template === "object" && config.template !== null
|
|
105
|
+
? config.template
|
|
106
|
+
: config;
|
|
103
107
|
return CommandTemplates.getCommandTemplateWarnings(
|
|
104
|
-
|
|
105
|
-
).map(
|
|
108
|
+
commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
|
|
109
|
+
).map(
|
|
110
|
+
(warning) => `Recipe ${file}: ${warning}`,
|
|
111
|
+
);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
function readDiscoveredRecipe(
|
|
@@ -218,13 +224,22 @@ export function discoverRecipeSources(
|
|
|
218
224
|
const diagnostics: string[] = getRecipeRootDiagnostics(sources);
|
|
219
225
|
for (const [id, bucket] of byId) {
|
|
220
226
|
bucket.sort(
|
|
221
|
-
(a, b) =>
|
|
227
|
+
(a, b) =>
|
|
228
|
+
a.priority - b.priority ||
|
|
229
|
+
a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
|
|
230
|
+
(a.path.endsWith(".json") ? -1 : 1),
|
|
222
231
|
);
|
|
223
232
|
const winner = bucket[0];
|
|
224
233
|
winner.active = true;
|
|
225
234
|
winner.shadows = bucket.slice(1).map((entry) => entry.path);
|
|
226
235
|
active.set(id, winner);
|
|
227
|
-
for (const shadow of bucket.slice(1))
|
|
236
|
+
for (const shadow of bucket.slice(1)) {
|
|
237
|
+
shadow.shadowed = true;
|
|
238
|
+
if (winner.path.endsWith(".json") && shadow.path.endsWith(".md"))
|
|
239
|
+
shadow.diagnostics.push(
|
|
240
|
+
`Markdown recipe ${shadow.path} is shadowed by JSON recipe ${winner.path}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
228
243
|
if (winner.invalid)
|
|
229
244
|
diagnostics.push(
|
|
230
245
|
`Recipe ${id} is invalid and blocks lower-priority recipes`,
|