pi-crew 0.11.5 → 0.11.7
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/CHANGELOG.md +165 -0
- package/README.md +1 -1
- package/dist/index.mjs +503 -493
- package/docs/dynamic-workflows.md +37 -0
- package/package.json +13 -9
- package/scripts/analyze-run.mjs +95 -11
- package/skills/model-routing-context/SKILL.md +4 -3
- package/skills/real-test-pi-crew/SKILL.md +9 -6
- package/src/config/config-validation.ts +28 -0
- package/src/config/env-vars.ts +29 -0
- package/src/config/types.ts +47 -0
- package/src/extension/crew-vibes/index.ts +29 -10
- package/src/extension/pi-api.ts +1 -0
- package/src/extension/registration/lifecycle-handlers.ts +65 -3
- package/src/extension/team-tool/respond.ts +54 -0
- package/src/extension/team-tool/status.ts +10 -0
- package/src/extension/team-tool.ts +393 -3
- package/src/extension/webhook-notify.ts +23 -1
- package/src/observability/exporters/otlp-exporter.ts +93 -11
- package/src/prompt/prompt-runtime.ts +46 -8
- package/src/runtime/agent-control.ts +1 -15
- package/src/runtime/background-runner.ts +3 -0
- package/src/runtime/broker/crew-broker.ts +12 -103
- package/src/runtime/broker/protocol/request-parsers.ts +9 -1
- package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +12 -4
- package/src/runtime/child-pi/child-pi.ts +93 -4
- package/src/runtime/child-pi/session-recovery.ts +334 -0
- package/src/runtime/dispatch-batch.ts +3 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
- package/src/runtime/live-session/live-agent-manager.ts +35 -0
- package/src/runtime/live-session/live-session-runtime.ts +34 -5
- package/src/runtime/mcp-proxy.ts +110 -7
- package/src/runtime/model/model-scope.ts +141 -21
- package/src/runtime/model/pi-args.ts +115 -11
- package/src/runtime/model/provider-extensions.ts +67 -1
- package/src/runtime/peer-dep.ts +24 -0
- package/src/runtime/rpc/README.md +100 -0
- package/src/runtime/rpc/frame-client.ts +328 -0
- package/src/runtime/rpc/rpc-worker.ts +151 -0
- package/src/runtime/rpc/ui-request-policy.ts +112 -0
- package/src/runtime/run-worker.ts +14 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
- package/src/runtime/stale-reconciler.ts +47 -0
- package/src/runtime/surface/surface-spawn.ts +8 -1
- package/src/runtime/task-output-context.ts +191 -32
- package/src/runtime/task-packet.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +170 -6
- package/src/runtime/task-runner/output-splitter.ts +48 -5
- package/src/runtime/task-runner/post-execution.ts +5 -5
- package/src/runtime/task-runner/pre-execution.ts +32 -4
- package/src/runtime/task-runner/prompt-builder.ts +10 -2
- package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
- package/src/schema/config-schema.ts +25 -0
- package/src/schema/team-tool-schema.ts +35 -0
- package/src/state/contracts.ts +16 -0
- package/src/state/crew-init.ts +114 -0
- package/src/state/stores/active-run-registry.ts +13 -6
- package/src/state/types.ts +0 -8
- package/src/ui/goal-flag.ts +44 -0
- package/src/ui/live-run-sidebar.ts +5 -1
- package/src/ui/pi-ui-compat.ts +37 -11
- package/src/ui/run-dashboard.ts +2 -1
- package/src/ui/widget/index.ts +0 -1
- package/src/ui/widget/widget-model.ts +6 -2
- package/src/utils/ip-literal.ts +145 -0
- package/src/workflows/workflow-config.ts +6 -0
|
@@ -16,6 +16,7 @@ import { pollWorkerInbox } from "./inbox-poll.ts";
|
|
|
16
16
|
import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
|
|
17
17
|
import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
|
|
18
18
|
import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
|
|
19
|
+
import { createWorkerEventsChannel } from "./worker-events-channel.ts";
|
|
19
20
|
|
|
20
21
|
export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
|
|
21
22
|
export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
|
|
@@ -621,10 +622,19 @@ function findAskResponse(manifest: TeamRunManifest, questionId: string): Mailbox
|
|
|
621
622
|
* report — best-effort wait.resolve on EVERY terminal path (answered /
|
|
622
623
|
* timed-out / aborted). A rejected resolve leaves the task parked; the
|
|
623
624
|
* scheduler's TTL leak-guard (stale-reconciler) is the backstop, so the
|
|
624
|
-
* failure is logged, never thrown.
|
|
625
|
-
|
|
625
|
+
* failure is logged, never thrown.
|
|
626
|
+
* G5 (deep-review 2026-10-01): the terminal outcome rides the resolve frame
|
|
627
|
+
* so the broker can emit `task.respond_delivered` ONLY when the worker
|
|
628
|
+
* actually picked up the leader's mailbox response ("answered") — a
|
|
629
|
+
* timed-out/aborted park was NOT a delivery and must not masquerade as one. */
|
|
630
|
+
async function resolvePark(
|
|
631
|
+
client: AskBrokerClientSurface,
|
|
632
|
+
taskId: string,
|
|
633
|
+
questionId: string,
|
|
634
|
+
outcome: "answered" | "timed-out" | "aborted",
|
|
635
|
+
): Promise<void> {
|
|
626
636
|
try {
|
|
627
|
-
const res = await client.request("wait.resolve", { to: taskId, questionId });
|
|
637
|
+
const res = await client.request("wait.resolve", { to: taskId, questionId, outcome });
|
|
628
638
|
if (!res.ok) {
|
|
629
639
|
logInternalError(
|
|
630
640
|
"prompt-runtime.ask-wait-resolve",
|
|
@@ -644,10 +654,11 @@ async function resolvePark(client: AskBrokerClientSurface, taskId: string, quest
|
|
|
644
654
|
}
|
|
645
655
|
|
|
646
656
|
/** ADR item 10: every ask-timeout outcome appends `ask.timedout` to the run's
|
|
647
|
-
* events.jsonl.
|
|
648
|
-
*
|
|
649
|
-
* the
|
|
650
|
-
*
|
|
657
|
+
* events.jsonl. WP-9 made PI_CREW_EVENTS_PATH unconditional (this comment
|
|
658
|
+
* used to say it was scratchpad-gated — stale); this path predates that and
|
|
659
|
+
* keeps deriving the file from the state store invariant eventsPath ===
|
|
660
|
+
* <stateRoot>/events.jsonl — the same invariant child-pi-spawn relies on to
|
|
661
|
+
* derive PI_CREW_STATE_ROOT. Fire-and-forget: never blocks the tool result. */
|
|
651
662
|
function emitAskTimedOutEvent(stateRoot: string, runId: string, taskId: string, questionId: string): void {
|
|
652
663
|
appendEventFireAndForget(path.join(stateRoot, "events.jsonl"), {
|
|
653
664
|
type: "ask.timedout",
|
|
@@ -774,7 +785,7 @@ export function createAskTool(deps: AskToolDeps = {}): AskToolDefinition {
|
|
|
774
785
|
}
|
|
775
786
|
const waitedMs = now() - startedAt;
|
|
776
787
|
// Terminal report (ADR item 8): best-effort un-park on EVERY path.
|
|
777
|
-
await resolvePark(client, taskId, questionId);
|
|
788
|
+
await resolvePark(client, taskId, questionId, terminal);
|
|
778
789
|
if (terminal === "answered" && answer) {
|
|
779
790
|
return {
|
|
780
791
|
content: [{ type: "text" as const, text: renderAskAnswer(questionId, answer.body) }],
|
|
@@ -1021,6 +1032,33 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1021
1032
|
void brokerHandle.close().catch(() => undefined);
|
|
1022
1033
|
});
|
|
1023
1034
|
|
|
1035
|
+
// ── W5 (P2-2): observe worker auto-compaction ──────────────────────────
|
|
1036
|
+
// Auto-compaction inside a worker is otherwise silent from the
|
|
1037
|
+
// orchestrator's view (perf reports only see token jumps). The SDK fires
|
|
1038
|
+
// `session_before_compact` right before the host pi compacts the session
|
|
1039
|
+
// context; this listener OBSERVES ONLY — it returns undefined so the host
|
|
1040
|
+
// proceeds with its own preparation untouched (host analog:
|
|
1041
|
+
// compaction-guard.ts `pi.on("session_before_compact", async () => { return; })`).
|
|
1042
|
+
// NAME DISCIPLINE: this is the SDK's SESSION compaction (host context
|
|
1043
|
+
// summarization) — NOT pi-crew's internal `prepareCompaction` event-log
|
|
1044
|
+
// rotation (event-log-rotation.ts); same word, different mechanism.
|
|
1045
|
+
// Channel discipline (WP-9): createWorkerEventsChannel is registered
|
|
1046
|
+
// unconditionally and self-no-ops without PI_CREW_EVENTS_PATH/
|
|
1047
|
+
// PI_CREW_BROKER_RUN_ID, so a main user session pays nothing. Payload is
|
|
1048
|
+
// scalar-only: branchEntries (large) and signal (AbortSignal) are never
|
|
1049
|
+
// serialized; preparation fields are read defensively (SDK shape evolves).
|
|
1050
|
+
const compactEventsChannel = createWorkerEventsChannel();
|
|
1051
|
+
pi.on("session_before_compact", (event) => {
|
|
1052
|
+
const tokensBefore = (event.preparation as { tokensBefore?: unknown } | undefined)?.tokensBefore;
|
|
1053
|
+
compactEventsChannel.emit("worker.session_before_compact", {
|
|
1054
|
+
reason: event.reason,
|
|
1055
|
+
willRetry: event.willRetry,
|
|
1056
|
+
branchEntryCount: event.branchEntries?.length ?? 0,
|
|
1057
|
+
hasCustomInstructions: typeof event.customInstructions === "string",
|
|
1058
|
+
...(typeof tokensBefore === "number" ? { tokensBefore } : {}),
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1024
1062
|
// ── Task 5 (§15.2): worker inbox pickup ────────────────────────────────
|
|
1025
1063
|
// A broker-eligible worker ALSO picks up sibling DMs / group broadcasts
|
|
1026
1064
|
// (`kind:"message"` entries addressed to this task in the durable
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import type { PiTeamsConfig } from "../config/config.ts";
|
|
3
|
-
import type {
|
|
2
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
4
3
|
import { appendTaskAttentionEvent } from "./attention-events.ts";
|
|
5
4
|
import { upsertCrewAgent } from "./crew-agent-records.ts";
|
|
6
5
|
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
@@ -200,16 +199,3 @@ function resetConsecutiveToolFailures(manifest: TeamRunManifest, agent: CrewAgen
|
|
|
200
199
|
};
|
|
201
200
|
upsertCrewAgent(manifest, updated);
|
|
202
201
|
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Reserve a control channel for a task before spawning its worker.
|
|
206
|
-
* This ensures cancel/steer requests can be queued immediately
|
|
207
|
-
* while the worker is still starting up.
|
|
208
|
-
*/
|
|
209
|
-
export function reserveControlChannel(taskId: string, runId: string): ControlReservation {
|
|
210
|
-
return {
|
|
211
|
-
reservedAt: new Date().toISOString(),
|
|
212
|
-
controllerId: `ctrl:${taskId}:${randomUUID()}`,
|
|
213
|
-
acceptsControlEvents: true,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
@@ -544,6 +544,9 @@ export async function executeBackgroundRun(
|
|
|
544
544
|
workflow: wf as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
|
|
545
545
|
signal,
|
|
546
546
|
tokenBudget: wf.maxTokenBudget,
|
|
547
|
+
// SDD-3 W-C G13: workflow-config override for the agent-call cap
|
|
548
|
+
// (unset → ctx default; a run is always bounded).
|
|
549
|
+
maxAgentCalls: wf.maxAgentCalls,
|
|
547
550
|
});
|
|
548
551
|
await saveRunManifestAsync(dwfResult.manifest);
|
|
549
552
|
earlyResult = dwfResult;
|
|
@@ -54,12 +54,12 @@ import {
|
|
|
54
54
|
isRequestObject,
|
|
55
55
|
parseMsgSendParams,
|
|
56
56
|
parseWaitRequestParams,
|
|
57
|
-
parseWaitResolveParams,
|
|
58
57
|
safeStringify,
|
|
59
58
|
WAIT_REQUEST_TIMEOUT_SEC_DEFAULT,
|
|
60
59
|
WAIT_REQUEST_TIMEOUT_SEC_MAX,
|
|
61
60
|
} from "./protocol/request-parsers.ts";
|
|
62
61
|
import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
|
|
62
|
+
import { handleWaitResolve } from "./protocol/wait-resolve.ts";
|
|
63
63
|
import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
|
|
64
64
|
import { WaitStatusCache } from "./wait-status-cache.ts";
|
|
65
65
|
|
|
@@ -1883,110 +1883,19 @@ export class CrewBroker {
|
|
|
1883
1883
|
});
|
|
1884
1884
|
}
|
|
1885
1885
|
|
|
1886
|
-
/** WP-2/R2:
|
|
1887
|
-
*
|
|
1888
|
-
* auth + state transition + ask.answered event ONLY (ADR item 6/8):
|
|
1889
|
-
* answer DELIVERY is the mailbox respond path (step 6), not this
|
|
1890
|
-
* method. A questionId mismatch (or a task that is not parked) is
|
|
1891
|
-
* rejected WITHOUT clearing anything — fail-closed. */
|
|
1886
|
+
/** WP-2/R2/R3: wait.resolve handler lives in ./protocol/wait-resolve.ts
|
|
1887
|
+
* (moved out for the wc-gate M4 §5 — pure move, no behavior change). */
|
|
1892
1888
|
private async handleWaitResolve(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
const parsed = parseWaitResolveParams(params);
|
|
1903
|
-
if (!parsed) {
|
|
1904
|
-
this.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
|
|
1905
|
-
return;
|
|
1906
|
-
}
|
|
1907
|
-
// Server-side identity enforcement (same rule as wait.request).
|
|
1908
|
-
if (parsed.to !== conn.taskId) {
|
|
1909
|
-
this.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
|
|
1910
|
-
return;
|
|
1911
|
-
}
|
|
1912
|
-
const cwd = this.options.cwd;
|
|
1913
|
-
if (!cwd) {
|
|
1914
|
-
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
1915
|
-
return;
|
|
1916
|
-
}
|
|
1917
|
-
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
1918
|
-
try {
|
|
1919
|
-
const l = loadRunManifestById(cwd, conn.runId);
|
|
1920
|
-
if (!l) {
|
|
1921
|
-
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1922
|
-
return;
|
|
1923
|
-
}
|
|
1924
|
-
loaded = l;
|
|
1925
|
-
} catch (err) {
|
|
1926
|
-
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1927
|
-
return;
|
|
1928
|
-
}
|
|
1929
|
-
if (this.options.waitMethodsEnabled !== true) {
|
|
1930
|
-
recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
|
|
1931
|
-
this.sendError(
|
|
1932
|
-
conn,
|
|
1933
|
-
id,
|
|
1934
|
-
"policy-disabled",
|
|
1935
|
-
"wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
|
|
1936
|
-
);
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
1939
|
-
const runId = conn.runId;
|
|
1940
|
-
const taskId = conn.taskId;
|
|
1941
|
-
const outcome = withRunLockSync(loaded.manifest, () => {
|
|
1942
|
-
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1943
|
-
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1944
|
-
const task = fresh.tasks.find((t) => t.id === taskId);
|
|
1945
|
-
if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
|
|
1946
|
-
if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
|
|
1947
|
-
return {
|
|
1948
|
-
code: "bad-params" as const,
|
|
1949
|
-
message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
|
|
1950
|
-
};
|
|
1951
|
-
}
|
|
1952
|
-
const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
|
|
1953
|
-
// waitState is a single run-level slot: clear it ONLY when it points
|
|
1954
|
-
// at this exact question — never clobber another task's newer park.
|
|
1955
|
-
const waitState = fresh.manifest.waitState;
|
|
1956
|
-
const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
|
|
1957
|
-
const updatedManifest = {
|
|
1958
|
-
...fresh.manifest,
|
|
1959
|
-
...(clearWaitState ? { waitState: undefined } : {}),
|
|
1960
|
-
updatedAt: new Date().toISOString(),
|
|
1961
|
-
};
|
|
1962
|
-
saveRunTasks(updatedManifest, updatedTasks);
|
|
1963
|
-
saveRunManifest(updatedManifest);
|
|
1964
|
-
return { code: "ok" as const, message: "" };
|
|
1965
|
-
});
|
|
1966
|
-
if (outcome.code !== "ok") {
|
|
1967
|
-
this.sendError(conn, id, outcome.code, outcome.message);
|
|
1968
|
-
return;
|
|
1969
|
-
}
|
|
1970
|
-
const eventsPath = loaded.manifest.eventsPath;
|
|
1971
|
-
void appendEventAsync(eventsPath, {
|
|
1972
|
-
type: "ask.answered",
|
|
1973
|
-
runId,
|
|
1974
|
-
taskId,
|
|
1975
|
-
message: `Question ${parsed.questionId} answered; task resumed.`,
|
|
1976
|
-
data: { questionId: parsed.questionId },
|
|
1977
|
-
}).catch((err) =>
|
|
1978
|
-
logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1979
|
-
);
|
|
1980
|
-
void appendEventAsync(eventsPath, {
|
|
1981
|
-
type: "task.resumed",
|
|
1982
|
-
runId,
|
|
1983
|
-
taskId,
|
|
1984
|
-
message: `Task resumed after ask answer (question ${parsed.questionId}).`,
|
|
1985
|
-
data: { questionId: parsed.questionId },
|
|
1986
|
-
}).catch((err) =>
|
|
1987
|
-
logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1889
|
+
await handleWaitResolve(
|
|
1890
|
+
conn,
|
|
1891
|
+
id,
|
|
1892
|
+
params,
|
|
1893
|
+
{
|
|
1894
|
+
sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
|
|
1895
|
+
sendResult: (c, i, r) => this.sendResult(c, i, r),
|
|
1896
|
+
},
|
|
1897
|
+
{ cwd: this.options.cwd, waitMethodsEnabled: this.options.waitMethodsEnabled },
|
|
1988
1898
|
);
|
|
1989
|
-
this.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
|
|
1990
1899
|
}
|
|
1991
1900
|
}
|
|
1992
1901
|
|
|
@@ -172,6 +172,13 @@ export function parseWaitRequestParams(value: unknown): WaitRequestParams | unde
|
|
|
172
172
|
export interface WaitResolveParams {
|
|
173
173
|
to: string;
|
|
174
174
|
questionId: string;
|
|
175
|
+
/** G5 (deep-review 2026-10-01): the parked worker's terminal outcome —
|
|
176
|
+
* "answered" = it PICKED UP the leader's mailbox response for this
|
|
177
|
+
* questionId (the only outcome that triggers the broker's
|
|
178
|
+
* task.respond_delivered ack event); "timed-out"/"aborted" resolve the
|
|
179
|
+
* park WITHOUT a delivery claim. Optional + fail-closed: legacy workers
|
|
180
|
+
* that omit it never claim a delivery (no event is emitted). */
|
|
181
|
+
outcome?: "answered" | "timed-out" | "aborted";
|
|
175
182
|
}
|
|
176
183
|
|
|
177
184
|
export function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
|
|
@@ -179,5 +186,6 @@ export function parseWaitResolveParams(value: unknown): WaitResolveParams | unde
|
|
|
179
186
|
const v = value as Record<string, unknown>;
|
|
180
187
|
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
181
188
|
if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
|
|
182
|
-
|
|
189
|
+
if (v.outcome !== undefined && v.outcome !== "answered" && v.outcome !== "timed-out" && v.outcome !== "aborted") return undefined;
|
|
190
|
+
return { to: v.to, questionId: v.questionId, ...(v.outcome !== undefined ? { outcome: v.outcome } : {}) };
|
|
183
191
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wait-resolve.ts — WP-2/R2 `wait.resolve` broker handler.
|
|
3
|
+
*
|
|
4
|
+
* Moved from crew-broker.ts (wc-gate M4 §5, GH #61 release audit 2026-10-02)
|
|
5
|
+
* — pure move, no behavior change. Terminal report of the parked `ask` tool:
|
|
6
|
+
* flips the task waiting→running and clears the park coordination state.
|
|
7
|
+
* Scoped to auth + state transition + ask.answered/task.resumed events +
|
|
8
|
+
* the G5 respond_delivered ack ONLY (ADR item 6/8): answer DELIVERY is the
|
|
9
|
+
* mailbox respond path (step 6), not this handler. A questionId mismatch
|
|
10
|
+
* (or a task that is not parked) is rejected WITHOUT clearing anything —
|
|
11
|
+
* fail-closed.
|
|
12
|
+
*
|
|
13
|
+
* Top-level function so the class method is a 1-line delegation
|
|
14
|
+
* (same pattern as msg-inbox.ts).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
18
|
+
import { appendEventAsync } from "../../../state/event-log/event-log.ts";
|
|
19
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../../state/stores/state-store.ts";
|
|
20
|
+
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
21
|
+
import type { ServerConnection } from "./connection-state.ts";
|
|
22
|
+
import { parseWaitResolveParams } from "./request-parsers.ts";
|
|
23
|
+
import { recordWaitPolicyRejection } from "./wait-auth.ts";
|
|
24
|
+
|
|
25
|
+
export interface WaitResolveHelpers {
|
|
26
|
+
sendError(conn: ServerConnection, id: string, code: string, message: string): void;
|
|
27
|
+
sendResult(conn: ServerConnection, id: string, result: unknown): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WaitResolveOptions {
|
|
31
|
+
/** Broker cwd (run state root). Undefined → no-manifest error. */
|
|
32
|
+
cwd?: string;
|
|
33
|
+
/** Fail-closed gate: wait.* methods disabled unless explicitly true. */
|
|
34
|
+
waitMethodsEnabled?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleWaitResolve(
|
|
38
|
+
conn: ServerConnection,
|
|
39
|
+
id: string,
|
|
40
|
+
params: unknown,
|
|
41
|
+
helpers: WaitResolveHelpers,
|
|
42
|
+
options: WaitResolveOptions,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (!conn.runId || !conn.taskId) {
|
|
45
|
+
helpers.sendError(conn, id, "auth", "not authed");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseWaitResolveParams(params);
|
|
49
|
+
if (!parsed) {
|
|
50
|
+
helpers.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Server-side identity enforcement (same rule as wait.request).
|
|
54
|
+
if (parsed.to !== conn.taskId) {
|
|
55
|
+
helpers.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!options.cwd) {
|
|
59
|
+
helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
63
|
+
try {
|
|
64
|
+
const l = loadRunManifestById(options.cwd, conn.runId);
|
|
65
|
+
if (!l) {
|
|
66
|
+
helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
loaded = l;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
helpers.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (options.waitMethodsEnabled !== true) {
|
|
75
|
+
recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
|
|
76
|
+
helpers.sendError(
|
|
77
|
+
conn,
|
|
78
|
+
id,
|
|
79
|
+
"policy-disabled",
|
|
80
|
+
"wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const runId = conn.runId;
|
|
85
|
+
const taskId = conn.taskId;
|
|
86
|
+
const outcome = withRunLockSync(loaded.manifest, () => {
|
|
87
|
+
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
88
|
+
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
89
|
+
const task = fresh.tasks.find((t) => t.id === taskId);
|
|
90
|
+
if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
|
|
91
|
+
if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
|
|
92
|
+
return {
|
|
93
|
+
code: "bad-params" as const,
|
|
94
|
+
message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
|
|
98
|
+
// G5 (deep-review 2026-10-01): capture the liveness pid (heartbeat-first,
|
|
99
|
+
// checkpoint-fallback — the same source stale-reconciler uses) before
|
|
100
|
+
// the flip, so respond_delivered records the same worker-pid evidence
|
|
101
|
+
// its respond_missed twin does root-side.
|
|
102
|
+
const workerPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
|
|
103
|
+
// waitState is a single run-level slot: clear it ONLY when it points
|
|
104
|
+
// at this exact question — never clobber another task's newer park.
|
|
105
|
+
const waitState = fresh.manifest.waitState;
|
|
106
|
+
const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
|
|
107
|
+
const updatedManifest = {
|
|
108
|
+
...fresh.manifest,
|
|
109
|
+
...(clearWaitState ? { waitState: undefined } : {}),
|
|
110
|
+
updatedAt: new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
saveRunTasks(updatedManifest, updatedTasks);
|
|
113
|
+
saveRunManifest(updatedManifest);
|
|
114
|
+
return { code: "ok" as const, message: "", workerPid };
|
|
115
|
+
});
|
|
116
|
+
if (outcome.code !== "ok") {
|
|
117
|
+
helpers.sendError(conn, id, outcome.code, outcome.message);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const eventsPath = loaded.manifest.eventsPath;
|
|
121
|
+
void appendEventAsync(eventsPath, {
|
|
122
|
+
type: "ask.answered",
|
|
123
|
+
runId,
|
|
124
|
+
taskId,
|
|
125
|
+
message: `Question ${parsed.questionId} answered; task resumed.`,
|
|
126
|
+
data: { questionId: parsed.questionId },
|
|
127
|
+
}).catch((err) =>
|
|
128
|
+
logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
129
|
+
);
|
|
130
|
+
void appendEventAsync(eventsPath, {
|
|
131
|
+
type: "task.resumed",
|
|
132
|
+
runId,
|
|
133
|
+
taskId,
|
|
134
|
+
message: `Task resumed after ask answer (question ${parsed.questionId}).`,
|
|
135
|
+
data: { questionId: parsed.questionId },
|
|
136
|
+
}).catch((err) =>
|
|
137
|
+
logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
138
|
+
);
|
|
139
|
+
// G5 (deep-review 2026-10-01): respond-delivery ack. wait.resolve fires on
|
|
140
|
+
// EVERY terminal path (answered/timed-out/aborted) — only the worker-claimed
|
|
141
|
+
// outcome "answered" is a DELIVERY: the parked ask tool found the
|
|
142
|
+
// questionId-tagged mailbox response and is resolving the park WITH the
|
|
143
|
+
// answer in hand (its findAskResponse exact-equality match). A
|
|
144
|
+
// timed-out/aborted resolve — or a legacy worker that sends no outcome —
|
|
145
|
+
// never claims a delivery: fail-closed, no event. This ack is the leader's
|
|
146
|
+
// only confirmation that a mailbox respond reached a live worker instead
|
|
147
|
+
// of falling into the void; the root-side respond_missed twin covers the
|
|
148
|
+
// dead-worker branch at write time (extension/team-tool/respond.ts).
|
|
149
|
+
if (parsed.outcome === "answered") {
|
|
150
|
+
void appendEventAsync(eventsPath, {
|
|
151
|
+
type: "task.respond_delivered",
|
|
152
|
+
runId,
|
|
153
|
+
taskId,
|
|
154
|
+
message: `Leader respond delivered: worker acknowledged the mailbox response for question ${parsed.questionId}.`,
|
|
155
|
+
data: {
|
|
156
|
+
questionId: parsed.questionId,
|
|
157
|
+
...(outcome.workerPid !== undefined ? { workerPid: outcome.workerPid } : {}),
|
|
158
|
+
},
|
|
159
|
+
}).catch((err) =>
|
|
160
|
+
logInternalError(
|
|
161
|
+
"crew-broker.wait.respond-delivered-event",
|
|
162
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
163
|
+
`runId=${runId}`,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
helpers.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
|
|
168
|
+
}
|
|
@@ -270,10 +270,17 @@ export function prepareSpawnContext(
|
|
|
270
270
|
agent: input.agent,
|
|
271
271
|
model: input.model,
|
|
272
272
|
sessionEnabled: true,
|
|
273
|
+
// W2 (P1-1): deterministic session identity so crash-path tail-recovery
|
|
274
|
+
// can find the worker's session JSONL (see session-recovery.ts). Falls
|
|
275
|
+
// back to child-pi.ts deriveSessionPaths when unset (idempotent both ways).
|
|
276
|
+
sessionId: input.sessionId,
|
|
277
|
+
sessionDir: input.sessionDir,
|
|
273
278
|
maxDepth: input.maxDepth,
|
|
274
279
|
skillPaths: input.skillPaths,
|
|
275
280
|
role: input.role,
|
|
276
281
|
thinkingOverride: input.thinkingOverride,
|
|
282
|
+
systemPromptAppend: input.systemPromptAppend,
|
|
283
|
+
hermeticWorkers: input.hermeticWorkers,
|
|
277
284
|
// ADR-5 §3: depthOverride is pre-encoded into depthEnv by runChildPi
|
|
278
285
|
// (parent's record depth as base-env PI_CREW_DEPTH); forward it so the
|
|
279
286
|
// child env gets parentDepth+1 = the true grandchild depth.
|
|
@@ -357,10 +364,11 @@ export function prepareSpawnContext(
|
|
|
357
364
|
// F4/S-1: the RAW (unredacted) snapshot must NEVER land in artifactsRoot —
|
|
358
365
|
// point it at a temp dir; the worker reads it then writeArtifact()
|
|
359
366
|
// (redact+atomic) is the ONLY writer into artifactsRoot.
|
|
360
|
-
// R3-1:
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// spawn). createSafeTempDir auto-tracks the dir for
|
|
367
|
+
// R3-1: since G3 (spill-always) buildPiWorkerArgs creates a tempDir for
|
|
368
|
+
// EVERY spawn (task.md) — the `??` guard stays as pure defense against
|
|
369
|
+
// future call shapes (resolveRealContainedPath(undefined) would
|
|
370
|
+
// TypeError and crash spawn). createSafeTempDir auto-tracks the dir for
|
|
371
|
+
// cleanupAllTrackedTempDirs.
|
|
364
372
|
const scratchTempDir = built.tempDir ?? createSafeTempDir(getPiTempBase(), "pi-crew-scratchpad-");
|
|
365
373
|
built.env.PI_CREW_SCRATCHPAD_SNAPSHOT = resolveRealContainedPath(scratchTempDir, `${input.agentId}.snapshot.json`);
|
|
366
374
|
// Phase 2 crash-resume (D1/D1b/D2): locate the latest snapshot artifact of a
|