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.
Files changed (68) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +1 -1
  3. package/dist/index.mjs +503 -493
  4. package/docs/dynamic-workflows.md +37 -0
  5. package/package.json +13 -9
  6. package/scripts/analyze-run.mjs +95 -11
  7. package/skills/model-routing-context/SKILL.md +4 -3
  8. package/skills/real-test-pi-crew/SKILL.md +9 -6
  9. package/src/config/config-validation.ts +28 -0
  10. package/src/config/env-vars.ts +29 -0
  11. package/src/config/types.ts +47 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/respond.ts +54 -0
  16. package/src/extension/team-tool/status.ts +10 -0
  17. package/src/extension/team-tool.ts +393 -3
  18. package/src/extension/webhook-notify.ts +23 -1
  19. package/src/observability/exporters/otlp-exporter.ts +93 -11
  20. package/src/prompt/prompt-runtime.ts +46 -8
  21. package/src/runtime/agent-control.ts +1 -15
  22. package/src/runtime/background-runner.ts +3 -0
  23. package/src/runtime/broker/crew-broker.ts +12 -103
  24. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  25. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  26. package/src/runtime/child-pi/child-pi-spawn.ts +12 -4
  27. package/src/runtime/child-pi/child-pi.ts +93 -4
  28. package/src/runtime/child-pi/session-recovery.ts +334 -0
  29. package/src/runtime/dispatch-batch.ts +3 -1
  30. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  31. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  32. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  33. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  34. package/src/runtime/mcp-proxy.ts +110 -7
  35. package/src/runtime/model/model-scope.ts +141 -21
  36. package/src/runtime/model/pi-args.ts +115 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/peer-dep.ts +24 -0
  39. package/src/runtime/rpc/README.md +100 -0
  40. package/src/runtime/rpc/frame-client.ts +328 -0
  41. package/src/runtime/rpc/rpc-worker.ts +151 -0
  42. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  43. package/src/runtime/run-worker.ts +14 -0
  44. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  45. package/src/runtime/stale-reconciler.ts +47 -0
  46. package/src/runtime/surface/surface-spawn.ts +8 -1
  47. package/src/runtime/task-output-context.ts +191 -32
  48. package/src/runtime/task-packet.ts +3 -0
  49. package/src/runtime/task-runner/child-executor.ts +170 -6
  50. package/src/runtime/task-runner/output-splitter.ts +48 -5
  51. package/src/runtime/task-runner/post-execution.ts +5 -5
  52. package/src/runtime/task-runner/pre-execution.ts +32 -4
  53. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  54. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  55. package/src/schema/config-schema.ts +25 -0
  56. package/src/schema/team-tool-schema.ts +35 -0
  57. package/src/state/contracts.ts +16 -0
  58. package/src/state/crew-init.ts +114 -0
  59. package/src/state/stores/active-run-registry.ts +13 -6
  60. package/src/state/types.ts +0 -8
  61. package/src/ui/goal-flag.ts +44 -0
  62. package/src/ui/live-run-sidebar.ts +5 -1
  63. package/src/ui/pi-ui-compat.ts +37 -11
  64. package/src/ui/run-dashboard.ts +2 -1
  65. package/src/ui/widget/index.ts +0 -1
  66. package/src/ui/widget/widget-model.ts +6 -2
  67. package/src/utils/ip-literal.ts +145 -0
  68. package/src/workflows/workflow-config.ts +6 -0
@@ -12,11 +12,14 @@ import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
12
12
  import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
13
13
  import { withRunLock } from "../state/coordination/locks.ts";
14
14
  import { replayPendingMailboxMessages } from "../state/coordination/mailbox.ts";
15
+ import { isTaskClaimExpired } from "../state/coordination/task-claims.ts";
15
16
  import { appendEventAsync, appendEventFireAndForget } from "../state/event-log/event-log.ts";
17
+ import { ACTIVE_RUN_STALE_MS, activeRunEntries, registerActiveRun, unregisterActiveRun } from "../state/stores/active-run-registry.ts";
16
18
  import { writeArtifact } from "../state/stores/artifact-store.ts";
17
19
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
18
20
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
19
21
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
22
+ import type { TeamConfig } from "../teams/team-config.ts";
20
23
  import { logInternalError } from "../utils/internal-error.ts";
21
24
  import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
22
25
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
@@ -40,6 +43,7 @@ import { buildParentContext, formatScoped, result, type TeamContext } from "./te
40
43
  // Lazy-loaded: run.ts pulls in spawnBackgroundTeamRun, resolveCrewRuntime, etc.
41
44
  // Static import fails silently in some jiti contexts (child-process), leaving handleRun undefined.
42
45
  import type { handleRun as _handleRunFn } from "./team-tool/run.ts";
46
+ import { resolveRunDeadline } from "./team-tool/run-deadline.ts";
43
47
 
44
48
  type HandleRunFn = typeof _handleRunFn;
45
49
  async function handleRun(...args: Parameters<HandleRunFn>): Promise<Awaited<ReturnType<HandleRunFn>>> {
@@ -315,12 +319,318 @@ async function recoverCheckpointedTasks(
315
319
  return { manifest: nextManifest, tasks: nextTasks, recovered };
316
320
  }
317
321
 
322
+ /** PID liveness probe — same semantics as filterAliveEntries: only ESRCH/ENOENT
323
+ * mean "process does not exist"; EPERM means alive in another security context. */
324
+ function isPidAliveForResume(pid: number): boolean {
325
+ try {
326
+ process.kill(pid, 0);
327
+ return true;
328
+ } catch (error) {
329
+ const code = (error as NodeJS.ErrnoException).code;
330
+ return code !== "ESRCH" && code !== "ENOENT";
331
+ }
332
+ }
333
+
334
+ /** G12 (SDD-3 W-C WI-1): liveness evidence for a run being resumed. Returns a
335
+ * human-readable refusal message when the run is LIVE, undefined when it is
336
+ * safe to resume. force:true bypasses OWNERSHIP, never LIVENESS — callers must
337
+ * refuse regardless of force when this returns a message.
338
+ *
339
+ * Three independent signals (any one ⇒ live):
340
+ * 1. active-run-registry entry (activeRunEntries already applies the full
341
+ * filter: terminal status, dead async PID, >30-min staleness);
342
+ * 2. a running task holding a PRESENT, UNEXPIRED worker claim (task-claims
343
+ * lease). Review fix 2026-10-01 (MAJOR): the claim must EXIST — coalesced
344
+ * dispatch (run-coalesced-task-group.ts) and the task-graph scheduler mark
345
+ * tasks running WITHOUT claims, so counting claim-less running tasks as
346
+ * live permanently refused resume of crashed coalesced runs (with a
347
+ * misleading "until undefined" message). Runs dispatched by real engines
348
+ * carry the registry (signal 1) / async-PID (signal 3) liveness instead;
349
+ * 3. the manifest's detached async PID is alive and the manifest is fresh
350
+ * (covers goal-loop/DWF background runs whose registry entry was pruned).
351
+ * Terminal runs (completed/failed/cancelled) never count as live via signal 3
352
+ * — a lingering post-finalize PID must not block legitimate re-runs. */
353
+ function resumeLivenessRefusal(manifest: TeamRunManifest, tasks: TeamTaskState[]): string | undefined {
354
+ const runId = manifest.runId;
355
+ const registryEntry = activeRunEntries().find((entry) => entry.runId === runId);
356
+ if (registryEntry) {
357
+ return [
358
+ `Run ${runId} is still live (active-run registry, last heartbeat ${registryEntry.updatedAt}) — resume refused to prevent double-dispatch.`,
359
+ `Current status: ${manifest.status}. Wait for it to finish, or cancel it first (team action=cancel runId=${runId}).`,
360
+ "force:true bypasses OWNERSHIP checks only — it can never bypass this liveness check.",
361
+ ].join("\n");
362
+ }
363
+ const claimed = tasks.find(
364
+ // Review fix 2026-10-01 (MAJOR): require the claim to EXIST — see the
365
+ // signal-2 note above (claim-less running tasks come from coalesced
366
+ // dispatch / scheduler markings and must not read as permanently live).
367
+ (task) => task.status === "running" && task.claim !== undefined && !isTaskClaimExpired(task.claim),
368
+ );
369
+ if (claimed) {
370
+ return [
371
+ `Run ${runId} is still live (task '${claimed.id}' holds an unexpired worker claim until ${claimed.claim?.leasedUntil}) — resume refused to prevent double-dispatch.`,
372
+ `Current status: ${manifest.status}. Wait for the worker to finish, or cancel the run first (team action=cancel runId=${runId}).`,
373
+ "force:true bypasses OWNERSHIP checks only — it can never bypass this liveness check.",
374
+ ].join("\n");
375
+ }
376
+ const pid = manifest.async?.pid;
377
+ if (
378
+ typeof pid === "number" &&
379
+ Number.isInteger(pid) &&
380
+ pid > 0 &&
381
+ manifest.status !== "completed" &&
382
+ manifest.status !== "failed" &&
383
+ manifest.status !== "cancelled"
384
+ ) {
385
+ const updatedAt = Date.parse(manifest.updatedAt);
386
+ // NIT 1 fix: shared registry constant — the resume horizon can never drift
387
+ // from filterAliveEntries' staleness horizon.
388
+ const fresh = Number.isFinite(updatedAt) && Date.now() - updatedAt <= ACTIVE_RUN_STALE_MS;
389
+ if (fresh && isPidAliveForResume(pid)) {
390
+ return [
391
+ `Run ${runId} is still live (background process PID ${pid} is alive) — resume refused to prevent double-dispatch.`,
392
+ `Current status: ${manifest.status}. Wait for the background run to finish, or cancel it first (team action=cancel runId=${runId}).`,
393
+ "force:true bypasses OWNERSHIP checks only — it can never bypass this liveness check.",
394
+ ].join("\n");
395
+ }
396
+ }
397
+ return undefined;
398
+ }
399
+
400
+ /** G11 (SDD-3 W-C WI-2): adopt ownership + refresh heartbeat for a special-kind
401
+ * (goal-loop / dynamic-workflow) resume, under the same per-run lock semantics
402
+ * as the static path. Mirrors the static path's B1 adoption (battery 2026-08-18
403
+ * case b): a force-resumed run must not keep its dead original ownerSessionId,
404
+ * or a third session's orphan-scan cancels the live resumed run. The adopted
405
+ * manifest PRESERVES the original runKind — the whole point of the branch. */
406
+ async function adoptSpecialKindRunForResume(
407
+ manifest: TeamRunManifest,
408
+ runKind: "goal-loop" | "dynamic-workflow",
409
+ ctx: TeamContext,
410
+ ): Promise<TeamRunManifest> {
411
+ return await withRunLock(manifest, async () => {
412
+ const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
413
+ const base = fresh?.manifest ?? manifest;
414
+ // W-C2 (SDD-4 WI-6): drop the dispatch-time detached-runner pointer. Resume
415
+ // arms re-execute IN THIS session's process (runGoalLoop /
416
+ // runDynamicWorkflow) — no runner is re-spawned — yet goal-wrapped runs carry
417
+ // `async: { pid }` from their original background spawn. A dead pid on an
418
+ // adopted "running" manifest is exactly the state that
419
+ // transitionStaleAsyncUnderLock (status.ts) flips to failed on the next
420
+ // status poll ("Async process stale: process does not exist"). The drop is
421
+ // audited on the run.resume_requested event (clearedAsyncPid).
422
+ const adoptedBase: TeamRunManifest = { ...base };
423
+ const staleAsyncPid = adoptedBase.async?.pid;
424
+ delete adoptedBase.async;
425
+ const adopted: TeamRunManifest = {
426
+ ...adoptedBase,
427
+ // WI-2 (G11): keep the ORIGINAL runKind — an accidental fallback to
428
+ // "team-run" would route the NEXT resume through the static path.
429
+ runKind,
430
+ // Mark running at adoption: registerActiveRun refuses terminal
431
+ // entries, and the run IS executing from this dispatch until terminal.
432
+ // allowTerminalExit mirrors the static resume path (finding 8 write
433
+ // guard — resume is the one legitimate terminal-exit flow).
434
+ status: "running",
435
+ summary: `Resuming ${runKind} run.`,
436
+ updatedAt: new Date().toISOString(),
437
+ ...(ctx.sessionId ? { ownerSessionId: ctx.sessionId } : {}),
438
+ };
439
+ await saveRunManifestAsync(adopted, { allowTerminalExit: true });
440
+ await appendEventAsync(adopted.eventsPath, {
441
+ type: "run.resume_requested",
442
+ runId: adopted.runId,
443
+ data: { runKind, action: "resume", ...(staleAsyncPid !== undefined ? { clearedAsyncPid: staleAsyncPid } : {}) },
444
+ });
445
+ return adopted;
446
+ });
447
+ }
448
+
449
+ /** G11 (SDD-3 W-C WI-2): resume arm for runKind="dynamic-workflow" — mirrors
450
+ * run.ts's DWF dispatch (:390-435): re-synthesize the dynamic team, adopt the
451
+ * run, then hand off to runDynamicWorkflow (which hydrates the dwf-checkpoint
452
+ * state, dwf-runner.ts round-18 P2-3). The static team lookup CANNOT resolve
453
+ * the synthetic `dwf-*` team, which is why this branch exists. */
454
+ async function resumeDynamicWorkflowRun(
455
+ params: TeamToolParamsValue,
456
+ ctx: TeamContext,
457
+ manifest: TeamRunManifest,
458
+ ): Promise<PiTeamsToolResult> {
459
+ const dwfWorkflow = allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === manifest.workflow);
460
+ if (dwfWorkflow?.runtime !== "dynamic" || !dwfWorkflow?.dynamicScript) {
461
+ return result(
462
+ `Workflow '${manifest.workflow ?? ""}' is not a dynamic workflow (runKind=dynamic-workflow); cannot resume run ${manifest.runId}. Fix or restore the workflow file, or re-dispatch with action=run.`,
463
+ { action: "resume", status: "error", runId: manifest.runId },
464
+ true,
465
+ );
466
+ }
467
+ // Re-synthesize the dynamic team (§0c C9 mirror of run.ts) — role resolution only.
468
+ const dwfTeam: TeamConfig = {
469
+ name: manifest.team,
470
+ description: `Dynamic workflow run for ${dwfWorkflow.name}`,
471
+ source: "dynamic",
472
+ filePath: "<dynamic-workflow>",
473
+ roles: [{ name: "worker", agent: params.agent ?? "executor" }],
474
+ workspaceMode: "single",
475
+ };
476
+ const adopted = await adoptSpecialKindRunForResume(manifest, "dynamic-workflow", ctx);
477
+ registerActiveRun(adopted);
478
+ // CORE-8 mirror: unified deadline (params.timeoutMs > config.maxRunMinutes > 1h default).
479
+ const dwfDeadline = resolveRunDeadline(ctx, params);
480
+ try {
481
+ // LAZY: defer dynamic import of the DWF runner to its call site (mirrors run.ts:399).
482
+ const { runDynamicWorkflow } = await import("../runtime/goal-workflow/dynamic-workflow-runner.ts");
483
+ const dwfResult = await runDynamicWorkflow({
484
+ manifest: adopted,
485
+ workflow: dwfWorkflow as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
486
+ team: dwfTeam,
487
+ signal: dwfDeadline.signal,
488
+ modelOverride: params.model,
489
+ tokenBudget:
490
+ params.tokenBudget ?? (dwfWorkflow as import("../workflows/workflow-config.ts").DynamicWorkflowConfig).maxTokenBudget,
491
+ });
492
+ await saveRunManifestAsync(dwfResult.manifest);
493
+ return result(
494
+ [
495
+ `Resumed dynamic-workflow run ${dwfResult.manifest.runId}.`,
496
+ `Status: ${dwfResult.manifest.status}`,
497
+ dwfResult.manifest.summary ? `Result: ${dwfResult.manifest.summary}` : undefined,
498
+ ]
499
+ .filter((line): line is string => line !== undefined)
500
+ .join("\n"),
501
+ {
502
+ action: "resume",
503
+ status: dwfResult.manifest.status === "failed" ? "error" : "ok",
504
+ runId: dwfResult.manifest.runId,
505
+ artifactsRoot: dwfResult.manifest.artifactsRoot,
506
+ },
507
+ dwfResult.manifest.status === "failed",
508
+ );
509
+ } catch (runnerError) {
510
+ // Round-11 runtime-fix mirror (run.ts): persist the failure instead of
511
+ // leaving the manifest at its pre-resume status forever.
512
+ const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
513
+ const failedManifest = {
514
+ ...adopted,
515
+ status: "failed" as const,
516
+ summary: `Dynamic workflow '${dwfWorkflow.name}' resume failed: ${failureReason}`.slice(0, 2000),
517
+ updatedAt: new Date().toISOString(),
518
+ };
519
+ await saveRunManifestAsync(failedManifest);
520
+ return result(
521
+ `Dynamic workflow '${dwfWorkflow.name}' resume failed: ${failureReason}`,
522
+ { action: "resume", status: "error", runId: failedManifest.runId, artifactsRoot: failedManifest.artifactsRoot },
523
+ true,
524
+ );
525
+ } finally {
526
+ unregisterActiveRun(adopted.runId);
527
+ clearTimeout(dwfDeadline.timer); // RC-02
528
+ }
529
+ }
530
+
531
+ /** G11 (SDD-3 W-C WI-2): resume arm for runKind="goal-loop" — mirrors the
532
+ * background-runner.ts goal-loop short-circuit (:492): hydrate GoalLoopState,
533
+ * continue the loop via runGoalLoop, map the goal outcome to a run status. */
534
+ async function resumeGoalLoopRun(params: TeamToolParamsValue, ctx: TeamContext, manifest: TeamRunManifest): Promise<PiTeamsToolResult> {
535
+ // Check the goal state BEFORE adoption: a missing GoalLoopState is not
536
+ // resumable at all, so the run must be left untouched (no ownership flip,
537
+ // no status change) for a clearer retry story.
538
+ // LAZY: defer heavy goal-loop imports to the call site (mirrors background-runner.ts:498-506).
539
+ const { GoalStore } = await import("../runtime/goal-workflow/goal-state-store.ts");
540
+ const store = new GoalStore(manifest.cwd);
541
+ const goalState = store.load(manifest.runId);
542
+ if (!goalState) {
543
+ return result(
544
+ `runKind="goal-loop" but GoalLoopState '${manifest.runId}' not found (cwd=${manifest.cwd}); cannot resume. The goal state file may have been pruned — start a new goal run instead.`,
545
+ { action: "resume", status: "error", runId: manifest.runId },
546
+ true,
547
+ );
548
+ }
549
+ const adopted = await adoptSpecialKindRunForResume(manifest, "goal-loop", ctx);
550
+ // LAZY: the runner import stays after the state check for fast failure.
551
+ const { runGoalLoop } = await import("../runtime/goal-workflow/goal-loop-runner.ts");
552
+ registerActiveRun(adopted);
553
+ // CORE-8 mirror (review MINOR 4): honor params.timeoutMs like the DWF arm —
554
+ // previously this arm resolved the deadline with {} and silently ignored the
555
+ // caller's timeout override.
556
+ const goalDeadline = resolveRunDeadline(ctx, params);
557
+ try {
558
+ const goalResult = await runGoalLoop({
559
+ goalState,
560
+ manifest: adopted,
561
+ signal: goalDeadline.signal,
562
+ deps: {
563
+ discoverAgents: (cwd: string) => allAgents(discoverAgents(cwd)),
564
+ },
565
+ });
566
+ // Fix P1-1 + round-6 #5 mirror (background-runner.ts:525-536): persist the
567
+ // terminal status reflecting the goal's actual outcome, not a blanket
568
+ // 'completed'.
569
+ const goalStatusToRunStatus: Record<string, TeamRunManifest["status"]> = {
570
+ achieved: "completed",
571
+ max_turns: "completed",
572
+ budget_exceeded: "completed",
573
+ blocked: "blocked",
574
+ cancelled: "cancelled",
575
+ paused: "blocked",
576
+ running: "running",
577
+ };
578
+ const runStatus = goalStatusToRunStatus[goalResult.goalState.state] ?? "completed";
579
+ const finalManifest: TeamRunManifest = {
580
+ ...goalResult.manifest,
581
+ status: runStatus,
582
+ updatedAt: new Date().toISOString(),
583
+ };
584
+ await saveRunManifestAsync(finalManifest);
585
+ return result(
586
+ [
587
+ `Resumed goal-loop run ${finalManifest.runId}.`,
588
+ `Status: ${finalManifest.status} (goal state: ${goalResult.goalState.state})`,
589
+ ].join("\n"),
590
+ {
591
+ action: "resume",
592
+ status: runStatus === "failed" ? "error" : "ok",
593
+ runId: finalManifest.runId,
594
+ artifactsRoot: finalManifest.artifactsRoot,
595
+ },
596
+ runStatus === "failed",
597
+ );
598
+ } catch (runnerError) {
599
+ // Round-11 runtime-fix mirror (DWF arm + review MINOR 1): persist the
600
+ // failure instead of leaving the adopted manifest stuck at
601
+ // status:"running" with a live registry entry until the stale horizon.
602
+ const failureReason = runnerError instanceof Error ? runnerError.message : String(runnerError);
603
+ const failedManifest = {
604
+ ...adopted,
605
+ status: "failed" as const,
606
+ summary: `Goal-loop resume failed: ${failureReason}`.slice(0, 2000),
607
+ updatedAt: new Date().toISOString(),
608
+ };
609
+ await saveRunManifestAsync(failedManifest);
610
+ return result(
611
+ `Goal-loop run ${failedManifest.runId} resume failed: ${failureReason}`,
612
+ { action: "resume", status: "error", runId: failedManifest.runId, artifactsRoot: failedManifest.artifactsRoot },
613
+ true,
614
+ );
615
+ } finally {
616
+ unregisterActiveRun(adopted.runId);
617
+ clearTimeout(goalDeadline.timer); // RC-02
618
+ }
619
+ }
620
+
318
621
  export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
319
622
  if (!params.runId) return result("Resume requires runId.", { action: "resume", status: "error" }, true);
320
623
  const runCwd = locateRunCwd(params.runId, ctx.cwd);
321
624
  if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "resume", status: "error" }, true);
322
625
  const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
323
626
  if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "resume", status: "error" }, true);
627
+ // G12 (SDD-3 W-C WI-1): liveness-first gate — BEFORE the ownership check and
628
+ // BEFORE any reset/dispatch. executeTeamRun runs OUTSIDE the resume lock
629
+ // (LOCK-2 below), so a resume accepted against a live run double-dispatches
630
+ // workers (duplicate tokens + duplicate side effects). force:true bypasses
631
+ // OWNERSHIP only — never LIVENESS — so the refusal applies even when forced.
632
+ const liveness = resumeLivenessRefusal(loaded.manifest, loaded.tasks);
633
+ if (liveness) return result(liveness, { action: "resume", status: "error", runId: loaded.manifest.runId }, true);
324
634
  // R1: foreign-ownership check — mirrors handleRetry/handleCancel. Without it,
325
635
  // another session can resume (and re-execute) a run it doesn't own, racing
326
636
  // the owning session.
@@ -332,8 +642,40 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
332
642
  true,
333
643
  );
334
644
  }
645
+ // G4 (SDD-3 W-C WI-1): force-on-foreign is an authorization override — liveness
646
+ // was already REFUSED above, so force only bypassed OWNERSHIP here. Record it
647
+ // as a security event (registered in TEAM_EVENT_TYPES) so cross-session forced
648
+ // resumes are auditable; never silent.
649
+ if (foreignRun && params.force) {
650
+ await appendEventAsync(loaded.manifest.eventsPath, {
651
+ type: "run.resume_forced_foreign",
652
+ runId: loaded.manifest.runId,
653
+ message: `Foreign run ${loaded.manifest.runId} force-resumed by session ${ctx.sessionId} (owner ${loaded.manifest.ownerSessionId}).`,
654
+ data: {
655
+ action: "resume",
656
+ forced: true,
657
+ ownerSessionId: loaded.manifest.ownerSessionId,
658
+ forcingSessionId: ctx.sessionId,
659
+ runStatus: loaded.manifest.status,
660
+ },
661
+ });
662
+ }
335
663
  if (!loaded.manifest.workflow)
336
664
  return result(`Run '${params.runId}' has no workflow to resume.`, { action: "resume", status: "error" }, true);
665
+ // G11 (SDD-3 W-C WI-2): goal-loop and dynamic-workflow runs use SYNTHETIC
666
+ // team names (goal-*/dwf-*) that never appear in discoverTeams — the static
667
+ // team lookup below fails with "Team not found" and the run is unresumable
668
+ // even though both engines have resume support (DWF checkpoint hydration,
669
+ // goal-loop state). Branch on the ORIGINAL runKind (mirrors run.ts:390-435
670
+ // dispatch and background-runner.ts:492 short-circuit); the resumed run
671
+ // PRESERVES its runKind (see adoptSpecialKindRunForResume).
672
+ const resumeRunKind = loaded.manifest.runKind ?? "team-run";
673
+ if (resumeRunKind === "dynamic-workflow") {
674
+ return await resumeDynamicWorkflowRun(params, ctx, loaded.manifest);
675
+ }
676
+ if (resumeRunKind === "goal-loop") {
677
+ return await resumeGoalLoopRun(params, ctx, loaded.manifest);
678
+ }
337
679
  const agents = allAgents(discoverAgents(ctx.cwd));
338
680
  const direct = directTeamAndWorkflowFromRun(loaded.manifest, loaded.tasks, agents);
339
681
  const team = direct?.team ?? allTeams(discoverTeams(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.team);
@@ -359,9 +701,36 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
359
701
  const fresh = loadRunManifestById(runCwd, loaded.manifest.runId);
360
702
  const lockedManifest = fresh?.manifest ?? loaded.manifest;
361
703
  const lockedTasks = fresh?.tasks ?? loaded.tasks;
704
+ // F1 (2026-10-01 security review): the PRE-lock liveness gate raced
705
+ // concurrent dispatch — between the pre-lock load and this lock acquisition
706
+ // another session may have adopted and REGISTERED the run. Re-check
707
+ // liveness against the LOCKED state before any recovery/reset mutation;
708
+ // force:true still never bypasses liveness (G12 principle).
709
+ const lockedLiveness = resumeLivenessRefusal(lockedManifest, lockedTasks);
710
+ if (lockedLiveness) {
711
+ return {
712
+ kind: "blocked" as const,
713
+ payload: result(lockedLiveness, { action: "resume", status: "error", runId: lockedManifest.runId }, true),
714
+ };
715
+ }
362
716
  const loadedConfig = loadConfig(ctx.cwd);
363
717
  const recovered = await recoverCheckpointedTasks(lockedManifest, lockedTasks);
364
718
  const resumeManifest = recovered.manifest;
719
+ // W-C2 (SDD-4 WI-6): drop the stale detached-runner pointer — resume never
720
+ // re-spawns a background runner (executeTeamRun below runs in THIS
721
+ // session's process), yet the adopted manifest kept the dispatch-time
722
+ // `async: { pid }` block pointing at the (now dead) original runner. During
723
+ // the re-execution window (status=running) any status poll read that dead
724
+ // pid and transitionStaleAsyncUnderLock flipped the LIVE resumed run to
725
+ // failed ("Async process stale: process does not exist", cancelling running
726
+ // tasks) — real-test battery 2026-10-02 finding 1: resume of a completed
727
+ // async run died 14s in while the sync contrast passed. Invariant restored:
728
+ // manifest.async is present ONLY while a detached runner owns execution; a
729
+ // resumed run's liveness is carried by registerActiveRun + ownerSessionId.
730
+ // The drop is audited on the run.resume_requested event (clearedAsyncPid).
731
+ const resumeBase: TeamRunManifest = { ...resumeManifest };
732
+ const staleAsyncPid = resumeBase.async?.pid;
733
+ delete resumeBase.async;
365
734
  const executedConfig = {
366
735
  ...effectiveRunConfig(loadedConfig.config, params.config),
367
736
  };
@@ -382,7 +751,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
382
751
  const runtime = await resolveCrewRuntime(executedConfig);
383
752
  const runtimeResolution = runtimeResolutionState(runtime);
384
753
  const runtimeManifest = {
385
- ...resumeManifest,
754
+ ...resumeBase,
386
755
  runtimeResolution,
387
756
  updatedAt: new Date().toISOString(),
388
757
  // B1 battery 2026-08-18 (case b root-cause fix): resume ADOPTS the run —
@@ -458,6 +827,9 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
458
827
  data: {
459
828
  replayedMailboxMessages: replay.messages.length,
460
829
  recoveredCheckpointTasks: recovered.recovered,
830
+ // W-C2 (SDD-4 WI-6): audit trail for the stale detached-runner pointer
831
+ // dropped at adoption (see resumeBase above).
832
+ ...(staleAsyncPid !== undefined ? { clearedAsyncPid: staleAsyncPid } : {}),
461
833
  },
462
834
  });
463
835
  if (recovered.recovered.length)
@@ -479,10 +851,28 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
479
851
  });
480
852
  const executeWorkers = runtime.kind !== "scaffold";
481
853
  const resumeSkillOverride = normalizeSkillOverride(params.skill) ?? runtimeManifest.skillOverride;
482
-
854
+ // F1 (2026-10-01 security review): adopt running + REGISTER in the
855
+ // cross-process active-run registry INSIDE the lock, mirroring run.ts
856
+ // dispatch (:388) and adoptSpecialKindRunForResume. The static path
857
+ // previously never registered, so during foreground executeTeamRun (which
858
+ // runs OUTSIDE the lock) a concurrent resume saw NO cross-process liveness
859
+ // signal until the first task claim appeared — the double-dispatch window
860
+ // (even with force:true). Terminal finalize unregisters (updateRunStatus →
861
+ // unregisterActiveRun); a resume that crashes mid-execution ages out via
862
+ // the registry's staleness horizon. registerActiveRun throwing here (a
863
+ // raced external cancel flipped the disk manifest terminal) is intentional
864
+ // fail-safe: the other writer won the race, so we must not dispatch.
865
+ const executingManifest = updateRunStatus(
866
+ runtimeManifest,
867
+ "running",
868
+ `Resuming run (adopted by session ${ctx.sessionId ?? "unknown"}).`,
869
+ // Finding 8 write guard: resume is the legitimate terminal-exit flow.
870
+ { allowTerminalExit: true },
871
+ );
872
+ registerActiveRun(executingManifest);
483
873
  return {
484
874
  kind: "execute" as const,
485
- runtimeManifest,
875
+ runtimeManifest: executingManifest,
486
876
  resetTasks,
487
877
  executeWorkers,
488
878
  resumeSkillOverride,
@@ -36,6 +36,7 @@ import type { CrewWebhookConfig } from "../config/types.ts";
36
36
  import { loadRunManifestById } from "../state/stores/state-store.ts";
37
37
  import type { TeamTaskState } from "../state/types.ts";
38
38
  import { logInternalError } from "../utils/internal-error.ts";
39
+ import { embeddedIpv4FromGroups, parseIpv6Literal } from "../utils/ip-literal.ts";
39
40
  import { isInQuietHours } from "./notification-router.ts";
40
41
 
41
42
  /** Documented payload (spec §Design). Field order matches the spec example. */
@@ -139,7 +140,28 @@ function isLocalAddress(hostname: string): boolean {
139
140
  const firstHextet = bare.split(":").find((segment) => segment.length > 0);
140
141
  if (firstHextet && /^[0-9a-f]{1,4}$/.test(firstHextet)) {
141
142
  const value = Number.parseInt(firstHextet, 16);
142
- return value >= 0xfe80 && value <= 0xfebf;
143
+ if (value >= 0xfe80 && value <= 0xfebf) return true;
144
+ }
145
+ // G6: classify any remaining IPv6 literal through the shared parser —
146
+ // the textual checks above match only a subset of spellings, so
147
+ // `::127.0.0.1` (IPv4-compatible loopback), `0:0:0:0:0:0:0:1` (expanded
148
+ // ::1) and full-form mapped addresses slipped through. Same predicate
149
+ // scope as above: loopback/unspecified/link-local + the local part of an
150
+ // embedded IPv4 (RFC1918 deliberately stays allowed — internal
151
+ // webhooks are a use case).
152
+ const groups = parseIpv6Literal(bare);
153
+ if (groups) {
154
+ if (groups.every((group) => group === 0)) return true;
155
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true;
156
+ if ((groups[0] & 0xffc0) === 0xfe80) return true;
157
+ const embedded = embeddedIpv4FromGroups(groups);
158
+ if (embedded) {
159
+ const v4 = ipv4Octets(embedded);
160
+ if (v4) {
161
+ const [a, b] = v4;
162
+ return a === 127 || a === 0 || (a === 169 && b === 254);
163
+ }
164
+ }
143
165
  }
144
166
  return false;
145
167
  }
@@ -2,6 +2,7 @@ import { lookup } from "node:dns/promises";
2
2
  import { promisify } from "node:util";
3
3
  import { gzip } from "node:zlib";
4
4
  import { logInternalError } from "../../utils/internal-error.ts";
5
+ import { bracketIpv6InUrl, embeddedIpv4FromGroups, parseIpv6Literal, unbracketHost } from "../../utils/ip-literal.ts";
5
6
  import { redactSecrets } from "../../utils/redaction.ts";
6
7
  import type { MetricRegistry } from "../metric-registry.ts";
7
8
  import type { MetricSnapshot } from "../metrics-primitives.ts";
@@ -17,6 +18,10 @@ const gzipAsync = promisify(gzip);
17
18
  * Only http:// and https:// to public hostnames are allowed.
18
19
  */
19
20
  export function validateEndpoint(endpoint: string): void {
21
+ // G6: a bare IPv6 literal in the authority (`http://::1:4318`) makes
22
+ // `new URL` throw — bracket it first so the classifier sees the literal
23
+ // instead of a generic invalid-URL error (RFC 3986 §3.2.2 host ABNF).
24
+ endpoint = bracketIpv6InUrl(endpoint);
20
25
  let url: URL;
21
26
  try {
22
27
  url = new URL(endpoint);
@@ -67,6 +72,13 @@ export function validateEndpoint(endpoint: string): void {
67
72
  if (lower.startsWith("::ffff:")) {
68
73
  throw new Error(`OTLP endpoint must not target IPv4-mapped IPv6 address: ${endpoint}`);
69
74
  }
75
+ // G6: catch every literal form the prefix checks above miss —
76
+ // IPv4-compatible `::a.b.c.d` (e.g. ::127.0.0.1, ::169.254.169.254),
77
+ // expanded spellings (`0:0:0:0:0:0:0:1`), zone-ids — through the
78
+ // shared parser, applying the same predicates as the IPv4 branch.
79
+ if (isPrivateIpv6Literal(bare)) {
80
+ throw new Error(`OTLP endpoint must not target private/reserved IPv6 address: ${endpoint}`);
81
+ }
70
82
  }
71
83
 
72
84
  // Reject IPv4 private/reserved ranges
@@ -101,6 +113,63 @@ export function validateEndpoint(endpoint: string): void {
101
113
  }
102
114
  }
103
115
 
116
+ /**
117
+ * IPv4 predicate shared by the IPv6 literal classifier below: loopback,
118
+ * private (RFC1918), link-local/metadata, this-network. Mirrors the ranges
119
+ * checked textually in `validateEndpoint`.
120
+ */
121
+ function isPrivateIpv4(address: string): boolean {
122
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
123
+ if (!match) return false;
124
+ const o0 = Number(match[1]);
125
+ const o1 = Number(match[2]);
126
+ if (o0 === 127 || o0 === 0) return true;
127
+ if (o0 === 10) return true;
128
+ if (o0 === 172 && o1 >= 16 && o1 <= 31) return true;
129
+ if (o0 === 192 && o1 === 168) return true;
130
+ if (o0 === 169 && o1 === 254) return true;
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * G6: classify a parsed IPv6 literal (8 groups) as private/reserved using
136
+ * the same predicates as the IPv4 branch. Catches the forms the textual
137
+ * prefix checks miss: IPv4-compatible `::a.b.c.d`, IPv4-mapped
138
+ * `::ffff:a.b.c.d` (refused wholesale, as before), expanded spellings of
139
+ * `::1` / ULA / link-local / site-local / multicast, and zone-ids.
140
+ */
141
+ function isPrivateIpv6Groups(groups: number[]): boolean {
142
+ // Unspecified `::`.
143
+ if (groups.every((group) => group === 0)) return true;
144
+ // Loopback `::1`.
145
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) return true;
146
+ // IPv4-mapped `::ffff:a.b.c.d` — refused wholesale (existing rule).
147
+ if (groups[5] === 0xffff && groups.slice(0, 5).every((group) => group === 0)) return true;
148
+ // IPv4-compatible `::a.b.c.d` — map to IPv4, apply the IPv4 predicates.
149
+ const embedded = embeddedIpv4FromGroups(groups);
150
+ if (embedded && isPrivateIpv4(embedded)) return true;
151
+ // Unique local fc00::/7.
152
+ if ((groups[0] & 0xfe00) === 0xfc00) return true;
153
+ // Link-local fe80::/10.
154
+ if ((groups[0] & 0xffc0) === 0xfe80) return true;
155
+ // Site-local fec0::/10 (deprecated).
156
+ if ((groups[0] & 0xffc0) === 0xfec0) return true;
157
+ // Multicast ff00::/8.
158
+ if ((groups[0] & 0xff00) === 0xff00) return true;
159
+ return false;
160
+ }
161
+
162
+ /**
163
+ * G6: classify ANY IPv6 literal spelling (bracketed or bare) as
164
+ * private/reserved. Non-literals (hostnames, plain IPv4) return false —
165
+ * those are handled by `validateEndpoint`'s IPv4 branch and the DNS
166
+ * rebinding guard.
167
+ */
168
+ function isPrivateIpv6Literal(host: string): boolean {
169
+ const groups = parseIpv6Literal(unbracketHost(host));
170
+ return groups !== undefined && isPrivateIpv6Groups(groups);
171
+ }
172
+
104
173
  /**
105
174
  * CFG-6: classify a single IP address (IPv4 or IPv6) as private / reserved /
106
175
  * loopback / link-local. Used by the runtime DNS-rebinding guard below to
@@ -122,14 +191,11 @@ export function isPrivateIpAddress(address: string): boolean {
122
191
  if (o0 === 169 && o1 === 254) return true;
123
192
  return false;
124
193
  }
125
- // IPv6 (without surrounding brackets, as `dns.lookup` returns them)
126
- if (lower === "::1" || lower === "::") return true;
127
- if (lower.startsWith("fd") || lower.startsWith("fc")) return true;
128
- if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
129
- if (lower.startsWith("fec") || lower.startsWith("fed") || lower.startsWith("fee") || lower.startsWith("fef")) return true;
130
- if (lower.startsWith("ff")) return true;
131
- if (lower.startsWith("::ffff:")) return true;
132
- return false;
194
+ // IPv6 (without surrounding brackets, as `dns.lookup` returns them):
195
+ // classify through the shared literal parser (G6) so compressed,
196
+ // expanded, embedded-IPv4 and zone-id forms all reduce to the same
197
+ // predicates — the old prefix chain missed `::127.0.0.1`.
198
+ return isPrivateIpv6Literal(lower);
133
199
  }
134
200
 
135
201
  /**
@@ -142,6 +208,9 @@ export function isPrivateIpAddress(address: string): boolean {
142
208
  * allowed to fall through so the underlying `fetch` surfaces the error.
143
209
  */
144
210
  export async function assertResolvedAddressSafe(endpoint: string): Promise<void> {
211
+ // G6: bare IPv6 literal authorities are bracketed before parsing so the
212
+ // literal classifier below sees them (idempotent for normal URLs).
213
+ endpoint = bracketIpv6InUrl(endpoint);
145
214
  let url: URL;
146
215
  try {
147
216
  url = new URL(endpoint);
@@ -155,7 +224,16 @@ export async function assertResolvedAddressSafe(endpoint: string): Promise<void>
155
224
  // round-trip so we don't double-report errors the sync check already
156
225
  // produces.
157
226
  if (hostname === "localhost" || hostname.endsWith(".localhost")) return;
158
- if (hostname.startsWith("[")) return;
227
+ // G6: bracketed IPv6 literals used to bypass this layer entirely; now
228
+ // they are classified with the same predicates as `validateEndpoint`
229
+ // (defense in depth — layer 2 must not trust layer 1 having run). A
230
+ // literal needs no DNS round-trip.
231
+ if (hostname.startsWith("[")) {
232
+ if (isPrivateIpv6Literal(hostname)) {
233
+ throw new Error(`OTLP endpoint targets private/reserved IPv6 literal: ${endpoint}`);
234
+ }
235
+ return;
236
+ }
159
237
  if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return;
160
238
  let addresses: { address: string }[];
161
239
  try {
@@ -261,8 +339,12 @@ export class OTLPExporter implements MetricExporter {
261
339
  private readonly registry: MetricRegistry;
262
340
 
263
341
  constructor(opts: OTLPExporterOptions, registry: MetricRegistry) {
264
- validateEndpoint(opts.endpoint);
265
- this.opts = opts;
342
+ // G6: normalize a bare IPv6 literal authority (`http://::1:4318`) to
343
+ // bracketed form ONCE — `new URL` throws on the raw form, and both
344
+ // SSRF layers plus the actual `fetch` must see the same parseable URL.
345
+ const endpoint = bracketIpv6InUrl(opts.endpoint);
346
+ validateEndpoint(endpoint);
347
+ this.opts = { ...opts, endpoint };
266
348
  this.registry = registry;
267
349
  }
268
350