pi-crew 0.11.47 → 0.11.49

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.
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import type { Writable } from "node:stream";
4
5
  import type { AgentConfig } from "../../agents/agent-config.ts";
5
6
  import { loadConfig } from "../../config/config.ts";
6
7
  import type { PiTeamsConfig } from "../../config/types.ts";
@@ -279,6 +280,16 @@ export interface ChildPiRunInput {
279
280
  * value issued by the parent's CrewBroker at spawn time; it lives only
280
281
  * in the parent's in-memory `Map<runId, token>` and in the child's env.
281
282
  * NEVER persisted to manifest, events, mailbox, or any run-dir file.
283
+ *
284
+ * OPT-IN fd3 route (PI_CREW_BROKER_STDIN=1, FIX-1 2026-10-28): the token
285
+ * is still written into built.env for every brokered spawn — RPC/surface
286
+ * branches compose WITHOUT the payload and need the env credential, but
287
+ * the headless JSON branch composes WITH ctx.brokerStdinPayload and
288
+ * `buildFinalChildPiSpawnOptions` strips the token from the FINAL child
289
+ * env there — on that branch alone the token rides the DEDICATED fd3 pipe
290
+ * (stdio entry 4; heap → fd3 → heap) instead. fd0 stays "ignore"
291
+ * (pre-0.11.46 shape) so pi@1.0.4 readPipedStdin can never race or merge
292
+ * the credential into the worker prompt.
282
293
  */
283
294
  brokerSpawn?: { socketPath: string; token: string };
284
295
  /**
@@ -852,9 +863,10 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
852
863
  // R6-8: modelChain threads the candidate chain for provider-key scoping.
853
864
  // v0.11.38 W10-1: workerCacheSplit — headless single-run workers opt into
854
865
  // short prompt-cache retention (see PI_CACHE_RETENTION_ENV).
855
- // LOW-1 closure: ctx.brokerStdinPayload (when present) switches stdin to
856
- // "pipe" + strips PI_CREW_BROKER_TOKEN from the child env — the token
857
- // travels over the stdin pipe right after spawn instead (heap → pipe → heap).
866
+ // LOW-1 closure → FD-3: ctx.brokerStdinPayload (when present) adds the
867
+ // DEDICATED fd3 pipe (4th stdio entry; fd0 stays "ignore") + strips
868
+ // PI_CREW_BROKER_TOKEN from the child env — the token travels over
869
+ // stdio[3] right after spawn instead (heap → fd3 → heap).
858
870
  const spawnOptions = buildFinalChildPiSpawnOptions(
859
871
  input.cwd,
860
872
  mergedEnv,
@@ -920,22 +932,29 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
920
932
  ts: new Date().toISOString(),
921
933
  });
922
934
  }
923
- // LOW-1 closure: hand the worker its broker credential over stdin — one
924
- // buildBrokerStdinLine JSON line (v2, per-task compound token), then close
925
- // the pipe. Mirror of the F4 async-runner handshake; the worker-side
926
- // reader (crew-broker-child.ts via stdin-handshake.readStdinFirstLine)
927
- // binds it by runId + taskId. Best-effort in BOTH directions: any write
928
- // failure (EPIPE on a fast-exiting child, destroyed stream) leaves the
929
- // worker creds-less exactly like a broker-less spawn — never throws out
930
- // of the spawn path.
935
+ // LOW-1 closure → FD-3 (2026-10-28): hand the worker its broker
936
+ // credential over the DEDICATED fd3 pipe (stdio[3]) — one
937
+ // buildBrokerStdinLine JSON line (v2, per-task compound token), then
938
+ // close our write end. fd0 is "ignore" (pre-0.11.46 shape): pi@1.0.4
939
+ // readPipedStdin consumes ALL piped fd0 bytes as PROMPT CONTENT in
940
+ // every mode except --mode rpc, so the earlier fd0 handshake raced it
941
+ // three ways (token JSON leaked into the prompt / creds-less silence /
942
+ // pause() deadlock → SIGTERM exit 0 with ~0 stdout — the 0.11.46/47
943
+ // live incident). The worker-side reader (crew-broker-child.ts via
944
+ // stdin-handshake.readFirstLineFromFd) binds the payload by runId +
945
+ // taskId. Best-effort in BOTH directions: any write failure (EPIPE on
946
+ // a fast-exiting child, destroyed stream, missing stdio[3] entry)
947
+ // leaves the worker creds-less exactly like a broker-less spawn —
948
+ // never throws out of the spawn path.
931
949
  if (spawnPrep.ctx.brokerStdinPayload) {
950
+ const fd3 = child.stdio[3] as Writable | null | undefined;
932
951
  try {
933
- child.stdin?.write(spawnPrep.ctx.brokerStdinPayload);
952
+ fd3?.write(spawnPrep.ctx.brokerStdinPayload);
934
953
  } catch {
935
954
  /* best-effort: worker proceeds creds-less */
936
955
  }
937
956
  try {
938
- child.stdin?.end();
957
+ fd3?.end();
939
958
  } catch {
940
959
  /* EPIPE: child already died — spawn error handling owns that */
941
960
  }
@@ -1,6 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { loadConfig } from "../../config/config.ts";
5
+ import { LIMIT_CEILINGS } from "../../config/config-validation.ts";
4
6
  import { DEFAULT_CACHE, DEFAULT_PATHS } from "../../config/defaults.ts";
5
7
  import { errors } from "../../errors.ts";
6
8
  import type { TeamConfig } from "../../teams/team-config.ts";
@@ -433,6 +435,24 @@ export function createTasksFromWorkflow(
433
435
  });
434
436
  }
435
437
 
438
+ /**
439
+ * Admission cap for {@link createRunManifest}: the effective
440
+ * `limits.maxTasksPerRun` from loaded config, falling back to the hard
441
+ * ceiling LIMIT_CEILINGS.maxTasksPerRun — no parsed config value can exceed
442
+ * the ceiling (config-validation clamps it), so a manifest beyond the ceiling
443
+ * is invalid regardless of config. Config-load failure degrades to the
444
+ * ceiling only (an admission check must never itself block run creation on a
445
+ * config read problem; loadConfig surfaces its own warnings separately).
446
+ */
447
+ function resolveMaxTasksPerRunAdmissionCap(cwd: string): number {
448
+ try {
449
+ const configured = loadConfig(cwd).config.limits?.maxTasksPerRun;
450
+ return typeof configured === "number" && Number.isFinite(configured) ? configured : LIMIT_CEILINGS.maxTasksPerRun;
451
+ } catch {
452
+ return LIMIT_CEILINGS.maxTasksPerRun;
453
+ }
454
+ }
455
+
436
456
  export function createRunManifest(params: {
437
457
  cwd: string;
438
458
  team: TeamConfig;
@@ -458,6 +478,20 @@ export function createRunManifest(params: {
458
478
  const paths = createRunPaths(params.cwd, params.runId);
459
479
  const now = (params.now ? params.now() : new Date()).toISOString();
460
480
  const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
481
+ // Fix-wave C (2026-10-28): PRE-DISPATCH admission gate for
482
+ // limits.maxTasksPerRun. evaluateCrewPolicy only runs at finalize
483
+ // (finalize-run.ts), so an oversized manifest was previously fully
484
+ // DISPATCHED and only blocked after the run ended. createRunManifest is the
485
+ // single funnel where every normal run entry (team-tool run/parallel,
486
+ // goal-loop turns) materializes its task manifest BEFORE dispatch, so the
487
+ // cap is refused here — before any state directory or event is written.
488
+ // Mid-run dynamic task growth remains owned by the finalize-time policy.
489
+ const maxTasksPerRunCap = resolveMaxTasksPerRunAdmissionCap(params.cwd);
490
+ if (tasks.length > maxTasksPerRunCap) {
491
+ throw errors.config(
492
+ `Refusing to admit run '${params.workflow?.name ?? params.team.name}': manifest has ${tasks.length} tasks, exceeding limits.maxTasksPerRun=${maxTasksPerRunCap}. Reduce the task count or raise 'limits.maxTasksPerRun' in pi-crew config (hard ceiling ${LIMIT_CEILINGS.maxTasksPerRun}).`,
493
+ );
494
+ }
461
495
  const manifest: TeamRunManifest = {
462
496
  schemaVersion: CURRENT_SCHEMA_VERSION,
463
497
  runId: paths.runId,