@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/lib/async-runs.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Owns detached run state, observation, log tailing, listing, and cancellation safety
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
9
|
import {
|
|
9
10
|
closeSync,
|
|
10
11
|
constants,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
writeFileSync,
|
|
20
21
|
writeSync,
|
|
21
22
|
} from "node:fs";
|
|
23
|
+
import { createConnection } from "node:net";
|
|
22
24
|
import { platform } from "node:os";
|
|
23
25
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
24
26
|
import { fileURLToPath } from "node:url";
|
|
@@ -32,13 +34,21 @@ import { writeJsonAtomic } from "./file-state.ts";
|
|
|
32
34
|
import * as Paths from "./paths.ts";
|
|
33
35
|
import * as RecipeReferences from "./recipe-references.ts";
|
|
34
36
|
import * as RecipeUsage from "./recipe-usage.ts";
|
|
37
|
+
import { notifyRuntimeWake } from "./runtime-notifier.ts";
|
|
35
38
|
|
|
36
39
|
const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
40
|
+
const RUN_INBOX_LOCK_TIMEOUT_MS = 5000;
|
|
37
41
|
|
|
38
42
|
export type AsyncRunLaunchSource = "spawn" | "tool";
|
|
39
43
|
|
|
44
|
+
export interface AsyncRunControlEndpoint {
|
|
45
|
+
path: string;
|
|
46
|
+
type: "fifo" | "mailbox" | "named-pipe";
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export interface AsyncRunStartParams {
|
|
41
50
|
async?: boolean;
|
|
51
|
+
control?: AsyncRunControlEndpoint;
|
|
42
52
|
file?: string;
|
|
43
53
|
launch_source?: AsyncRunLaunchSource;
|
|
44
54
|
name?: string;
|
|
@@ -112,6 +122,7 @@ export interface AsyncRunMeta {
|
|
|
112
122
|
template: CommandTemplateValue;
|
|
113
123
|
values: Record<string, unknown>;
|
|
114
124
|
artifacts?: Record<string, string>;
|
|
125
|
+
control?: AsyncRunControlEndpoint;
|
|
115
126
|
mailbox?: RecipeReferences.TemplateRecipeMailbox;
|
|
116
127
|
recipe_context_records?: RecipeReferences.TemplateRecipeContextRecord[];
|
|
117
128
|
retire_when?: "children_terminal";
|
|
@@ -207,7 +218,10 @@ function assertNoActiveRunState(stateDir: string): void {
|
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
function resolveRecipeFile(file: string): string {
|
|
210
|
-
return
|
|
221
|
+
return (
|
|
222
|
+
RecipeReferences.getRecipePath(file, DEFAULT_RECIPE_ROOT) ??
|
|
223
|
+
RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT)
|
|
224
|
+
);
|
|
211
225
|
}
|
|
212
226
|
|
|
213
227
|
function isMutableUsageRecipeFile(file: string): boolean {
|
|
@@ -446,6 +460,7 @@ export function startRun(
|
|
|
446
460
|
template: resolved.template,
|
|
447
461
|
values,
|
|
448
462
|
...(artifacts ? { artifacts } : {}),
|
|
463
|
+
...(startParams.control ? { control: startParams.control } : {}),
|
|
449
464
|
...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
|
|
450
465
|
...(recipeContextRecords && recipeContextRecords.length > 0
|
|
451
466
|
? { recipe_context_records: recipeContextRecords }
|
|
@@ -650,6 +665,193 @@ export function readRunEvents(runOrDir: string, lines = 40): RunOutboxEvent[] {
|
|
|
650
665
|
.filter((event): event is RunOutboxEvent => Boolean(event));
|
|
651
666
|
}
|
|
652
667
|
|
|
668
|
+
export type RunInboxStatus =
|
|
669
|
+
| "queued"
|
|
670
|
+
| "sent"
|
|
671
|
+
| "claimed"
|
|
672
|
+
| "handled"
|
|
673
|
+
| "failed";
|
|
674
|
+
|
|
675
|
+
export type RunInboxMessage = Record<string, unknown> & {
|
|
676
|
+
id?: string;
|
|
677
|
+
status?: RunInboxStatus | string;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
export interface ProcessRunInboxResult {
|
|
681
|
+
claimed: number;
|
|
682
|
+
failed: number;
|
|
683
|
+
handled: number;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function runInboxFile(stateDir: string): string {
|
|
687
|
+
return join(stateDir, "inbox.jsonl");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function sleepSync(ms: number): void {
|
|
691
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function acquireRunInboxLock(stateDir: string): () => void {
|
|
695
|
+
const lockDir = join(stateDir, ".inbox.lock");
|
|
696
|
+
const started = Date.now();
|
|
697
|
+
while (true) {
|
|
698
|
+
try {
|
|
699
|
+
mkdirSync(lockDir, { recursive: false });
|
|
700
|
+
writeFileSync(
|
|
701
|
+
join(lockDir, "owner.json"),
|
|
702
|
+
`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
|
|
703
|
+
"utf8",
|
|
704
|
+
);
|
|
705
|
+
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
706
|
+
} catch (error) {
|
|
707
|
+
try {
|
|
708
|
+
const stat = statSync(lockDir);
|
|
709
|
+
if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
|
|
710
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
} catch {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (Date.now() - started > RUN_INBOX_LOCK_TIMEOUT_MS) {
|
|
717
|
+
throw new Error("Run inbox lock timed out.", { cause: error });
|
|
718
|
+
}
|
|
719
|
+
sleepSync(10);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function parseRunInboxLine(line: string): RunInboxMessage | undefined {
|
|
725
|
+
try {
|
|
726
|
+
return JSON.parse(line) as RunInboxMessage;
|
|
727
|
+
} catch {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
|
|
733
|
+
const file = runInboxFile(stateDir);
|
|
734
|
+
if (!existsSync(file)) return [];
|
|
735
|
+
return readFileSync(file, "utf8")
|
|
736
|
+
.split("\n")
|
|
737
|
+
.filter((line) => line.trim())
|
|
738
|
+
.map(parseRunInboxLine)
|
|
739
|
+
.filter((message): message is RunInboxMessage => Boolean(message));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function writeRunInboxMessages(
|
|
743
|
+
stateDir: string,
|
|
744
|
+
messages: RunInboxMessage[],
|
|
745
|
+
): void {
|
|
746
|
+
writeFileSync(
|
|
747
|
+
runInboxFile(stateDir),
|
|
748
|
+
messages.length
|
|
749
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
750
|
+
: "",
|
|
751
|
+
"utf8",
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function readRunInboxMessages(
|
|
756
|
+
runOrDir: string,
|
|
757
|
+
lines = 40,
|
|
758
|
+
): RunInboxMessage[] {
|
|
759
|
+
const status = getRunStatus(runOrDir);
|
|
760
|
+
const stateDir = String(status.state_dir);
|
|
761
|
+
return tailLines(runInboxFile(stateDir), lines)
|
|
762
|
+
.map(parseRunInboxLine)
|
|
763
|
+
.filter((message): message is RunInboxMessage => Boolean(message));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function updateRunInboxMessageStatus(
|
|
767
|
+
runOrDir: string,
|
|
768
|
+
id: string,
|
|
769
|
+
nextStatus: RunInboxStatus,
|
|
770
|
+
metadata: Record<string, unknown> = {},
|
|
771
|
+
): boolean {
|
|
772
|
+
const status = getRunStatus(runOrDir);
|
|
773
|
+
const stateDir = String(status.state_dir);
|
|
774
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
775
|
+
try {
|
|
776
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
777
|
+
const timestampKey = `${nextStatus}_at`;
|
|
778
|
+
let changed = false;
|
|
779
|
+
const updated = messages.map((message) => {
|
|
780
|
+
if (message.id !== id) return message;
|
|
781
|
+
changed = true;
|
|
782
|
+
return {
|
|
783
|
+
...message,
|
|
784
|
+
...metadata,
|
|
785
|
+
[timestampKey]: new Date().toISOString(),
|
|
786
|
+
status: nextStatus,
|
|
787
|
+
};
|
|
788
|
+
});
|
|
789
|
+
if (changed) writeRunInboxMessages(stateDir, updated);
|
|
790
|
+
return changed;
|
|
791
|
+
} finally {
|
|
792
|
+
releaseLock();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function claimRunInboxMessage(
|
|
797
|
+
runOrDir: string,
|
|
798
|
+
owner = "runtime",
|
|
799
|
+
statuses: string[] = ["queued"],
|
|
800
|
+
): RunInboxMessage | undefined {
|
|
801
|
+
const status = getRunStatus(runOrDir);
|
|
802
|
+
const stateDir = String(status.state_dir);
|
|
803
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
804
|
+
try {
|
|
805
|
+
const messages = readRunInboxMessagesFromStateDir(stateDir);
|
|
806
|
+
const index = messages.findIndex((message) =>
|
|
807
|
+
statuses.includes(String(message.status ?? "queued")),
|
|
808
|
+
);
|
|
809
|
+
if (index < 0) return undefined;
|
|
810
|
+
const claimed = {
|
|
811
|
+
...messages[index],
|
|
812
|
+
claimed_at: new Date().toISOString(),
|
|
813
|
+
claimed_by: owner,
|
|
814
|
+
id: typeof messages[index].id === "string" ? messages[index].id : randomUUID(),
|
|
815
|
+
status: "claimed",
|
|
816
|
+
} satisfies RunInboxMessage;
|
|
817
|
+
messages[index] = claimed;
|
|
818
|
+
writeRunInboxMessages(stateDir, messages);
|
|
819
|
+
return claimed;
|
|
820
|
+
} finally {
|
|
821
|
+
releaseLock();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export async function processRunInboxMessages(
|
|
826
|
+
runOrDir: string,
|
|
827
|
+
handler: (message: RunInboxMessage) => Promise<void> | void,
|
|
828
|
+
options: { limit?: number; owner?: string; statuses?: string[] } = {},
|
|
829
|
+
): Promise<ProcessRunInboxResult> {
|
|
830
|
+
const result: ProcessRunInboxResult = { claimed: 0, failed: 0, handled: 0 };
|
|
831
|
+
const limit = Math.max(1, Number(options.limit ?? 1));
|
|
832
|
+
const owner = options.owner ?? "runtime";
|
|
833
|
+
for (let index = 0; index < limit; index += 1) {
|
|
834
|
+
const message = claimRunInboxMessage(runOrDir, owner, options.statuses);
|
|
835
|
+
if (!message?.id) break;
|
|
836
|
+
result.claimed += 1;
|
|
837
|
+
try {
|
|
838
|
+
await handler(message);
|
|
839
|
+
if (updateRunInboxMessageStatus(runOrDir, message.id, "handled")) {
|
|
840
|
+
result.handled += 1;
|
|
841
|
+
}
|
|
842
|
+
} catch (error) {
|
|
843
|
+
if (
|
|
844
|
+
updateRunInboxMessageStatus(runOrDir, message.id, "failed", {
|
|
845
|
+
error: error instanceof Error ? error.message : String(error),
|
|
846
|
+
})
|
|
847
|
+
) {
|
|
848
|
+
result.failed += 1;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return result;
|
|
853
|
+
}
|
|
854
|
+
|
|
653
855
|
export function appendRunOutboxEvent(
|
|
654
856
|
runOrDir: string,
|
|
655
857
|
event: {
|
|
@@ -700,15 +902,153 @@ export function appendRunOutboxEvent(
|
|
|
700
902
|
};
|
|
701
903
|
}
|
|
702
904
|
|
|
703
|
-
export
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
905
|
+
export interface SendRunMessageOptions {
|
|
906
|
+
namedPipeSend?: (path: string, payload: string) => Promise<number>;
|
|
907
|
+
platform?: NodeJS.Platform;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function getRunControlEndpoint(
|
|
911
|
+
status: Record<string, unknown>,
|
|
912
|
+
stateDir: string,
|
|
913
|
+
): AsyncRunControlEndpoint {
|
|
914
|
+
const control = status.control;
|
|
915
|
+
if (control && typeof control === "object" && !Array.isArray(control)) {
|
|
916
|
+
const record = control as Record<string, unknown>;
|
|
917
|
+
if (
|
|
918
|
+
(record.type === "fifo" ||
|
|
919
|
+
record.type === "mailbox" ||
|
|
920
|
+
record.type === "named-pipe") &&
|
|
921
|
+
typeof record.path === "string" &&
|
|
922
|
+
record.path.trim()
|
|
923
|
+
) {
|
|
924
|
+
return { path: record.path, type: record.type };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return { path: join(stateDir, "control.fifo"), type: "fifo" };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function appendRunInboxMessage(stateDir: string, message: string): string {
|
|
931
|
+
const id = randomUUID();
|
|
932
|
+
const ts = new Date().toISOString();
|
|
933
|
+
let record: Record<string, unknown>;
|
|
934
|
+
try {
|
|
935
|
+
const parsed = JSON.parse(message) as unknown;
|
|
936
|
+
record = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
937
|
+
? (parsed as Record<string, unknown>)
|
|
938
|
+
: { body: parsed, type: "run.message" };
|
|
939
|
+
} catch {
|
|
940
|
+
record = { body: message, type: "run.message" };
|
|
941
|
+
}
|
|
942
|
+
const releaseLock = acquireRunInboxLock(stateDir);
|
|
943
|
+
try {
|
|
944
|
+
writeFileSync(
|
|
945
|
+
runInboxFile(stateDir),
|
|
946
|
+
`${JSON.stringify({ ...record, id, queued_at: ts, received_at: ts, status: "queued" })}\n`,
|
|
947
|
+
{ flag: "a" },
|
|
710
948
|
);
|
|
949
|
+
} finally {
|
|
950
|
+
releaseLock();
|
|
951
|
+
}
|
|
952
|
+
return id;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function writeRunMessageReceipt(
|
|
956
|
+
stateDir: string,
|
|
957
|
+
message: string,
|
|
958
|
+
bytes: number,
|
|
959
|
+
inboxId: string,
|
|
960
|
+
): void {
|
|
961
|
+
const trimmedMessage = message.trim().toLowerCase();
|
|
962
|
+
const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(
|
|
963
|
+
trimmedMessage,
|
|
964
|
+
);
|
|
965
|
+
const ts = new Date().toISOString();
|
|
966
|
+
writeFileSync(
|
|
967
|
+
join(stateDir, "events.jsonl"),
|
|
968
|
+
`${JSON.stringify({ bytes, event: "run.message", inbox_id: inboxId, terminal: terminalMessage || undefined, ts })}\n`,
|
|
969
|
+
{ flag: "a" },
|
|
970
|
+
);
|
|
971
|
+
updateRunInboxMessageStatus(stateDir, inboxId, "sent", { bytes });
|
|
972
|
+
if (terminalMessage) {
|
|
973
|
+
markTerminalHandled(stateDir, {
|
|
974
|
+
event: "run.message",
|
|
975
|
+
message: trimmedMessage,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function sendRunMessageToFifo(
|
|
981
|
+
endpoint: AsyncRunControlEndpoint,
|
|
982
|
+
payload: string,
|
|
983
|
+
): number {
|
|
984
|
+
if (!existsSync(endpoint.path))
|
|
985
|
+
throw new Error(`Run control FIFO not found: ${endpoint.path}`);
|
|
986
|
+
const stat = statSync(endpoint.path);
|
|
987
|
+
if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
|
|
988
|
+
throw new Error(`Run control endpoint is not a FIFO: ${endpoint.path}`);
|
|
711
989
|
}
|
|
990
|
+
let fd: number | undefined;
|
|
991
|
+
try {
|
|
992
|
+
fd = openSync(endpoint.path, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
993
|
+
return writeSync(fd, payload);
|
|
994
|
+
} finally {
|
|
995
|
+
if (fd !== undefined) closeSync(fd);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function notifyRunMessageWake(
|
|
1000
|
+
stateDir: string,
|
|
1001
|
+
run: string,
|
|
1002
|
+
bytes: number,
|
|
1003
|
+
endpoint: AsyncRunControlEndpoint,
|
|
1004
|
+
inboxId: string,
|
|
1005
|
+
): Record<string, unknown> | undefined {
|
|
1006
|
+
try {
|
|
1007
|
+
const event = notifyRuntimeWake(stateDir, {
|
|
1008
|
+
actor: `run:${run}`,
|
|
1009
|
+
metadata: { bytes, control_type: endpoint.type, inbox_id: inboxId },
|
|
1010
|
+
reason: "run.message",
|
|
1011
|
+
});
|
|
1012
|
+
return { wake: "wake.jsonl", wake_id: event.id };
|
|
1013
|
+
} catch {
|
|
1014
|
+
return undefined;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function sendRunMessageToNamedPipe(
|
|
1019
|
+
endpoint: AsyncRunControlEndpoint,
|
|
1020
|
+
payload: string,
|
|
1021
|
+
send?: (path: string, payload: string) => Promise<number>,
|
|
1022
|
+
): Promise<number> {
|
|
1023
|
+
if (send) return send(endpoint.path, payload);
|
|
1024
|
+
return new Promise((resolve, reject) => {
|
|
1025
|
+
const socket = createConnection(endpoint.path);
|
|
1026
|
+
let settled = false;
|
|
1027
|
+
const timeout = setTimeout(() => {
|
|
1028
|
+
if (settled) return;
|
|
1029
|
+
settled = true;
|
|
1030
|
+
socket.destroy();
|
|
1031
|
+
reject(new Error("named pipe connection timed out"));
|
|
1032
|
+
}, 5000);
|
|
1033
|
+
const finish = (error?: Error): void => {
|
|
1034
|
+
if (settled) return;
|
|
1035
|
+
settled = true;
|
|
1036
|
+
clearTimeout(timeout);
|
|
1037
|
+
if (error) reject(error);
|
|
1038
|
+
else resolve(Buffer.byteLength(payload));
|
|
1039
|
+
};
|
|
1040
|
+
socket.on("error", finish);
|
|
1041
|
+
socket.on("connect", () => {
|
|
1042
|
+
socket.end(payload, () => finish());
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export async function sendRunMessage(
|
|
1048
|
+
runOrDir: string,
|
|
1049
|
+
message: string,
|
|
1050
|
+
options: SendRunMessageOptions = {},
|
|
1051
|
+
): Promise<Record<string, unknown>> {
|
|
712
1052
|
const status = getRunStatus(runOrDir);
|
|
713
1053
|
const stateDir = String(status.state_dir);
|
|
714
1054
|
const run = String(status.run ?? runOrDir);
|
|
@@ -718,64 +1058,108 @@ export function sendRunMessage(
|
|
|
718
1058
|
if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
|
|
719
1059
|
if (!pidMatchesRun(pid, String(status.cwd), stateDir))
|
|
720
1060
|
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
|
-
}
|
|
1061
|
+
const endpoint = getRunControlEndpoint(status, stateDir);
|
|
728
1062
|
const payload = message.endsWith("\n") ? message : `${message}\n`;
|
|
729
|
-
|
|
1063
|
+
const payloadBytes = Buffer.byteLength(payload);
|
|
1064
|
+
const inboxId = appendRunInboxMessage(stateDir, message);
|
|
1065
|
+
const wake = notifyRunMessageWake(stateDir, run, payloadBytes, endpoint, inboxId);
|
|
1066
|
+
const runtimePlatform = options.platform ?? process.platform;
|
|
1067
|
+
if (endpoint.type === "mailbox") {
|
|
1068
|
+
return {
|
|
1069
|
+
...(wake ?? {}),
|
|
1070
|
+
bytes: payloadBytes,
|
|
1071
|
+
control: "inbox.jsonl",
|
|
1072
|
+
control_path: endpoint.path,
|
|
1073
|
+
control_type: endpoint.type,
|
|
1074
|
+
queued: true,
|
|
1075
|
+
run,
|
|
1076
|
+
sent: true,
|
|
1077
|
+
state_dir: stateDir,
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
730
1080
|
try {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
});
|
|
1081
|
+
if (endpoint.type === "fifo") {
|
|
1082
|
+
if (runtimePlatform === "win32") {
|
|
1083
|
+
throw new Error(
|
|
1084
|
+
"run actor messages on native Windows require a named-pipe control endpoint; this recipe still exposes Unix FIFO control.",
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
const bytes = sendRunMessageToFifo(endpoint, payload);
|
|
1088
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
1089
|
+
return {
|
|
1090
|
+
...(wake ?? {}),
|
|
1091
|
+
bytes,
|
|
1092
|
+
control: "control.fifo",
|
|
1093
|
+
control_path: endpoint.path,
|
|
1094
|
+
control_type: endpoint.type,
|
|
1095
|
+
run,
|
|
1096
|
+
sent: true,
|
|
1097
|
+
state_dir: stateDir,
|
|
1098
|
+
};
|
|
758
1099
|
}
|
|
1100
|
+
const bytes = await sendRunMessageToNamedPipe(
|
|
1101
|
+
endpoint,
|
|
1102
|
+
payload,
|
|
1103
|
+
options.namedPipeSend,
|
|
1104
|
+
);
|
|
1105
|
+
writeRunMessageReceipt(stateDir, message, bytes, inboxId);
|
|
759
1106
|
return {
|
|
1107
|
+
...(wake ?? {}),
|
|
760
1108
|
bytes,
|
|
761
|
-
control:
|
|
1109
|
+
control: endpoint.path,
|
|
1110
|
+
control_path: endpoint.path,
|
|
1111
|
+
control_type: endpoint.type,
|
|
762
1112
|
run,
|
|
763
1113
|
sent: true,
|
|
764
1114
|
state_dir: stateDir,
|
|
765
1115
|
};
|
|
766
1116
|
} catch (error) {
|
|
767
1117
|
throw new Error(
|
|
768
|
-
`Run control
|
|
1118
|
+
`Run control endpoint is not ready: ${endpoint.path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
769
1119
|
);
|
|
770
|
-
} finally {
|
|
771
|
-
if (fd !== undefined) closeSync(fd);
|
|
772
1120
|
}
|
|
773
1121
|
}
|
|
774
1122
|
|
|
1123
|
+
export interface RunProcessSignalPlan {
|
|
1124
|
+
args?: string[];
|
|
1125
|
+
command?: string;
|
|
1126
|
+
signalTarget: "processGroup" | "process" | "processTree";
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
export function getRunProcessSignalPlan(
|
|
1130
|
+
pid: number,
|
|
1131
|
+
signal: NodeJS.Signals,
|
|
1132
|
+
runtimePlatform: NodeJS.Platform = process.platform,
|
|
1133
|
+
): RunProcessSignalPlan {
|
|
1134
|
+
if (runtimePlatform === "win32") {
|
|
1135
|
+
return {
|
|
1136
|
+
args: [
|
|
1137
|
+
"/PID",
|
|
1138
|
+
String(pid),
|
|
1139
|
+
"/T",
|
|
1140
|
+
...(signal === "SIGKILL" ? ["/F"] : []),
|
|
1141
|
+
],
|
|
1142
|
+
command: "taskkill",
|
|
1143
|
+
signalTarget: "processTree",
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
return { signalTarget: "processGroup" };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
775
1149
|
function signalOwnedRunProcess(
|
|
776
1150
|
pid: number,
|
|
777
1151
|
signal: NodeJS.Signals,
|
|
778
|
-
):
|
|
1152
|
+
): RunProcessSignalPlan {
|
|
1153
|
+
const plan = getRunProcessSignalPlan(pid, signal);
|
|
1154
|
+
if (plan.command && plan.args) {
|
|
1155
|
+
const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
|
|
1156
|
+
if (result.status !== 0) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
result.stderr?.trim() || result.stdout?.trim() || `${plan.command} failed`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
return plan;
|
|
1162
|
+
}
|
|
779
1163
|
try {
|
|
780
1164
|
process.kill(-pid, signal);
|
|
781
1165
|
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 {
|