@llblab/pi-actors 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +125 -60
- package/BACKLOG.md +133 -176
- package/CHANGELOG.md +27 -0
- package/README.md +13 -0
- package/dist/fixtures/protocol/actor-message-branch.json +13 -0
- package/dist/fixtures/protocol/artifact-manifest.json +9 -0
- package/dist/fixtures/protocol/mailbox-contract.json +15 -0
- package/dist/fixtures/protocol/recipe-summary.json +16 -0
- package/dist/fixtures/protocol/room-message.json +11 -0
- package/dist/fixtures/protocol/room-roster.json +11 -0
- package/dist/fixtures/protocol/run-inbox-message.json +9 -0
- package/dist/fixtures/protocol/run-outbox-event.json +9 -0
- package/dist/fixtures/protocol/run-state.json +10 -0
- package/dist/index.js +3 -2
- package/dist/lib/actor-inspector-tui.js +5 -32
- package/dist/lib/actor-rooms.d.ts +8 -0
- package/dist/lib/actor-rooms.js +64 -20
- package/dist/lib/actor-worker.d.ts +13 -0
- package/dist/lib/actor-worker.js +86 -0
- package/dist/lib/async-runner.d.ts +5 -0
- package/dist/lib/async-runner.js +134 -0
- package/dist/lib/async-runs.js +9 -28
- package/dist/lib/conformance.d.ts +12 -0
- package/dist/lib/conformance.js +28 -0
- package/dist/lib/coordinator.d.ts +5 -0
- package/dist/lib/coordinator.js +574 -0
- package/dist/lib/locker.d.ts +5 -0
- package/dist/lib/locker.js +310 -0
- package/dist/lib/mailbox-loop.d.ts +41 -0
- package/dist/lib/mailbox-loop.js +62 -0
- package/dist/lib/observability.d.ts +2 -2
- package/dist/lib/observability.js +51 -53
- package/dist/lib/prompts.d.ts +1 -1
- package/dist/lib/prompts.js +1 -1
- package/dist/lib/recipe-references.js +25 -2
- package/dist/lib/recipe-utils.d.ts +5 -0
- package/dist/lib/recipe-utils.js +385 -0
- package/dist/lib/runtime-notifier.js +3 -7
- package/dist/lib/state-readers.d.ts +21 -0
- package/dist/lib/state-readers.js +74 -0
- package/dist/lib/tools.js +1 -1
- package/dist/lib/validate-recipe.d.ts +6 -0
- package/dist/lib/validate-recipe.js +104 -0
- package/dist/recipes/actor-worker.json +35 -0
- package/dist/recipes/coordinator-locker.json +45 -0
- package/dist/recipes/lens-swarm.json +66 -0
- package/dist/recipes/locker.json +45 -0
- package/dist/recipes/music-player.json +38 -0
- package/dist/recipes/pipeline-architect-coordinator.json +95 -0
- package/dist/recipes/pipeline-artifact-bundle.json +100 -0
- package/dist/recipes/pipeline-artifact-report.json +58 -0
- package/dist/recipes/pipeline-artifact-write.json +72 -0
- package/dist/recipes/pipeline-async-run-ops.json +70 -0
- package/dist/recipes/pipeline-checkpoint-continuation.json +67 -0
- package/dist/recipes/pipeline-development-tasking.json +81 -0
- package/dist/recipes/pipeline-docs-maintenance.json +80 -0
- package/dist/recipes/pipeline-media-library.json +59 -0
- package/dist/recipes/pipeline-quorum-review.json +79 -0
- package/dist/recipes/pipeline-release-readiness.json +110 -0
- package/dist/recipes/pipeline-release-summary.json +88 -0
- package/dist/recipes/pipeline-repo-health.json +89 -0
- package/dist/recipes/pipeline-research-synthesis.json +94 -0
- package/dist/recipes/pipeline-review-readiness.json +54 -0
- package/dist/recipes/pipeline-room-swarm.json +50 -0
- package/dist/recipes/subagent-artifact.json +32 -0
- package/dist/recipes/subagent-checkpoint.json +33 -0
- package/dist/recipes/subagent-conflict-report.json +32 -0
- package/dist/recipes/subagent-contradiction-map.json +33 -0
- package/dist/recipes/subagent-critic.json +35 -0
- package/dist/recipes/subagent-evidence-map.json +33 -0
- package/dist/recipes/subagent-followup.json +33 -0
- package/dist/recipes/subagent-judge.json +33 -0
- package/dist/recipes/subagent-merge.json +33 -0
- package/dist/recipes/subagent-message.json +34 -0
- package/dist/recipes/subagent-normalize.json +31 -0
- package/dist/recipes/subagent-plan.json +33 -0
- package/dist/recipes/subagent-prompt.json +28 -0
- package/dist/recipes/subagent-quorum.json +43 -0
- package/dist/recipes/subagent-review-coordinator.json +114 -0
- package/dist/recipes/subagent-review.json +37 -0
- package/dist/recipes/subagent-task-card.json +35 -0
- package/dist/recipes/subagent-tools.json +27 -0
- package/dist/recipes/subagent-verify.json +34 -0
- package/dist/recipes/subagents-prompts.json +51 -0
- package/dist/recipes/utility-actor-message.json +23 -0
- package/dist/recipes/utility-artifact-manifest.json +16 -0
- package/dist/recipes/utility-artifact-write.json +16 -0
- package/dist/recipes/utility-changelog-head.json +11 -0
- package/dist/recipes/utility-changelog-section.json +13 -0
- package/dist/recipes/utility-coordinator-lock-snapshot.json +13 -0
- package/dist/recipes/utility-git-log.json +11 -0
- package/dist/recipes/utility-git-status.json +9 -0
- package/dist/recipes/utility-jsonl-tail.json +10 -0
- package/dist/recipes/utility-markdown-index.json +14 -0
- package/dist/recipes/utility-package-summary.json +11 -0
- package/dist/recipes/utility-playlist-build.json +17 -0
- package/dist/recipes/utility-playlist-scan.json +11 -0
- package/dist/recipes/utility-run-ops-snapshot.json +17 -0
- package/dist/recipes/utility-run-state-files.json +13 -0
- package/dist/recipes/utility-run-summary.json +11 -0
- package/dist/recipes/utility-skill-summary.json +13 -0
- package/dist/recipes/utility-validate-recipe.json +13 -0
- package/dist/recipes/utility-validation-wrapper.json +13 -0
- package/dist/scripts/actor-worker.mjs +31 -0
- package/dist/scripts/async-runner.mjs +31 -0
- package/dist/scripts/build-dist.mjs +33 -0
- package/dist/scripts/conformance.mjs +33 -0
- package/dist/scripts/coordinator.mjs +31 -0
- package/dist/scripts/locker.mjs +33 -0
- package/dist/scripts/music-player.mjs +964 -0
- package/dist/scripts/recipe-utils.mjs +31 -0
- package/dist/scripts/validate-recipe.mjs +34 -0
- package/dist/skills/actors/SKILL.md +377 -0
- package/dist/skills/swarm/SKILL.md +467 -0
- package/dist/skills/swarm/references/development-swarm.md +596 -0
- package/docs/actor-messages.md +2 -2
- package/docs/async-runs.md +11 -0
- package/docs/template-recipes.md +1 -1
- package/fixtures/protocol/actor-message-branch.json +13 -0
- package/fixtures/protocol/artifact-manifest.json +9 -0
- package/fixtures/protocol/mailbox-contract.json +15 -0
- package/fixtures/protocol/recipe-summary.json +16 -0
- package/fixtures/protocol/room-message.json +11 -0
- package/fixtures/protocol/room-roster.json +11 -0
- package/fixtures/protocol/run-inbox-message.json +9 -0
- package/fixtures/protocol/run-outbox-event.json +9 -0
- package/fixtures/protocol/run-state.json +10 -0
- package/index.ts +3 -0
- package/lib/actor-inspector-tui.ts +11 -34
- package/lib/actor-rooms.ts +88 -18
- package/lib/actor-worker.ts +118 -0
- package/lib/async-runner.ts +173 -0
- package/lib/async-runs.ts +12 -22
- package/lib/conformance.ts +46 -0
- package/lib/coordinator.ts +664 -0
- package/lib/locker.ts +340 -0
- package/lib/mailbox-loop.ts +148 -0
- package/lib/observability.ts +24 -19
- package/lib/prompts.ts +1 -1
- package/lib/recipe-references.ts +37 -2
- package/lib/recipe-utils.ts +486 -0
- package/lib/runtime-notifier.ts +4 -6
- package/lib/state-readers.ts +93 -0
- package/lib/tools.ts +1 -1
- package/lib/validate-recipe.ts +110 -0
- package/package.json +10 -2
- package/recipes/actor-worker.json +35 -0
- package/recipes/pipeline-quorum-review.json +12 -7
- package/scripts/actor-worker.mjs +31 -0
- package/scripts/async-runner.mjs +11 -201
- package/scripts/build-dist.mjs +33 -0
- package/scripts/conformance.mjs +21 -35
- package/scripts/coordinator.mjs +15 -625
- package/scripts/locker.mjs +20 -332
- package/scripts/recipe-utils.mjs +17 -477
- package/scripts/validate-recipe.mjs +18 -121
- package/skills/actors/SKILL.md +8 -3
- package/skills/swarm/SKILL.md +3 -1
package/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
48
48
|
const runDirWatchers = new Map<string, FSWatcher>();
|
|
49
49
|
const observedRuns = new Map<string, Observability.RunObservedStatus>();
|
|
50
50
|
const observedRunEventLines = new Map<string, number>();
|
|
51
|
+
const observedRunOutboxEventIds = new Map<string, Set<string>>();
|
|
51
52
|
const retirementAttempts = new Set<string>();
|
|
52
53
|
let runStatusFrame = 0;
|
|
53
54
|
let communicationWidgetVisible = false;
|
|
@@ -152,6 +153,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
152
153
|
const outboxEvents = Observability.detectRunOutboxEvents(
|
|
153
154
|
observedRunEventLines,
|
|
154
155
|
summary,
|
|
156
|
+
observedRunOutboxEventIds,
|
|
155
157
|
);
|
|
156
158
|
if (!notify) return;
|
|
157
159
|
retireCandidateRuns(ctx, summary);
|
|
@@ -177,6 +179,7 @@ export default function toolRegistryExtension(pi: ExtensionAPI) {
|
|
|
177
179
|
observedRunEventLines,
|
|
178
180
|
summary,
|
|
179
181
|
transitions.map((transition) => transition.stateDir ?? transition.run),
|
|
182
|
+
observedRunOutboxEventIds,
|
|
180
183
|
);
|
|
181
184
|
for (const event of outboxEvents) {
|
|
182
185
|
if (!Observability.shouldNotifyRunOutboxEvent(event)) continue;
|
|
@@ -11,6 +11,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|
|
11
11
|
import type { ActorMessage } from "./actor-messages.ts";
|
|
12
12
|
import * as Limits from "./limits.ts";
|
|
13
13
|
import * as Paths from "./paths.ts";
|
|
14
|
+
import { readJsonFileResilient, readJsonlFileResilient } from "./state-readers.ts";
|
|
14
15
|
|
|
15
16
|
export interface ActorInspectorPreview {
|
|
16
17
|
body_preview?: string;
|
|
@@ -72,22 +73,7 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
function readJsonLines(file: string): Record<string, unknown>[] {
|
|
75
|
-
|
|
76
|
-
return fs
|
|
77
|
-
.readFileSync(file, "utf8")
|
|
78
|
-
.split("\n")
|
|
79
|
-
.filter(Boolean)
|
|
80
|
-
.flatMap((line) => {
|
|
81
|
-
try {
|
|
82
|
-
return [JSON.parse(line) as Record<string, unknown>];
|
|
83
|
-
} catch {
|
|
84
|
-
return [];
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
} catch (error) {
|
|
88
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
76
|
+
return readJsonlFileResilient<Record<string, unknown>>(file).records;
|
|
91
77
|
}
|
|
92
78
|
|
|
93
79
|
function previewValue(
|
|
@@ -156,16 +142,10 @@ function readRoomRosterRecords(
|
|
|
156
142
|
stateDir: string,
|
|
157
143
|
room: string,
|
|
158
144
|
): Record<string, Record<string, unknown>> {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"utf8",
|
|
164
|
-
),
|
|
165
|
-
) as Record<string, Record<string, unknown>>;
|
|
166
|
-
} catch {
|
|
167
|
-
return {};
|
|
168
|
-
}
|
|
145
|
+
return readJsonFileResilient<Record<string, Record<string, unknown>>>(
|
|
146
|
+
path.join(stateDir, "rooms", room, "roster.json"),
|
|
147
|
+
{},
|
|
148
|
+
).value;
|
|
169
149
|
}
|
|
170
150
|
|
|
171
151
|
function memberDisplay(
|
|
@@ -297,14 +277,11 @@ function readOutboxPreviews(
|
|
|
297
277
|
}
|
|
298
278
|
|
|
299
279
|
function getRunOwnerId(stateDir: string): string | undefined {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
} catch {
|
|
306
|
-
return undefined;
|
|
307
|
-
}
|
|
280
|
+
const meta = readJsonFileResilient<Record<string, unknown> | undefined>(
|
|
281
|
+
path.join(stateDir, "run.json"),
|
|
282
|
+
undefined,
|
|
283
|
+
).value;
|
|
284
|
+
return typeof meta?.ownerId === "string" ? meta.ownerId : undefined;
|
|
308
285
|
}
|
|
309
286
|
|
|
310
287
|
function matchesOwner(stateDir: string, ownerId: string | undefined): boolean {
|
package/lib/actor-rooms.ts
CHANGED
|
@@ -11,6 +11,11 @@ import * as path from "node:path";
|
|
|
11
11
|
import type { ActorMessage } from "./actor-messages.ts";
|
|
12
12
|
import * as Limits from "./limits.ts";
|
|
13
13
|
import { notifyRuntimeWake } from "./runtime-notifier.ts";
|
|
14
|
+
import {
|
|
15
|
+
formatStateReadDiagnostics,
|
|
16
|
+
readJsonFileResilient,
|
|
17
|
+
readJsonlFileResilient,
|
|
18
|
+
} from "./state-readers.ts";
|
|
14
19
|
|
|
15
20
|
const STATE_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
16
21
|
const STATE_LOCK_TIMEOUT_MS = 5000;
|
|
@@ -63,6 +68,8 @@ export interface RoomStatus {
|
|
|
63
68
|
message_count: number;
|
|
64
69
|
roster_count: number;
|
|
65
70
|
compaction?: RoomCompactionInfo;
|
|
71
|
+
diagnostics?: string[];
|
|
72
|
+
diagnostics_count?: number;
|
|
66
73
|
last_message_at?: string;
|
|
67
74
|
last_message_from?: string;
|
|
68
75
|
last_message_summary?: string;
|
|
@@ -194,12 +201,7 @@ function runFromRoomAddress(address: string): string | undefined {
|
|
|
194
201
|
}
|
|
195
202
|
|
|
196
203
|
function readJsonFile<T>(file: string, fallback: T): T {
|
|
197
|
-
|
|
198
|
-
return JSON.parse(fs.readFileSync(file, "utf8")) as T;
|
|
199
|
-
} catch (error) {
|
|
200
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback;
|
|
201
|
-
throw error;
|
|
202
|
-
}
|
|
204
|
+
return readJsonFileResilient<T>(file, fallback).value;
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
function writeJsonFile(file: string, value: unknown): void {
|
|
@@ -460,6 +462,11 @@ function updateRosterForMessage(
|
|
|
460
462
|
}
|
|
461
463
|
|
|
462
464
|
export interface BranchInboxRecord extends ActorMessage {
|
|
465
|
+
claimed_at?: string;
|
|
466
|
+
claimed_by?: string;
|
|
467
|
+
error?: string;
|
|
468
|
+
failed_at?: string;
|
|
469
|
+
handled_at?: string;
|
|
463
470
|
id?: string;
|
|
464
471
|
queued_at?: string;
|
|
465
472
|
status?: string;
|
|
@@ -589,6 +596,49 @@ export function appendBranchInboxMessage(
|
|
|
589
596
|
}
|
|
590
597
|
}
|
|
591
598
|
|
|
599
|
+
export function claimBranchInboxMessage(
|
|
600
|
+
stateDir: string,
|
|
601
|
+
run: string,
|
|
602
|
+
address: string,
|
|
603
|
+
owner = "runtime",
|
|
604
|
+
statuses: string[] = ["queued"],
|
|
605
|
+
): BranchInboxRecord | undefined {
|
|
606
|
+
const branch = branchIdFromAddress(address, run);
|
|
607
|
+
if (!branch)
|
|
608
|
+
throw new Error(`Expected branch:${run}/<branch>; got ${address}`);
|
|
609
|
+
const releaseLock = acquireBranchInboxLock(stateDir, branch);
|
|
610
|
+
try {
|
|
611
|
+
const file = branchInboxFile(stateDir, branch);
|
|
612
|
+
const records = readAllBranchInboxLines(stateDir, run, address);
|
|
613
|
+
let claimed: BranchInboxRecord | undefined;
|
|
614
|
+
const now = new Date().toISOString();
|
|
615
|
+
const updated = records.map((record) => {
|
|
616
|
+
if (claimed || !("message" in record)) return record;
|
|
617
|
+
const status = record.message.status ?? "queued";
|
|
618
|
+
if (!statuses.includes(status)) return record;
|
|
619
|
+
claimed = {
|
|
620
|
+
...record.message,
|
|
621
|
+
claimed_at: now,
|
|
622
|
+
claimed_by: owner,
|
|
623
|
+
status: "claimed",
|
|
624
|
+
};
|
|
625
|
+
return { message: claimed };
|
|
626
|
+
});
|
|
627
|
+
if (!claimed) return undefined;
|
|
628
|
+
fs.writeFileSync(
|
|
629
|
+
file,
|
|
630
|
+
`${updated.map((record) => ("raw" in record ? record.raw : JSON.stringify(record.message))).join("\n")}\n`,
|
|
631
|
+
);
|
|
632
|
+
notifyActorWake(stateDir, address, "branch.inbox.claim", {
|
|
633
|
+
id: claimed.id,
|
|
634
|
+
owner,
|
|
635
|
+
});
|
|
636
|
+
return claimed;
|
|
637
|
+
} finally {
|
|
638
|
+
releaseLock();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
592
642
|
export function updateBranchInboxMessageStatus(
|
|
593
643
|
stateDir: string,
|
|
594
644
|
run: string,
|
|
@@ -682,7 +732,13 @@ export function readRoomMessages(
|
|
|
682
732
|
): RoomTimelineEntry[] {
|
|
683
733
|
try {
|
|
684
734
|
const lines = readJsonlTailLines(messagesFile(stateDir, room), limit);
|
|
685
|
-
return lines.
|
|
735
|
+
return lines.flatMap((line) => {
|
|
736
|
+
try {
|
|
737
|
+
return [JSON.parse(line) as RoomTimelineEntry];
|
|
738
|
+
} catch {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
});
|
|
686
742
|
} catch (error) {
|
|
687
743
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
688
744
|
throw error;
|
|
@@ -722,10 +778,22 @@ export function readRoomMessagePreviews(
|
|
|
722
778
|
export function getRoomStatus(stateDir: string, room: string): RoomStatus {
|
|
723
779
|
const messageCount = readRoomMessageCount(stateDir, room);
|
|
724
780
|
const [last] = readRoomMessages(stateDir, room, 1);
|
|
725
|
-
const
|
|
781
|
+
const compactionRead = readJsonFileResilient<RoomCompactionInfo | undefined>(
|
|
726
782
|
path.join(roomDir(stateDir, room), "compaction.json"),
|
|
727
783
|
undefined,
|
|
728
784
|
);
|
|
785
|
+
const rosterRead = readJsonFileResilient<Record<string, RoomMember>>(
|
|
786
|
+
rosterFile(stateDir, room),
|
|
787
|
+
{},
|
|
788
|
+
);
|
|
789
|
+
const messageRead = readJsonlFileResilient<RoomTimelineEntry>(
|
|
790
|
+
messagesFile(stateDir, room),
|
|
791
|
+
);
|
|
792
|
+
const diagnostics = [
|
|
793
|
+
...messageRead.diagnostics,
|
|
794
|
+
...rosterRead.diagnostics,
|
|
795
|
+
...compactionRead.diagnostics,
|
|
796
|
+
];
|
|
729
797
|
return {
|
|
730
798
|
...(last
|
|
731
799
|
? {
|
|
@@ -735,10 +803,16 @@ export function getRoomStatus(stateDir: string, room: string): RoomStatus {
|
|
|
735
803
|
last_message_type: last.type,
|
|
736
804
|
}
|
|
737
805
|
: {}),
|
|
738
|
-
...(
|
|
806
|
+
...(compactionRead.value ? { compaction: compactionRead.value } : {}),
|
|
807
|
+
...(diagnostics.length
|
|
808
|
+
? {
|
|
809
|
+
diagnostics: formatStateReadDiagnostics(diagnostics),
|
|
810
|
+
diagnostics_count: diagnostics.length,
|
|
811
|
+
}
|
|
812
|
+
: {}),
|
|
739
813
|
message_count: messageCount,
|
|
740
814
|
room,
|
|
741
|
-
roster_count: Object.keys(
|
|
815
|
+
roster_count: Object.keys(rosterRead.value).length,
|
|
742
816
|
};
|
|
743
817
|
}
|
|
744
818
|
|
|
@@ -785,14 +859,10 @@ export function ensureDefaultRoom(
|
|
|
785
859
|
export function readCommunicationSnapshot(
|
|
786
860
|
stateDir: string,
|
|
787
861
|
): ActorCommunicationSnapshot | undefined {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
} catch (error) {
|
|
793
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
794
|
-
throw error;
|
|
795
|
-
}
|
|
862
|
+
return readJsonFileResilient<ActorCommunicationSnapshot | undefined>(
|
|
863
|
+
snapshotFile(stateDir),
|
|
864
|
+
undefined,
|
|
865
|
+
).value;
|
|
796
866
|
}
|
|
797
867
|
|
|
798
868
|
export function readRoomContacts(
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical mailbox-backed actor worker entrypoint logic.
|
|
3
|
+
* Zones: worker demo, mailbox loop recipe runtime
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { appendRoomMessage, ensureRoomMember } from "./actor-rooms.ts";
|
|
10
|
+
import { handleMailboxLoopOnce, isMailboxLoopStopMessage } from "./mailbox-loop.ts";
|
|
11
|
+
|
|
12
|
+
interface ActorWorkerArgs {
|
|
13
|
+
branch?: string;
|
|
14
|
+
poll_ms?: string | number;
|
|
15
|
+
run?: string;
|
|
16
|
+
state_dir?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseActorWorkerArgs(argv: string[]): ActorWorkerArgs {
|
|
20
|
+
const args: Record<string, string | true> = {};
|
|
21
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
22
|
+
const token = argv[index];
|
|
23
|
+
if (!token.startsWith("--")) continue;
|
|
24
|
+
const key = token.slice(2).replaceAll("-", "_");
|
|
25
|
+
const next = argv[index + 1];
|
|
26
|
+
if (!next || next.startsWith("--")) {
|
|
27
|
+
args[key] = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
args[key] = next;
|
|
31
|
+
index += 1;
|
|
32
|
+
}
|
|
33
|
+
return args as ActorWorkerArgs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sleep(ms: number): Promise<void> {
|
|
37
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function text(value: unknown): string {
|
|
41
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runActorWorker(argv = process.argv.slice(2)): Promise<void> {
|
|
45
|
+
const args = parseActorWorkerArgs(argv);
|
|
46
|
+
const stateDir = String(args.state_dir ?? "");
|
|
47
|
+
const run = String(args.run ?? "");
|
|
48
|
+
const branch = String(args.branch ?? "worker");
|
|
49
|
+
const pollMs = Math.max(50, Number(args.poll_ms ?? 1000));
|
|
50
|
+
|
|
51
|
+
if (!stateDir || !run) {
|
|
52
|
+
throw new Error("usage: actor-worker.mjs --state-dir <dir> --run <id> [--branch worker] [--poll-ms 1000]");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const branchAddress = `branch:${run}/${branch}`;
|
|
56
|
+
const roomAddress = `room:${run}`;
|
|
57
|
+
const journalPath = join(stateDir, "worker-events.jsonl");
|
|
58
|
+
|
|
59
|
+
function journal(event: string, data: Record<string, unknown> = {}): void {
|
|
60
|
+
appendFileSync(
|
|
61
|
+
journalPath,
|
|
62
|
+
`${JSON.stringify({ event, ts: new Date().toISOString(), ...data })}\n`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function room(type: string, summary: string, body: Record<string, unknown> = {}): void {
|
|
67
|
+
appendRoomMessage(stateDir, "main", {
|
|
68
|
+
body,
|
|
69
|
+
from: branchAddress,
|
|
70
|
+
summary,
|
|
71
|
+
to: roomAddress,
|
|
72
|
+
type,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ensureRoomMember(
|
|
77
|
+
stateDir,
|
|
78
|
+
run,
|
|
79
|
+
"main",
|
|
80
|
+
branchAddress,
|
|
81
|
+
{ display: branch, role: "worker", status: "present" },
|
|
82
|
+
`${branch} joined as mailbox worker`,
|
|
83
|
+
);
|
|
84
|
+
room("awaiting_assignment", `${branch} awaiting assignment`, { branch });
|
|
85
|
+
journal("worker.started", { branch, run });
|
|
86
|
+
|
|
87
|
+
let stopping = false;
|
|
88
|
+
while (!stopping) {
|
|
89
|
+
const result = await handleMailboxLoopOnce(
|
|
90
|
+
{ address: branchAddress, kind: "branch", run, stateDir },
|
|
91
|
+
(message) => {
|
|
92
|
+
if (isMailboxLoopStopMessage(message)) {
|
|
93
|
+
stopping = true;
|
|
94
|
+
room("actor.leave", `${branch} stopping`, { branch, reason: message.type });
|
|
95
|
+
journal("worker.stopping", { id: message.id, type: message.type });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
room("task.claim", `${branch} claimed ${message.type}`, {
|
|
99
|
+
branch,
|
|
100
|
+
id: message.id,
|
|
101
|
+
type: message.type,
|
|
102
|
+
});
|
|
103
|
+
room("task.result", `${branch} handled ${message.type}`, {
|
|
104
|
+
branch,
|
|
105
|
+
id: message.id,
|
|
106
|
+
result: text(message.body),
|
|
107
|
+
type: message.type,
|
|
108
|
+
});
|
|
109
|
+
room("awaiting_assignment", `${branch} awaiting assignment`, { branch });
|
|
110
|
+
journal("task.handled", { id: message.id, type: message.type });
|
|
111
|
+
},
|
|
112
|
+
{ owner: branchAddress },
|
|
113
|
+
);
|
|
114
|
+
if (!result.handled) await sleep(pollMs);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
journal("worker.done", { branch, run });
|
|
118
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached async-runner entrypoint logic.
|
|
3
|
+
* Zones: async run process, command-template execution telemetry
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { appendRecipeContextToPiArgs } from "./actor-recipe-context.ts";
|
|
10
|
+
import { execCommandTemplate } from "./command-templates.ts";
|
|
11
|
+
import { executeRegisteredTool, type ToolExecOptions } from "./execution.ts";
|
|
12
|
+
import { writeJsonAtomic } from "./file-state.ts";
|
|
13
|
+
|
|
14
|
+
function quoteCommandDetailPart(value: string): string {
|
|
15
|
+
if (value === "") return "''";
|
|
16
|
+
if (/^[A-Za-z0-9_/:=.,@%+\-]+$/.test(value)) return value;
|
|
17
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatCommandDetail(command: string, args: string[]): string {
|
|
21
|
+
return [command, ...args].map(quoteCommandDetailPart).join(" ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function summarizeCommandDetail(commandDetail: string): string {
|
|
25
|
+
return commandDetail.length > 160
|
|
26
|
+
? `${commandDetail.slice(0, 157)}...`
|
|
27
|
+
: commandDetail;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runAsyncRunner(stateDir = process.argv[2]): Promise<void> {
|
|
31
|
+
if (!stateDir) throw new Error("missing state dir");
|
|
32
|
+
|
|
33
|
+
const runPath = join(stateDir, "run.json");
|
|
34
|
+
const progressPath = join(stateDir, "progress.json");
|
|
35
|
+
const resultPath = join(stateDir, "result.json");
|
|
36
|
+
const eventsPath = join(stateDir, "events.jsonl");
|
|
37
|
+
const outboxPath = join(stateDir, "outbox.jsonl");
|
|
38
|
+
const stdoutPath = join(stateDir, "stdout.log");
|
|
39
|
+
const stderrPath = join(stateDir, "stderr.log");
|
|
40
|
+
const meta = JSON.parse(readFileSync(runPath, "utf8"));
|
|
41
|
+
|
|
42
|
+
function event(name: string, data: Record<string, unknown> = {}): void {
|
|
43
|
+
appendFileSync(
|
|
44
|
+
eventsPath,
|
|
45
|
+
`${JSON.stringify({ event: name, ts: new Date().toISOString(), ...data })}\n`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function outbox(
|
|
50
|
+
name: string,
|
|
51
|
+
summary: string,
|
|
52
|
+
data: Record<string, unknown> = {},
|
|
53
|
+
delivery = "log",
|
|
54
|
+
level = "info",
|
|
55
|
+
): void {
|
|
56
|
+
appendFileSync(
|
|
57
|
+
outboxPath,
|
|
58
|
+
`${JSON.stringify({ body: data, data, delivery, event: name, from: `run:${meta.run}`, level, summary, to: "coordinator", ts: new Date().toISOString(), type: name })}\n`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function progress(phase: string, extra: Record<string, unknown> = {}): void {
|
|
63
|
+
writeJsonAtomic(progressPath, {
|
|
64
|
+
phase,
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
...extra,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let activeSubagents = 0;
|
|
71
|
+
let completedSubagents = 0;
|
|
72
|
+
const subagentFailures: Record<string, unknown>[] = [];
|
|
73
|
+
|
|
74
|
+
function getCommandDoneDelivery(result: { code: number }): string {
|
|
75
|
+
return result.code !== 0 || activeSubagents > 0 ? "followup" : "log";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function progressRunning(): void {
|
|
79
|
+
progress("running", {
|
|
80
|
+
activeSubagents,
|
|
81
|
+
completed: completedSubagents,
|
|
82
|
+
failures: subagentFailures,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function observedExec(command: string, args: string[], options?: ToolExecOptions) {
|
|
87
|
+
const commandDetail = formatCommandDetail(command, args);
|
|
88
|
+
const execArgs = appendRecipeContextToPiArgs(
|
|
89
|
+
command,
|
|
90
|
+
args,
|
|
91
|
+
meta.recipe_context_records,
|
|
92
|
+
options?.actorRecipeContext,
|
|
93
|
+
);
|
|
94
|
+
activeSubagents += 1;
|
|
95
|
+
event("command.start", { activeSubagents, command: commandDetail });
|
|
96
|
+
progressRunning();
|
|
97
|
+
const result = await execCommandTemplate(command, execArgs, options);
|
|
98
|
+
activeSubagents = Math.max(0, activeSubagents - 1);
|
|
99
|
+
completedSubagents += 1;
|
|
100
|
+
if (result.code !== 0) {
|
|
101
|
+
subagentFailures.push({
|
|
102
|
+
code: result.code,
|
|
103
|
+
command: commandDetail,
|
|
104
|
+
killed: result.killed,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
event("command.done", {
|
|
108
|
+
activeSubagents,
|
|
109
|
+
code: result.code,
|
|
110
|
+
command: commandDetail,
|
|
111
|
+
killed: result.killed,
|
|
112
|
+
});
|
|
113
|
+
outbox(
|
|
114
|
+
"command.done",
|
|
115
|
+
`Command ${summarizeCommandDetail(commandDetail)} completed with code ${result.code}`,
|
|
116
|
+
{
|
|
117
|
+
activeSubagents,
|
|
118
|
+
...(meta.artifacts ? { artifacts: meta.artifacts } : {}),
|
|
119
|
+
run_files: [stdoutPath, stderrPath, resultPath, eventsPath, outboxPath],
|
|
120
|
+
code: result.code,
|
|
121
|
+
command: commandDetail,
|
|
122
|
+
killed: result.killed,
|
|
123
|
+
},
|
|
124
|
+
getCommandDoneDelivery(result),
|
|
125
|
+
result.code === 0 ? "info" : "error",
|
|
126
|
+
);
|
|
127
|
+
progressRunning();
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
event("run.runner.start", { pid: process.pid });
|
|
133
|
+
progressRunning();
|
|
134
|
+
const result = await executeRegisteredTool(
|
|
135
|
+
{
|
|
136
|
+
name: "run_actor",
|
|
137
|
+
description: "Detached command-template run actor",
|
|
138
|
+
template: meta.template,
|
|
139
|
+
args: [],
|
|
140
|
+
defaults: {},
|
|
141
|
+
},
|
|
142
|
+
meta.values || {},
|
|
143
|
+
observedExec,
|
|
144
|
+
meta.cwd,
|
|
145
|
+
);
|
|
146
|
+
const text = result.content?.[0]?.text || "";
|
|
147
|
+
appendFileSync(stdoutPath, text);
|
|
148
|
+
writeJsonAtomic(resultPath, {
|
|
149
|
+
code: result.details.code,
|
|
150
|
+
command: result.details.command,
|
|
151
|
+
killed: result.details.killed,
|
|
152
|
+
truncated: result.details.truncated,
|
|
153
|
+
completedAt: new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
progress("done", {
|
|
156
|
+
completed: 1,
|
|
157
|
+
failures: result.details.nonCriticalFailures || [],
|
|
158
|
+
});
|
|
159
|
+
event("run.done", { code: result.details.code });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
appendFileSync(stderrPath, `${message}\n`);
|
|
163
|
+
writeJsonAtomic(resultPath, {
|
|
164
|
+
code: 1,
|
|
165
|
+
error: message,
|
|
166
|
+
killed: false,
|
|
167
|
+
completedAt: new Date().toISOString(),
|
|
168
|
+
});
|
|
169
|
+
progress("failed", { completed: 0, failures: [{ message }] });
|
|
170
|
+
event("run.failed", { error: message });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
package/lib/async-runs.ts
CHANGED
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
} from "./command-templates.ts";
|
|
34
34
|
import { substituteCommandTemplateToken } from "./command-templates.ts";
|
|
35
35
|
import { writeJsonAtomic } from "./file-state.ts";
|
|
36
|
+
import { readJsonFileResilient, readJsonlFileResilient } from "./state-readers.ts";
|
|
36
37
|
import * as Paths from "./paths.ts";
|
|
37
38
|
import * as RecipeReferences from "./recipe-references.ts";
|
|
38
39
|
import * as RecipeUsage from "./recipe-usage.ts";
|
|
@@ -345,11 +346,10 @@ function resolveStartParams(params: AsyncRunStartParams): AsyncRunStartParams {
|
|
|
345
346
|
}
|
|
346
347
|
|
|
347
348
|
function readJson(path: string): Record<string, unknown> | undefined {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
349
|
+
return readJsonFileResilient<Record<string, unknown> | undefined>(
|
|
350
|
+
path,
|
|
351
|
+
undefined,
|
|
352
|
+
).value;
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
function isAlive(pid: number): boolean {
|
|
@@ -392,16 +392,12 @@ function tailLines(path: string, lines: number): string[] {
|
|
|
392
392
|
function getInterruptedRunStatus(
|
|
393
393
|
stateDir: string,
|
|
394
394
|
): "cancelled" | "killed" | undefined {
|
|
395
|
-
const events =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (event.event === "run.cancel") return "cancelled";
|
|
402
|
-
} catch {
|
|
403
|
-
// Ignore malformed event lines.
|
|
404
|
-
}
|
|
395
|
+
const events = readJsonlFileResilient<Record<string, unknown>>(
|
|
396
|
+
join(stateDir, "events.jsonl"),
|
|
397
|
+
).records.slice(-200);
|
|
398
|
+
for (const event of events.reverse()) {
|
|
399
|
+
if (event.event === "run.kill") return "killed";
|
|
400
|
+
if (event.event === "run.cancel") return "cancelled";
|
|
405
401
|
}
|
|
406
402
|
return undefined;
|
|
407
403
|
}
|
|
@@ -913,13 +909,7 @@ function parseRunInboxLine(line: string): RunInboxMessage | undefined {
|
|
|
913
909
|
}
|
|
914
910
|
|
|
915
911
|
function readRunInboxMessagesFromStateDir(stateDir: string): RunInboxMessage[] {
|
|
916
|
-
|
|
917
|
-
if (!existsSync(file)) return [];
|
|
918
|
-
return readFileSync(file, "utf8")
|
|
919
|
-
.split("\n")
|
|
920
|
-
.filter((line) => line.trim())
|
|
921
|
-
.map(parseRunInboxLine)
|
|
922
|
-
.filter((message): message is RunInboxMessage => Boolean(message));
|
|
912
|
+
return readJsonlFileResilient<RunInboxMessage>(runInboxFile(stateDir)).records;
|
|
923
913
|
}
|
|
924
914
|
|
|
925
915
|
function writeRunInboxMessages(
|