pi-crew 0.10.3 → 0.10.4
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 +2 -1
- package/README.md +5 -1
- package/dist/index.mjs +10685 -6882
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +8 -3
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +1 -0
- package/skills/real-test-pi-crew/SKILL.md +153 -6
- package/src/config/migration-validator.ts +113 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +3 -7
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/handle-settings.ts +4 -1
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +5 -0
- package/src/extension/team-tool.ts +6 -14
- package/src/hooks/registry.ts +3 -0
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/runtime/background-runner.ts +30 -35
- package/src/runtime/broker/crew-broker.ts +112 -441
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi.ts +15 -0
- package/src/runtime/finalize-run.ts +15 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/model/pi-args.ts +6 -1
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +33 -0
- package/src/runtime/team-runner.ts +3 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +5 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/settings-overlay.ts +4 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* delegate-event.ts — Event recording for the broker delegate surface.
|
|
3
|
+
*
|
|
4
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
5
|
+
* recordDelegateEvent uses ONLY the module-scoped event-log + error helpers;
|
|
6
|
+
* no broker state. Promoted to a top-level function so the class method is
|
|
7
|
+
* a 1-line delegation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { appendEventAsync } from "../../../state/event-log/event-log.ts";
|
|
11
|
+
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
12
|
+
|
|
13
|
+
/** Fire-and-forget async append; an append failure is logged, never thrown
|
|
14
|
+
* (broker handlers must not block the event loop on the sync event-log lock).
|
|
15
|
+
* See `crew-broker.delegate.event` event scope conventions in ADR-5 §10. */
|
|
16
|
+
export function recordDelegateEvent(
|
|
17
|
+
manifest: { eventsPath: string; runId: string },
|
|
18
|
+
type:
|
|
19
|
+
| "delegate.requested"
|
|
20
|
+
| "delegate.admitted"
|
|
21
|
+
| "delegate.rejected"
|
|
22
|
+
| "delegate.completed"
|
|
23
|
+
| "delegate.timed_out"
|
|
24
|
+
| "delegate.rolled_up",
|
|
25
|
+
taskId: string,
|
|
26
|
+
data: Record<string, unknown>,
|
|
27
|
+
): void {
|
|
28
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
29
|
+
type,
|
|
30
|
+
runId: manifest.runId,
|
|
31
|
+
taskId,
|
|
32
|
+
message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
|
|
33
|
+
data,
|
|
34
|
+
}).catch((err) =>
|
|
35
|
+
logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mailbox-fanout.ts — Phase 1.3 mailbox-event fanout helper.
|
|
3
|
+
*
|
|
4
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
5
|
+
* Operates only on the connectionsByRun index + writeOrQueue/enqueueFrame
|
|
6
|
+
* functions passed in. Promoted to a top-level function so the class
|
|
7
|
+
* method is a 1-line delegation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { MailboxMessage } from "../../../state/coordination/mailbox.ts";
|
|
11
|
+
import { encodeBrokerFrame } from "../../../utils/ndjson.ts";
|
|
12
|
+
import type { ServerConnection } from "../protocol/connection-state.ts";
|
|
13
|
+
|
|
14
|
+
export interface FanoutWriters {
|
|
15
|
+
writeOrQueue(conn: ServerConnection, buf: Buffer, force: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Phase 1.3: push a durable-appended mailbox message to any connected
|
|
19
|
+
* recipient for the message's run. Best-effort — silently skips
|
|
20
|
+
* recipients that are offline (they recover via msg.inbox). Never throws. */
|
|
21
|
+
export function fanoutMailboxMessage(
|
|
22
|
+
connectionsByRun: Map<string, Set<ServerConnection>>,
|
|
23
|
+
writers: FanoutWriters,
|
|
24
|
+
msg: MailboxMessage,
|
|
25
|
+
): void {
|
|
26
|
+
const set = connectionsByRun.get(msg.runId);
|
|
27
|
+
if (!set || set.size === 0) return;
|
|
28
|
+
// Recipient delivery dedup lives in src/prompt/prompt-runtime.ts and is
|
|
29
|
+
// keyed by the same message id in this mailbox event and the steering JSONL.
|
|
30
|
+
const eventFrame = encodeBrokerFrame({
|
|
31
|
+
event: "mailbox.message",
|
|
32
|
+
data: {
|
|
33
|
+
id: msg.id,
|
|
34
|
+
from: msg.from,
|
|
35
|
+
to: msg.to,
|
|
36
|
+
body: msg.body,
|
|
37
|
+
kind: msg.kind,
|
|
38
|
+
priority: msg.priority,
|
|
39
|
+
},
|
|
40
|
+
seq: 0, // mailbox messages don't carry a TeamEvent seq; dedup by msg.id
|
|
41
|
+
});
|
|
42
|
+
for (const conn of set) {
|
|
43
|
+
if (conn.closed || !conn.authed) continue;
|
|
44
|
+
// Recipient filter: deliver to the addressed task, or to all if 'all'.
|
|
45
|
+
// Task 5b (§15.2 wake): "parent"-addressed messages land in the
|
|
46
|
+
// run-level inbox, whose live consumer is the run's orchestrator
|
|
47
|
+
// connection (role from the orchestrator token — its taskId never
|
|
48
|
+
// equals "parent"), so without this branch the wake frame would be
|
|
49
|
+
// filtered out and the orchestrator would only see the message on
|
|
50
|
+
// its next inbox poll.
|
|
51
|
+
const isRecipient = !msg.to || msg.to === "all" || conn.taskId === msg.to || (msg.to === "parent" && conn.role === "orchestrator");
|
|
52
|
+
if (!isRecipient) continue;
|
|
53
|
+
try {
|
|
54
|
+
writers.writeOrQueue(conn, eventFrame, false);
|
|
55
|
+
} catch {
|
|
56
|
+
/* a slow/dead recipient must not break fanout to others */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connection-state.ts — Type definitions for crew-broker connection state
|
|
3
|
+
* and broker options.
|
|
4
|
+
*
|
|
5
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
6
|
+
* These are pure types/interfaces used by the CrewBroker class but they
|
|
7
|
+
* contribute ~80 lines to the file's line count. Splitting them out
|
|
8
|
+
* contributes to the ≤2000-line gate (spec §5 M4 acceptance).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type * as net from "node:net";
|
|
12
|
+
import type { NdjsonDecoder } from "../../../utils/ndjson.ts";
|
|
13
|
+
import type { GrandchildSpawnInput, GrandchildSpawnResult } from "../../delegate-spawn.ts";
|
|
14
|
+
import type { WaitStatusCache } from "../wait-status-cache.ts";
|
|
15
|
+
|
|
16
|
+
export interface CrewBrokerOptions {
|
|
17
|
+
/** Root session ID used to derive the socket path. */
|
|
18
|
+
sessionId: string;
|
|
19
|
+
/** Pre-resolved socket path (skips re-derivation; useful for tests). */
|
|
20
|
+
socketPath?: string;
|
|
21
|
+
/** Frame cap in UTF-8 bytes. Default 256 KiB. */
|
|
22
|
+
maxFrameBytes?: number;
|
|
23
|
+
/** Per-connection outbound queue cap. Default 256. */
|
|
24
|
+
outboundQueueCap?: number;
|
|
25
|
+
/** Required: when false, start() is a no-op and the server never binds.
|
|
26
|
+
* Lets the lifecycle controller install the broker unconditionally and
|
|
27
|
+
* have a single kill switch. */
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
/** CWD for `loadRunManifestById` (Phase 1 msg.send / msg.inbox resolution).
|
|
30
|
+
* When omitted, manifest-touching methods return no-manifest errors. */
|
|
31
|
+
cwd?: string;
|
|
32
|
+
/** Optional test seam: override the `net` module (allows fake-server tests). */
|
|
33
|
+
netModule?: typeof net;
|
|
34
|
+
/** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
|
|
35
|
+
* wrapping a loader spy). Production uses a plain cache — see
|
|
36
|
+
* wait-status-cache.ts (R10-3). */
|
|
37
|
+
waitStatusCache?: WaitStatusCache;
|
|
38
|
+
/** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
|
|
39
|
+
* gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
|
|
40
|
+
* explicitly true, wait.request/wait.resolve are rejected with a
|
|
41
|
+
* `policy-disabled` error AND a `policy.action` event is appended to the
|
|
42
|
+
* run's events.jsonl (never silent). The production wiring threads
|
|
43
|
+
* `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
|
|
44
|
+
waitMethodsEnabled?: boolean;
|
|
45
|
+
/** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
|
|
46
|
+
* TRUE since D8 (spec v0.7) — nested spawning is open out of the box; the
|
|
47
|
+
* broker still fail-closes when the flag is anything but true. The
|
|
48
|
+
* production wiring threads `config.nesting.enabled` (loadConfig layers
|
|
49
|
+
* DEFAULT_NESTING.enabled=true; a user `false` closes the surface); tests
|
|
50
|
+
* pass it explicitly. Rejections are NEVER silent (delegate.rejected). */
|
|
51
|
+
nestingEnabled?: boolean;
|
|
52
|
+
/** Optional override for the nested-slot budget size (config nesting.maxSlots). */
|
|
53
|
+
nestingMaxSlots?: number;
|
|
54
|
+
nestingMaxDepth?: number;
|
|
55
|
+
nestingTrustedEscalation?: boolean;
|
|
56
|
+
/** Global worker semaphore size, used to size the nested-slot budget. */
|
|
57
|
+
globalWorkerSemaphore?: number;
|
|
58
|
+
/** Test seam / alternative spawner for delegate grandchildren. Production
|
|
59
|
+
* uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
|
|
60
|
+
grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
|
|
61
|
+
/** Resolved model catalog (canonical provider/id strings) for admission-time
|
|
62
|
+
* model validation (ADR-5 §7). When omitted, model validation is skipped
|
|
63
|
+
* (documented gap — the production wiring must always supply it). */
|
|
64
|
+
modelCatalog?: () => string[] | undefined;
|
|
65
|
+
/** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
|
|
66
|
+
* admission gate. Default false. */
|
|
67
|
+
serializeOnPathOverlap?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per-connection server-side state. */
|
|
71
|
+
export interface ServerConnection {
|
|
72
|
+
socket: net.Socket;
|
|
73
|
+
decoder: NdjsonDecoder;
|
|
74
|
+
/** Whether the connection has completed `hello` successfully. */
|
|
75
|
+
authed: boolean;
|
|
76
|
+
/** Run id bound by hello. */
|
|
77
|
+
runId?: string;
|
|
78
|
+
/** Task id bound by hello. */
|
|
79
|
+
taskId?: string;
|
|
80
|
+
/** Role bound by hello: orchestrator can steer/msg-send; workers default. */
|
|
81
|
+
role?: "orchestrator" | "worker";
|
|
82
|
+
/** How the hello token matched the registry (ADR-0 item 6). Derived,
|
|
83
|
+
* non-secret metadata recorded at hello time so `wait.*` can reject a
|
|
84
|
+
* legacy bare-runId fallback match WITHOUT keeping the raw token on the
|
|
85
|
+
* connection (tokens stay confined to the heap-only registry). */
|
|
86
|
+
authMatchKind?: "compound" | "runId-fallback";
|
|
87
|
+
/** Task 10 fix round 2 (BUG #3): sha256 of the secret this connection
|
|
88
|
+
* authenticated with. Derived, one-way — never the plaintext token. The
|
|
89
|
+
* post-hello revocation check evaluates THIS digest so a revoke →
|
|
90
|
+
* re-issue window cannot let an old connection ride the freshly issued
|
|
91
|
+
* token for the same key. */
|
|
92
|
+
authedSecretHash?: string;
|
|
93
|
+
/** Outbound queue of encoded frames awaiting drain. */
|
|
94
|
+
outbound: Buffer[];
|
|
95
|
+
/** Set when the queue has hit the cap and a frame was dropped. */
|
|
96
|
+
needsResync: boolean;
|
|
97
|
+
/** Set when the connection is closing (idempotent). */
|
|
98
|
+
closed: boolean;
|
|
99
|
+
/** Timer for the hello deadline. */
|
|
100
|
+
helloTimer: NodeJS.Timeout | null;
|
|
101
|
+
/** Monotonic seq counter for outbound events (diagnostic). */
|
|
102
|
+
outboundSeq: number;
|
|
103
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* events-replay.ts — Phase 1.5 events.since handler (label corrected
|
|
3
|
+
* 2026-09-10 per review F4 — "Phase 2" is events.subscribe). Used by clients
|
|
4
|
+
* to resync after a missed live frame (e.g. after a queue overflow or
|
|
5
|
+
* reconnect).
|
|
6
|
+
*
|
|
7
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
8
|
+
* Operates only on the connection + a writers adapter (sendError/sendResult).
|
|
9
|
+
* Promoted to a top-level function so the class method is a 1-line delegation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readEventsCursor } from "../../../state/event-log/event-log.ts";
|
|
13
|
+
import { loadRunManifestById } from "../../../state/stores/state-store.ts";
|
|
14
|
+
import type { ServerConnection } from "./connection-state.ts";
|
|
15
|
+
|
|
16
|
+
export interface EventWriterHelpers {
|
|
17
|
+
sendError(conn: ServerConnection, id: string, code: string, message: string): void;
|
|
18
|
+
sendResult(conn: ServerConnection, id: string, result: unknown): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Phase 2: events.since — page-replay handler. Returns events with
|
|
22
|
+
* seq > sinceSeq from the durable log, capped to `limit` (default 1000). */
|
|
23
|
+
export async function handleEventsSince(
|
|
24
|
+
conn: ServerConnection,
|
|
25
|
+
id: string,
|
|
26
|
+
params: unknown,
|
|
27
|
+
helpers: EventWriterHelpers,
|
|
28
|
+
cwd: string | undefined,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
if (!conn.runId) {
|
|
31
|
+
helpers.sendError(conn, id, "auth", "not authed");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!cwd) {
|
|
35
|
+
helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let eventsPath: string;
|
|
39
|
+
try {
|
|
40
|
+
const loaded = loadRunManifestById(cwd, conn.runId);
|
|
41
|
+
if (!loaded) {
|
|
42
|
+
helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
eventsPath = loaded.manifest.eventsPath;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
helpers.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const v = params && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : {};
|
|
51
|
+
const sinceSeq = typeof v.sinceSeq === "number" && Number.isFinite(v.sinceSeq) ? Math.max(0, Math.floor(v.sinceSeq)) : 0;
|
|
52
|
+
const limit = typeof v.limit === "number" && Number.isFinite(v.limit) ? Math.min(Math.max(1, Math.floor(v.limit)), 1000) : 1000;
|
|
53
|
+
try {
|
|
54
|
+
const result = readEventsCursor(eventsPath, { sinceSeq, limit });
|
|
55
|
+
// hasMore is true iff the total filtered count exceeds the page we
|
|
56
|
+
// returned. When `total === events.length` we are at the exact end
|
|
57
|
+
// of the stream (caller will discover this on the next call when
|
|
58
|
+
// `nextSeq` is unchanged from `sinceSeq`).
|
|
59
|
+
const hasMore = result.total > result.events.length;
|
|
60
|
+
helpers.sendResult(conn, id, {
|
|
61
|
+
events: result.events,
|
|
62
|
+
nextSeq: result.nextSeq,
|
|
63
|
+
hasMore,
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
helpers.sendError(conn, id, "replay-failed", (err as Error).message);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manifest-loader.ts — Helper for loading run manifests during hello.
|
|
3
|
+
*
|
|
4
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
5
|
+
* The CrewBroker.loadRunForHello method delegated entirely to
|
|
6
|
+
* loadRunManifestById after one safety check (cwd absence); the helper is
|
|
7
|
+
* now a top-level function the class method delegates to in 1 line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { loadRunManifestById } from "../../../state/stores/state-store.ts";
|
|
11
|
+
import type { TeamRunManifest, TeamTaskState } from "../../../state/types.ts";
|
|
12
|
+
|
|
13
|
+
export function loadRunForHello(cwd: string | undefined, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
|
|
14
|
+
if (!cwd) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
return loadRunManifestById(cwd, runId) ?? undefined;
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* msg-inbox.ts — Phase 1.2 msg.inbox broker handler (Phase 1.1 = msg.send;
|
|
3
|
+
* label corrected 2026-09-10 per review F3 — it was mislabeled 1.1 at extraction).
|
|
4
|
+
*
|
|
5
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
6
|
+
* Operates on a connection + writers adapter + cwd. Top-level function so
|
|
7
|
+
* the class method is a 1-line delegation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readMailbox } from "../../../state/coordination/mailbox.ts";
|
|
11
|
+
import { loadRunManifestById } from "../../../state/stores/state-store.ts";
|
|
12
|
+
import type { ServerConnection } from "./connection-state.ts";
|
|
13
|
+
import { parseMsgInboxParams } from "./request-parsers.ts";
|
|
14
|
+
|
|
15
|
+
export interface MsgInboxHelpers {
|
|
16
|
+
sendError(conn: ServerConnection, id: string, code: string, message: string): void;
|
|
17
|
+
sendResult(conn: ServerConnection, id: string, result: unknown): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Phase 1.1: read the durable mailbox for the addressed task (or run-level
|
|
21
|
+
* "inbox" lane) with cursor/limit pagination. Returns messages whose status
|
|
22
|
+
* is NOT yet `acknowledged`; callers should ack after processing. */
|
|
23
|
+
export async function handleMsgInbox(
|
|
24
|
+
conn: ServerConnection,
|
|
25
|
+
id: string,
|
|
26
|
+
params: unknown,
|
|
27
|
+
helpers: MsgInboxHelpers,
|
|
28
|
+
cwd: string | undefined,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
if (!conn.runId) {
|
|
31
|
+
helpers.sendError(conn, id, "auth", "not authed");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const parsed = parseMsgInboxParams(params);
|
|
35
|
+
if (!parsed) {
|
|
36
|
+
helpers.sendError(conn, id, "bad-params", "msg.inbox: invalid params");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!cwd) {
|
|
40
|
+
helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
let manifest: Parameters<typeof readMailbox>[0];
|
|
44
|
+
try {
|
|
45
|
+
const loaded = loadRunManifestById(cwd, conn.runId);
|
|
46
|
+
if (!loaded) {
|
|
47
|
+
helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
manifest = loaded.manifest;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
helpers.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const limit = Math.min(Math.max(parsed.limit ?? 100, 1), 1000);
|
|
56
|
+
const taskId = conn.taskId ?? undefined;
|
|
57
|
+
const all = readMailbox(manifest, "inbox", taskId);
|
|
58
|
+
const filtered = all.filter((m) => m.status !== "acknowledged");
|
|
59
|
+
const offset = parsed.cursor ? Number.parseInt(parsed.cursor, 10) || 0 : 0;
|
|
60
|
+
const page = filtered.slice(offset, offset + limit);
|
|
61
|
+
const nextOffset = offset + page.length;
|
|
62
|
+
const hasMore = nextOffset < filtered.length;
|
|
63
|
+
helpers.sendResult(conn, id, {
|
|
64
|
+
messages: page,
|
|
65
|
+
nextCursor: hasMore ? String(nextOffset) : undefined,
|
|
66
|
+
hasMore,
|
|
67
|
+
total: filtered.length,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* request-parsers.ts — Protocol-level parameter parsers and type guards for
|
|
3
|
+
* the crew-broker wire protocol.
|
|
4
|
+
*
|
|
5
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
6
|
+
* Every parser rejects malformed frames at the boundary so handlers see only
|
|
7
|
+
* typed params. Method-name charset check (^[a-zA-Z][a-zA-Z0-9._-]{0,63}$)
|
|
8
|
+
* guards against control chars / oversized names reaching the dispatcher.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MailboxMessageKind, MailboxMessagePriority } from "../../../state/coordination/mailbox.ts";
|
|
12
|
+
|
|
13
|
+
/** Protocol version negotiated at `hello` time. Bump on breaking change. */
|
|
14
|
+
export const BROKER_PROTOCOL = 1;
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Type guards (no `any`)
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export function isRequestObject(value: unknown): value is { id: string; method: string; params: unknown } {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
22
|
+
const v = value as Record<string, unknown>;
|
|
23
|
+
if (typeof v.id !== "string" || v.id.length === 0 || v.id.length > 256) return false;
|
|
24
|
+
if (typeof v.method !== "string" || v.method.length === 0 || v.method.length > 64) return false;
|
|
25
|
+
// Method names are restricted to a small safe charset. This guards against
|
|
26
|
+
// odd inputs (control chars, very long names) reaching the dispatcher.
|
|
27
|
+
if (!/^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(v.method)) return false;
|
|
28
|
+
// params may be anything (validated per-method), but not undefined-shaped.
|
|
29
|
+
return "params" in v;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isHelloParams(value: unknown): value is {
|
|
33
|
+
protocol: number;
|
|
34
|
+
runId: string;
|
|
35
|
+
taskId: string;
|
|
36
|
+
token: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
} {
|
|
39
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
40
|
+
const v = value as Record<string, unknown>;
|
|
41
|
+
if (v.protocol !== BROKER_PROTOCOL) {
|
|
42
|
+
// Force exact-type comparison (must be the number 1, not "1").
|
|
43
|
+
if (typeof v.protocol !== "number" || !Number.isInteger(v.protocol)) return false;
|
|
44
|
+
}
|
|
45
|
+
if (typeof v.runId !== "string" || v.runId.length === 0 || v.runId.length > 256) return false;
|
|
46
|
+
if (typeof v.taskId !== "string" || v.taskId.length === 0 || v.taskId.length > 256) return false;
|
|
47
|
+
if (typeof v.token !== "string" || v.token.length === 0 || v.token.length > 256) return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Phase 1 parameter parsers (module-level; no `any`)
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
export interface MsgSendParams {
|
|
56
|
+
to: string | string[] | "all";
|
|
57
|
+
body: unknown;
|
|
58
|
+
kind?: MailboxMessageKind;
|
|
59
|
+
priority?: MailboxMessagePriority;
|
|
60
|
+
replyTo?: string;
|
|
61
|
+
/** Task 5b (§15.2): short subject echoed into the worker.message wake
|
|
62
|
+
* event (bounded like the tool-side MSG_SUBJECT_MAX_CHARS). */
|
|
63
|
+
subject?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
|
|
67
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
68
|
+
const v = value as Record<string, unknown>;
|
|
69
|
+
const to = v.to;
|
|
70
|
+
if (typeof to !== "string" && !Array.isArray(to)) return undefined;
|
|
71
|
+
if (Array.isArray(to) && !to.every((s) => typeof s === "string" && s.length > 0)) return undefined;
|
|
72
|
+
if (typeof to === "string" && to.length === 0) return undefined;
|
|
73
|
+
if (v.body === undefined) return undefined;
|
|
74
|
+
const kind = v.kind as MailboxMessageKind | undefined;
|
|
75
|
+
if (kind !== undefined && !["message", "notify", "steer", "follow-up", "response", "group_join"].includes(kind)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const priority = v.priority as MailboxMessagePriority | undefined;
|
|
79
|
+
if (priority !== undefined && !["urgent", "normal", "low"].includes(priority)) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const replyTo = typeof v.replyTo === "string" ? v.replyTo : undefined;
|
|
83
|
+
const subject = typeof v.subject === "string" && v.subject.length > 0 && v.subject.length <= 256 ? v.subject : undefined;
|
|
84
|
+
return { to: to as string | string[] | "all", body: v.body, kind, priority, replyTo, subject };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MsgInboxParams {
|
|
88
|
+
limit?: number;
|
|
89
|
+
cursor?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseMsgInboxParams(value: unknown): MsgInboxParams | undefined {
|
|
93
|
+
if (value === undefined || value === null) return { limit: 100, cursor: undefined };
|
|
94
|
+
if (typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
95
|
+
const v = value as Record<string, unknown>;
|
|
96
|
+
const limit = v.limit;
|
|
97
|
+
if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const cursor = v.cursor;
|
|
101
|
+
if (cursor !== undefined && typeof cursor !== "string") return undefined;
|
|
102
|
+
return { limit: limit as number | undefined, cursor: cursor as string | undefined };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function safeStringify(value: unknown): string {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.stringify(value) ?? "{}";
|
|
108
|
+
} catch {
|
|
109
|
+
return "{}";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
/** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
|
|
118
|
+
* timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
|
|
119
|
+
* amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
|
|
120
|
+
export const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
|
|
121
|
+
/** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
|
|
122
|
+
export const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
|
|
123
|
+
/** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
|
|
124
|
+
export const WAIT_QUESTION_MAX_CHARS = 8192;
|
|
125
|
+
/** Bounded answer-choice list: at most 16 options, 256 chars each. */
|
|
126
|
+
export const WAIT_OPTIONS_MAX = 16;
|
|
127
|
+
export const WAIT_OPTION_MAX_CHARS = 256;
|
|
128
|
+
|
|
129
|
+
export interface WaitRequestParams {
|
|
130
|
+
to: string;
|
|
131
|
+
question: string;
|
|
132
|
+
options?: string[];
|
|
133
|
+
timeoutSec?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
138
|
+
const v = value as Record<string, unknown>;
|
|
139
|
+
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
140
|
+
if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
let options: string[] | undefined;
|
|
144
|
+
if (v.options !== undefined) {
|
|
145
|
+
if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
|
|
146
|
+
for (const o of v.options) {
|
|
147
|
+
if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
|
|
148
|
+
}
|
|
149
|
+
options = v.options as string[];
|
|
150
|
+
}
|
|
151
|
+
// timeoutSec is clamped server-side in the handler (max 3600); the parser
|
|
152
|
+
// only rejects non-finite values. Non-positive values clamp to 1s.
|
|
153
|
+
if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
to: v.to,
|
|
158
|
+
question: v.question,
|
|
159
|
+
options,
|
|
160
|
+
timeoutSec: v.timeoutSec as number | undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface WaitResolveParams {
|
|
165
|
+
to: string;
|
|
166
|
+
questionId: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
|
|
170
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
171
|
+
const v = value as Record<string, unknown>;
|
|
172
|
+
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
173
|
+
if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
|
|
174
|
+
return { to: v.to, questionId: v.questionId };
|
|
175
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wait-auth.ts — Auth + policy-rejection helpers for the broker wait.*
|
|
3
|
+
* surface.
|
|
4
|
+
*
|
|
5
|
+
* Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
|
|
6
|
+
* waitAuthError is pure (operates only on conn metadata). recordWaitPolicyRejection
|
|
7
|
+
* uses module-scoped event-log + internal-error helpers — same as the class
|
|
8
|
+
* method did. Both are now top-level functions; the class delegates to them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendEventAsync } from "../../../state/event-log/event-log.ts";
|
|
12
|
+
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
13
|
+
import type { ServerConnection } from "./connection-state.ts";
|
|
14
|
+
|
|
15
|
+
/** Auth failure for the wait.* methods: worker role + task-scoped (compound)
|
|
16
|
+
* token ONLY (ADR-0 2026-08-17 item 6). Legacy bare-runId fallback match is
|
|
17
|
+
* REJECTED with a migrate hint; orchestrator token is rejected by role. */
|
|
18
|
+
export function waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
|
|
19
|
+
if (conn.role !== "worker" || conn.authMatchKind === undefined) {
|
|
20
|
+
return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
|
|
21
|
+
}
|
|
22
|
+
if (conn.authMatchKind !== "compound") {
|
|
23
|
+
return {
|
|
24
|
+
code: "forbidden",
|
|
25
|
+
message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** ADR-0 item 7: a disabled-gate rejection MUST leave a durable trace in
|
|
32
|
+
* events.jsonl. The gate fails CLOSED but never SILENTLY. Fire-and-forget
|
|
33
|
+
* async append (broker handlers must not block the event loop on the sync
|
|
34
|
+
* event-log lock); an append failure is logged, never thrown. */
|
|
35
|
+
export function recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
|
|
36
|
+
const runId = manifest.runId;
|
|
37
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
38
|
+
type: "policy.action",
|
|
39
|
+
runId,
|
|
40
|
+
taskId,
|
|
41
|
+
message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
|
|
42
|
+
data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
|
|
43
|
+
}).catch((err) =>
|
|
44
|
+
logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -157,6 +157,17 @@ export interface ChildPiRunInput {
|
|
|
157
157
|
onSpawn?: (pid: number) => void;
|
|
158
158
|
/** Structured lifecycle events for durable logging (spawn, crash, timeout, kill, exit). */
|
|
159
159
|
onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
|
|
160
|
+
/** F1 fix (battery 2026-09-10): task-state activity bridge for SURFACE workers.
|
|
161
|
+
* Surface workers have no stdout pipe, so onJsonEvent/onStdoutLine never fire
|
|
162
|
+
* and task.heartbeat/agentProgress starve - the heartbeat-watcher then flags a
|
|
163
|
+
* healthy pane worker dead (~340s) and the stale-reconciler cancels the run
|
|
164
|
+
* (observed live: run team_20260910161234, worker productive 7min in pane
|
|
165
|
+
* w2:p8Z, 0 task.progress events). This callback receives every event tailed
|
|
166
|
+
* from the per-agent recorder log so the CALLER can update task liveness state.
|
|
167
|
+
* Contract: the callback MUST NOT append to the per-agent events file being
|
|
168
|
+
* tailed (watch->append loop - see event-log-tail-source.ts header); task-state
|
|
169
|
+
* persist + run-level task.progress are safe (different files). */
|
|
170
|
+
onSurfaceActivity?: (event: Record<string, unknown>) => void;
|
|
160
171
|
maxDepth?: number;
|
|
161
172
|
finalDrainMs?: number;
|
|
162
173
|
/** F12: early-exit the drain when stdout has been silent for this many ms
|
|
@@ -492,6 +503,10 @@ async function trySurfaceBranch(
|
|
|
492
503
|
}
|
|
493
504
|
const bridgeEvent = bridgeEventFromJsonEvent(runId, taskId, event);
|
|
494
505
|
if (bridgeEvent) runEventBus.emit({ type: "worker_status", runId, taskId, data: bridgeEvent });
|
|
506
|
+
// F1 fix (battery 2026-09-10): feed the same tailed event to the task-state
|
|
507
|
+
// bridge so child-executor can keep task.heartbeat/agentProgress fresh for
|
|
508
|
+
// surface workers (see ChildPiRunInput.onSurfaceActivity doc).
|
|
509
|
+
input.onSurfaceActivity?.(event as Record<string, unknown>);
|
|
495
510
|
});
|
|
496
511
|
}
|
|
497
512
|
let exitInfo: SurfaceExitInfo;
|
|
@@ -19,7 +19,7 @@ import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
|
|
|
19
19
|
import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
|
|
20
20
|
import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
|
|
21
21
|
import { withRunLock } from "../state/coordination/locks.ts";
|
|
22
|
-
import {
|
|
22
|
+
import { appendEventAsync, appendEventBuffered, appendEventFireAndForget, readEvents } from "../state/event-log/event-log.ts";
|
|
23
23
|
import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
|
|
24
24
|
import { HealthStore } from "../state/stores/health-store.ts";
|
|
25
25
|
import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
@@ -211,12 +211,12 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
|
|
|
211
211
|
createdAt: new Date().toISOString(),
|
|
212
212
|
};
|
|
213
213
|
decisions = [...decisions, branchDecision];
|
|
214
|
-
|
|
214
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
215
215
|
type: "branch.stale",
|
|
216
216
|
runId: manifest.runId,
|
|
217
217
|
message: branchFreshness.message,
|
|
218
218
|
data: { branchFreshness },
|
|
219
|
-
});
|
|
219
|
+
}).catch((e) => logInternalError("finalize-run.buffered", e, "type=branch.stale"));
|
|
220
220
|
}
|
|
221
221
|
const policyArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
222
222
|
kind: "metadata",
|
|
@@ -232,15 +232,17 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
|
|
|
232
232
|
content: `${JSON.stringify(recoveryLedger, null, 2)}\n`,
|
|
233
233
|
});
|
|
234
234
|
for (const item of decisions)
|
|
235
|
-
|
|
235
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
236
236
|
type: item.action === "escalate" ? "policy.escalated" : "policy.action",
|
|
237
237
|
runId: manifest.runId,
|
|
238
238
|
taskId: item.taskId,
|
|
239
239
|
message: item.message,
|
|
240
240
|
data: { action: item.action, reason: item.reason },
|
|
241
|
-
})
|
|
241
|
+
}).catch((e) =>
|
|
242
|
+
logInternalError("finalize-run.buffered", e, `type=${item.action === "escalate" ? "policy.escalated" : "policy.action"}`),
|
|
243
|
+
);
|
|
242
244
|
for (const item of recoveryLedger.entries)
|
|
243
|
-
|
|
245
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
244
246
|
type: item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted",
|
|
245
247
|
runId: manifest.runId,
|
|
246
248
|
taskId: item.taskId,
|
|
@@ -251,7 +253,13 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
|
|
|
251
253
|
attempt: item.attempt,
|
|
252
254
|
state: item.state,
|
|
253
255
|
},
|
|
254
|
-
})
|
|
256
|
+
}).catch((e) =>
|
|
257
|
+
logInternalError(
|
|
258
|
+
"finalize-run.buffered",
|
|
259
|
+
e,
|
|
260
|
+
`type=${item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted"}`,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
255
263
|
return {
|
|
256
264
|
...manifest,
|
|
257
265
|
updatedAt: new Date().toISOString(),
|