pi-crew 0.9.65 → 0.9.67

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 (54) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +2 -3
  3. package/agents/executor.md +1 -1
  4. package/agents/test-engineer.md +1 -1
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +4526 -4100
  7. package/package.json +7 -4
  8. package/skills/real-test-pi-crew/SKILL.md +10 -9
  9. package/src/config/config.ts +19 -3
  10. package/src/config/role-tools.ts +2 -2
  11. package/src/config/types.ts +2 -0
  12. package/src/extension/knowledge-injection.ts +17 -0
  13. package/src/extension/pi-api.ts +0 -16
  14. package/src/extension/team-tool/api/agent-control.ts +358 -0
  15. package/src/extension/team-tool/api/handler-context.ts +57 -0
  16. package/src/extension/team-tool/api/heartbeat.ts +75 -0
  17. package/src/extension/team-tool/api/mailbox.ts +242 -0
  18. package/src/extension/team-tool/api/plan-approval.ts +190 -0
  19. package/src/extension/team-tool/api/read.ts +443 -0
  20. package/src/extension/team-tool/api/task-claims.ts +207 -0
  21. package/src/extension/team-tool/api.ts +56 -1301
  22. package/src/extension/team-tool/cancel.ts +49 -4
  23. package/src/extension/team-tool/dispatch/manage.ts +7 -4
  24. package/src/extension/team-tool/explain.ts +3 -1
  25. package/src/extension/team-tool/goal-wrap.ts +2 -2
  26. package/src/extension/team-tool/goal.ts +4 -4
  27. package/src/extension/team-tool/lifecycle-actions.ts +9 -6
  28. package/src/extension/team-tool/parallel-dispatch.ts +2 -2
  29. package/src/extension/team-tool/respond.ts +11 -3
  30. package/src/extension/team-tool/run-intent.ts +357 -0
  31. package/src/extension/team-tool/run.ts +8 -285
  32. package/src/extension/team-tool/status.ts +56 -21
  33. package/src/extension/team-tool-types.ts +2 -0
  34. package/src/extension/team-tool.ts +10 -8
  35. package/src/prompt/scratchpad-lifecycle.ts +80 -5
  36. package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
  37. package/src/runtime/child-pi/child-pi.ts +22 -144
  38. package/src/runtime/child-pi/mock-fixtures.ts +171 -0
  39. package/src/runtime/crew-agent-records.ts +18 -1
  40. package/src/runtime/output/output-validator.ts +34 -6
  41. package/src/runtime/scheduling/scheduler.ts +67 -19
  42. package/src/runtime/scratchpad/README.md +6 -0
  43. package/src/runtime/scratchpad/guest.ts +54 -5
  44. package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
  45. package/src/runtime/supervisor-contact.ts +0 -16
  46. package/src/runtime/task-runner/child-executor.ts +1 -0
  47. package/src/runtime/task-runner/state-helpers.ts +9 -1
  48. package/src/runtime/team-runner.ts +39 -2
  49. package/src/state/contracts.ts +3 -0
  50. package/src/state/event-log/event-log.ts +19 -27
  51. package/src/state/gitignore-manager.ts +61 -7
  52. package/src/utils/glob-match.ts +29 -0
  53. package/types/dwf.d.ts +1 -1
  54. package/src/types/new-api-types.ts +0 -35
@@ -1,6 +1,4 @@
1
- import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
2
1
  import { loadConfig } from "../../config/config.ts";
3
- import { sanitizeTaskText } from "../../runtime/task-packet.ts";
4
2
  // Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
5
3
  import type { executeTeamRun as ExecuteTeamRunFn } from "../../runtime/team-runner.ts";
6
4
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
@@ -8,9 +6,6 @@ import { atomicWriteJson } from "../../state/atomic-write.ts";
8
6
  import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
9
7
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
10
8
  import { createRunManifest, loadRunManifestById, updateRunStatus } from "../../state/stores/state-store.ts";
11
- import { allTeams, discoverTeams } from "../../teams/discover-teams.ts";
12
- import { allWorkflows, discoverWorkflows } from "../../workflows/discover-workflows.ts";
13
- import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktree-manager.ts";
14
9
 
15
10
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
16
11
  const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
@@ -18,7 +13,6 @@ const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamR
18
13
  import { errorMessage } from "../../utils/guards.ts";
19
14
  import { logInternalError } from "../../utils/internal-error.ts";
20
15
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
21
- import { paramRequired } from "./param-error.ts";
22
16
 
23
17
  let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
24
18
  async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
@@ -82,8 +76,8 @@ import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
82
76
  import type { PiTeamsToolResult } from "../tool-result.ts";
83
77
  import { effectiveRunConfig } from "./config-patch.ts";
84
78
  import { buildParentContext, result, type TeamContext } from "./context.ts";
85
- import { isGoalWrapEnabled, shouldGoalWrap, startGoalWrappedRun } from "./goal-wrap.ts";
86
79
  import { resolveRunDeadline } from "./run-deadline.ts";
80
+ import { validateRunIntent } from "./run-intent.ts";
87
81
 
88
82
  function tailFile(filePath: string, maxBytes = 4096): string | undefined {
89
83
  try {
@@ -131,76 +125,6 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
131
125
  timer.unref();
132
126
  }
133
127
 
134
- /**
135
- * Max analysis size in bytes — matches the `maxLength: 100_000` schema cap on
136
- * the inline `analysis` param, so the file channel can't smuggle in a larger
137
- * payload than the inline channel allows (prompt-size blowup guard).
138
- */
139
- const MAX_ANALYSIS_BYTES = 100_000;
140
-
141
- /**
142
- * Resolve the analysis channel (round-X Y1): inline `analysis` or `analysisPath` file.
143
- *
144
- * Called BEFORE `createRunManifest` so validation failures fail-fast and no orphan
145
- * run state is left on disk. Sanitizes the parsed content with the same
146
- * {@link sanitizeTaskText} machinery used in `buildTaskPacket` (SEC-007), since the
147
- * resulting text is injected into worker prompts via standard sharedReads.
148
- *
149
- * Mutual exclusivity mirrors the `budgetTotal`/`budgetUnlimited` pattern in
150
- * `goal.ts` — a cold-review #2 blocking fix pattern.
151
- */
152
- function resolveAnalysisText(
153
- params: TeamToolParamsValue,
154
- cwd: string,
155
- ): { text?: string; error?: string; source: "inline" | "path" | "none" } {
156
- const hasInline = typeof params.analysis === "string" && params.analysis.length > 0;
157
- const hasPath = typeof params.analysisPath === "string" && params.analysisPath.length > 0;
158
-
159
- if (hasInline && hasPath) {
160
- return {
161
- error: "`analysis` and `analysisPath` are mutually exclusive. Set exactly one.",
162
- source: "none",
163
- };
164
- }
165
- if (!hasInline && !hasPath) return { source: "none" };
166
-
167
- if (hasPath) {
168
- let resolved: string;
169
- try {
170
- resolved = resolveRealContainedPath(cwd, params.analysisPath as string);
171
- } catch {
172
- return {
173
- error: `analysisPath must be within project directory: ${params.analysisPath}`,
174
- source: "none",
175
- };
176
- }
177
- if (!fs.existsSync(resolved)) {
178
- return {
179
- error: `Analysis file not found: ${resolved}`,
180
- source: "none",
181
- };
182
- }
183
- // Size cap BEFORE reading: mirror the inline schema cap (maxLength 100_000)
184
- // so a large file can't blow up worker prompts via the sharedReads channel.
185
- const { size } = fs.statSync(resolved);
186
- if (size > MAX_ANALYSIS_BYTES) {
187
- return {
188
- error: `Analysis file too large: ${size} bytes (max ${MAX_ANALYSIS_BYTES}). Trim the analysis or pass a summary inline.`,
189
- source: "none",
190
- };
191
- }
192
- const raw = fs.readFileSync(resolved, "utf-8");
193
- const sanitized = sanitizeTaskText(raw);
194
- if (!sanitized) return { source: "none" };
195
- return { text: sanitized, source: "path" };
196
- }
197
-
198
- // hasInline
199
- const sanitized = sanitizeTaskText(params.analysis as string);
200
- if (!sanitized) return { source: "none" };
201
- return { text: sanitized, source: "inline" };
202
- }
203
-
204
128
  /**
205
129
  * Options for {@link formatRunResult} (EXT-2 refactor).
206
130
  */
@@ -390,215 +314,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
390
314
  const { handleChainRun } = await import("./chain-dispatch.ts");
391
315
  return handleChainRun(params, ctx, handleRun);
392
316
  }
393
- const goal = params.goal ?? params.task;
394
- if (!goal)
395
- return result(
396
- paramRequired("run", "goal or task", "{ action: 'run', goal: '<what to achieve>' }"),
397
- { action: "run", status: "error" },
398
- true,
399
- );
400
- const intentPrefix = goal.length > 60 ? `${goal.slice(0, 57)}...` : goal;
401
-
402
- // P0: Ensure .crew directory structure exists before creating any manifests.
403
- // Latched dynamic import (loadCrewInit) — concurrent `team` tool calls from
404
- // N subagents share ONE in-flight promise so crew-init.ts's module body
405
- // evaluates exactly once (avoids the cold-start race on CREW_README / path / fs).
406
- const workingDir = ctx.cwd ?? process.cwd();
407
- const { ensureCrewDirectory } = await loadCrewInit();
408
- await ensureCrewDirectory(workingDir);
409
-
410
- // WORKTREE FIX: If worktree mode is needed but cwd is not a git repo,
411
- // auto-correct to the nearest git repo root. This prevents "not a git repository"
412
- // errors when ctx.cwd points to a parent directory that isn't a git repo.
413
- let resolvedCtx = ctx;
414
- if (workingDir) {
415
- try {
416
- const gitRoot = await findGitRootAsync(workingDir);
417
- if (gitRoot && gitRoot !== workingDir) {
418
- resolvedCtx = { ...ctx, cwd: gitRoot };
419
- }
420
- } catch {
421
- // cwd is not in a git repo — validate below if worktree mode is needed
422
- }
423
- }
424
-
425
- // WORKTREE PRECONDITION CHECK: validate git repo exists and is clean
426
- // BEFORE creating the run manifest, so we return a friendly error
427
- // instead of crashing mid-execution in prepareTaskWorkspace.
428
- if (params.workspaceMode === "worktree") {
429
- let gitRoot: string | undefined;
430
- try {
431
- gitRoot = await findGitRootAsync(resolvedCtx.cwd);
432
- } catch {
433
- // not a git repo
434
- }
435
- if (!gitRoot) {
436
- return result(
437
- `Worktree mode requires a git repository. '${resolvedCtx.cwd}' is not inside a git repo.\nUse workspaceMode: 'single' or run from a git repository.`,
438
- { action: "run", status: "error" },
439
- true,
440
- );
441
- }
442
- // Check if clean leader is required (can be disabled via config)
443
- const preCheckConfig = loadConfig(resolvedCtx.cwd);
444
- if (preCheckConfig.config.requireCleanWorktreeLeader !== false) {
445
- try {
446
- await assertCleanLeaderAsync(gitRoot);
447
- } catch (err) {
448
- const msg = errorMessage(err);
449
- return result(
450
- `${msg}\nCommit or stash changes before using worktree mode, or use workspaceMode: 'single'.`,
451
- { action: "run", status: "error" },
452
- true,
453
- );
454
- }
455
- }
456
- }
457
-
458
- const teams = allTeams(discoverTeams(resolvedCtx.cwd));
459
- const workflows = allWorkflows(discoverWorkflows(resolvedCtx.cwd));
460
- const agents = allAgents(discoverAgents(resolvedCtx.cwd));
461
- const directAgent = params.agent ? agents.find((item) => item.name === params.agent) : undefined;
462
- if (params.agent && !directAgent) return result(`Agent '${params.agent}' not found.`, { action: "run", status: "error" }, true);
463
- const teamName = params.team ?? "default";
464
- const team = directAgent
465
- ? {
466
- name: `direct-${directAgent.name}`,
467
- description: `Direct subagent run for ${directAgent.name}`,
468
- source: "builtin" as const,
469
- filePath: "<generated>",
470
- roles: [
471
- {
472
- name: params.role ?? "agent",
473
- agent: directAgent.name,
474
- description: directAgent.description,
475
- },
476
- ],
477
- defaultWorkflow: "direct-agent",
478
- workspaceMode: params.workspaceMode,
479
- }
480
- : teams.find((item) => item.name === teamName);
481
- if (!team) return result(`Team '${teamName}' not found.`, { action: "run", status: "error" }, true);
482
- // BUG-44 (github #44): `chain` is a dispatcher-only workflow — the chain runner owns
483
- // step execution, so it is never runnable via the normal executeTeamRun path (its
484
- // .workflow.md parses to doc headings, and validation fails fast with a confusing
485
- // error). If a caller (or a chain step forwarding params.workflow) asks for
486
- // workflow='chain' WITHOUT a chain param, fall back to the team's default workflow
487
- // instead of failing. Chain steps are the only realistic producers of this state;
488
- // handleChainRun itself routes via params.chain before reaching here.
489
- const workflowName = directAgent
490
- ? "direct-agent"
491
- : params.workflow === "chain" && !params.chain
492
- ? (team.defaultWorkflow ?? "default")
493
- : (params.workflow ?? team.defaultWorkflow ?? "default");
494
- const baseWorkflow = directAgent
495
- ? {
496
- name: "direct-agent",
497
- description: `Direct task for ${directAgent.name}`,
498
- source: "builtin" as const,
499
- filePath: "<generated>",
500
- steps: [
501
- {
502
- id: "01_agent",
503
- role: params.role ?? "agent",
504
- task: "{goal}",
505
- model: params.model,
506
- reads: params.analysis || params.analysisPath ? ["analysis.md"] : undefined,
507
- },
508
- ],
509
- }
510
- : workflows.find((item) => item.name === workflowName);
511
- if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
512
-
513
- // ANALYSIS CHANNEL (round-X Y1): resolve analysis text from inline or file BEFORE
514
- // createRunManifest so validation errors fail-fast (no orphan run state).
515
- const analysisParam = resolveAnalysisText(params, resolvedCtx.cwd);
516
- if (analysisParam.error) return result(analysisParam.error, { action: "run", status: "error" }, true);
517
-
518
- // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { expandParallelResearchWorkflow }` above (issue #28, RFC 17). At call time the module body has fully evaluated, so the dynamic import returns a live binding. Multi-line form breaks scripts/check-lazy-imports.mjs (which does `lines[lineNum - 2]`), so keep destructuring + await import on one line.
519
- const { expandParallelResearchWorkflow: expandParallelResearch } = await import("../../runtime/scheduling/parallel-research.ts");
520
- const workflow = directAgent ? baseWorkflow : expandParallelResearch(baseWorkflow, resolvedCtx.cwd);
521
- const isDynamicWorkflow =
522
- !directAgent && (workflow as import("../../workflows/workflow-config.ts").WorkflowConfig).runtime === "dynamic";
523
- const effectiveRunKind = isDynamicWorkflow ? params.runKind : undefined;
524
- if (params.runKind !== undefined && !isDynamicWorkflow) {
525
- logInternalError(
526
- "team-tool.run.runKindIgnored",
527
- new Error(`Ignoring runKind='${params.runKind}' because workflow '${workflow.name}' is not dynamic.`),
528
- undefined,
529
- "warn",
530
- );
531
- }
532
-
533
- // PREFLIGHT (advisory only, since v0.9.15): classify workflow topology and emit
534
- // informational notes per the rule in .crew/knowledge.md "pi-crew USAGE THRESHOLD
535
- // RULE". Never blocks execution — the agent (caller) decides whether to proceed,
536
- // refactor, or override. This honors Pi's design philosophy: tooling provides
537
- // information, agents exercise judgment.
538
- // Skip for direct-agent runs (they bypass the workflow system entirely).
539
- if (!directAgent) {
540
- // LAZY: defer preflight-validator import until a team run actually requests it.
541
- const { validateWorkflowUsage } = await import("../../workflows/preflight-validator.ts");
542
- const preflight = validateWorkflowUsage(workflow, {
543
- force: params.force === true,
544
- });
545
- const icon = preflight.level === "warn" ? "⚠️ " : preflight.level === "note" ? "ℹ️ " : "";
546
- const tag = preflight.level.toUpperCase();
547
- console.warn(`${icon}[team-tool.preflight] ${tag}: ${preflight.message} (workflow=${workflow.name})`);
548
- if (preflight.suggestion) {
549
- console.warn(`[team-tool.preflight] → ${preflight.suggestion}`);
550
- }
551
- }
552
-
553
- // RFC v0.5 vision: goal-wrap. If .crew/config.json has goalWrap[workflow.name].enabled=true,
554
- // route to a goal loop where this workflow runs as the worker turn (judge → feedback → redo
555
- // until achieved). Only for eligible builtins (implementation, fast-fix, default). Per-workflow
556
- // toggle; OFF by default. See goal-wrap.ts.
557
- //
558
- // SAFETY (commit b57bad3): multi-step workflows crash non-deterministically
559
- // in the background goal-loop process (V8/libuv race in team-runner batch
560
- // transition). When goal-wrap is unsafe for this workflow, we do NOT error
561
- // out — we fall through to the normal team-run path so the user still gets
562
- // the run they asked for. The disabled reason is logged for traceability.
563
- if (!directAgent && workflow.source === "builtin" && isGoalWrapEnabled(resolvedCtx.cwd, workflow.name)) {
564
- const decision = shouldGoalWrap(resolvedCtx.cwd, workflow);
565
- if (decision.enabled) {
566
- if (analysisParam.text) {
567
- console.warn(
568
- `[team-tool.run] analysis param is ignored by goal-wrapped run (workflow=${workflow.name}). The analysis artifact will not be written.`,
569
- );
570
- }
571
- return await startGoalWrappedRun(params, ctx, workflow, goal);
572
- }
573
- // goal-wrap disabled for this workflow — fall through silently to normal
574
- // team-run path. Log the reason so it's discoverable in events.jsonl and
575
- // debug logs. This preserves the trace of WHY goal-wrap was bypassed for
576
- // a given run (vs. just disappearing without explanation).
577
- if (decision.message) {
578
- logInternalError(
579
- "team-tool.run.goalWrapBypassed",
580
- new Error(decision.message),
581
- `workflow=${workflow.name} reason=${decision.reason}`,
582
- );
583
- }
584
- }
585
-
586
- // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { validateWorkflowForTeam }` above (issue #28, RFC 17).
587
- const { validateWorkflowForTeam: validateWorkflow } = await import("../../workflows/validate-workflow.ts");
588
- const validationErrors = validateWorkflow(workflow, team);
589
- if (validationErrors.length > 0) {
590
- return result(
591
- [`Workflow '${workflow.name}' is not valid for team '${team.name}':`, ...validationErrors.map((error) => `- ${error}`)].join(
592
- "\n",
593
- ),
594
- { action: "run", status: "error" },
595
- true,
596
- );
597
- }
598
317
 
599
- // LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { normalizeSkillOverride }` above (issue #28, RFC 17).
600
- const { normalizeSkillOverride: normalizeSkill } = await import("../../runtime/skill-instructions.ts");
601
- const skillOverride = normalizeSkill(params.skill);
318
+ // H3 phase 4 (2026-08-10): validation extracted to run-intent.ts. Returns
319
+ // a validated RunIntent (goal/team/workflow/analysis/skillOverride…) or an
320
+ // error result with the exact precedence order the run tests assert.
321
+ const validated = await validateRunIntent(params, ctx);
322
+ if (validated.kind === "error") return validated.result;
323
+ const { goal, resolvedCtx, directAgent, team, workflow, agents, analysisParam, isDynamicWorkflow, effectiveRunKind, skillOverride } =
324
+ validated.intent;
602
325
  const { manifest, tasks, paths } = createRunManifest({
603
326
  cwd: resolvedCtx.cwd,
604
327
  team,
@@ -19,6 +19,25 @@ import { result, type TeamContext } from "./context.ts";
19
19
  import { paramRequired } from "./param-error.ts";
20
20
  import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
21
21
 
22
+ // H5 (2026-08-10): idempotency guard for the stale-async side-effect below.
23
+ // Without this, every status poll of a dead async run (dashboard refresh,
24
+ // status-bar tick, broker RPC) re-runs saveRunTasks + appendEvent — each
25
+ // costing ~30ms of blocking I/O on what callers treat as a read-only path.
26
+ // The runId is added the first time we transition it; it's never removed
27
+ // (a real resurrection goes through a fresh runId, not a status query).
28
+ // Bounded FIFO to avoid unbounded growth from long-lived sessions.
29
+ const STALE_ASYNC_MARKED = new Set<string>();
30
+ const STALE_ASYNC_MARKED_MAX = 256;
31
+ function markStaleAsync(runId: string): boolean {
32
+ if (STALE_ASYNC_MARKED.has(runId)) return false;
33
+ STALE_ASYNC_MARKED.add(runId);
34
+ if (STALE_ASYNC_MARKED.size > STALE_ASYNC_MARKED_MAX) {
35
+ const oldest = STALE_ASYNC_MARKED.keys().next().value;
36
+ if (oldest !== undefined) STALE_ASYNC_MARKED.delete(oldest);
37
+ }
38
+ return true;
39
+ }
40
+
22
41
  export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
23
42
  if (!params.runId)
24
43
  return result(
@@ -41,24 +60,32 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
41
60
  const liveness = checkProcessLiveness(asyncState.pid);
42
61
  asyncLivenessLine = `Async: pid=${asyncState.pid ?? "unknown"} alive=${liveness.alive ? "true" : "false"} detail=${liveness.detail} log=${asyncState.logPath} spawnedAt=${asyncState.spawnedAt}`;
43
62
  if (!liveness.alive && isActiveRunStatus(manifest.status)) {
44
- manifest = updateRunStatus(manifest, "failed", `Async process stale: ${liveness.detail}`);
45
- tasks = tasks.map((task) =>
46
- task.status === "running"
47
- ? {
48
- ...task,
49
- status: "cancelled" as const,
50
- finishedAt: new Date().toISOString(),
51
- error: "Async process died; task was not completed.",
52
- }
53
- : task,
54
- );
55
- saveRunTasks(manifest, tasks);
56
- appendEvent(manifest.eventsPath, {
57
- type: "async.stale",
58
- runId: manifest.runId,
59
- message: liveness.detail,
60
- data: { pid: asyncState.pid },
61
- });
63
+ // H5: only transition once per runId. Repeated status polls of a
64
+ // dead async run previously re-ran saveRunTasks + sync appendEvent
65
+ // on every call (~30ms blocking each).
66
+ if (markStaleAsync(manifest.runId)) {
67
+ manifest = updateRunStatus(manifest, "failed", `Async process stale: ${liveness.detail}`);
68
+ tasks = tasks.map((task) =>
69
+ task.status === "running"
70
+ ? {
71
+ ...task,
72
+ status: "cancelled" as const,
73
+ finishedAt: new Date().toISOString(),
74
+ error: "Async process died; task was not completed.",
75
+ }
76
+ : task,
77
+ );
78
+ saveRunTasks(manifest, tasks);
79
+ // async.stale (2026-08-10): sync append (byte-identical to pre-extract
80
+ // api.ts) so callers reading eventsPath immediately after status see
81
+ // the event — async fire-and-forget would race.
82
+ appendEvent(manifest.eventsPath, {
83
+ type: "async.stale",
84
+ runId: manifest.runId,
85
+ message: liveness.detail,
86
+ data: { pid: asyncState.pid },
87
+ });
88
+ }
62
89
  }
63
90
  }
64
91
  const counts = new Map<string, number>();
@@ -74,7 +101,14 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
74
101
  const attentionByTask = new Map(
75
102
  allEvents.filter((event) => event.type === "task.attention" && event.taskId).map((event) => [event.taskId!, event]),
76
103
  );
77
- const controlConfig = resolveCrewControlConfig(loadConfig(ctx.cwd).config);
104
+ // H5 (2026-08-10): hoist the config load. Previously three separate
105
+ // loadConfig(ctx.cwd) calls at lines 77, 85, 119 each stat'd up to 4 files;
106
+ // status is called per dashboard refresh / RPC poll, so this tripled the
107
+ // stat cost on every tick. loadConfig is mtime-cached internally so the
108
+ // three calls returned the same object, but each still paid the cache
109
+ // lookup + stat syscall. One call, reused everywhere.
110
+ const cfg = loadConfig(ctx.cwd).config;
111
+ const controlConfig = resolveCrewControlConfig(cfg);
78
112
  const crewAgents = readCrewAgents(manifest).map((agent) => applyAttentionState(manifest, agent, controlConfig));
79
113
  const artifactLines = manifest.artifacts
80
114
  .slice(-10)
@@ -82,7 +116,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
82
116
  (artifact) => `- ${artifact.kind}: ${artifact.path}${artifact.sizeBytes !== undefined ? ` (${artifact.sizeBytes} bytes)` : ""}`,
83
117
  );
84
118
  const deliveryState = readDeliveryState(manifest);
85
- const ackTimeoutMs = loadConfig(ctx.cwd).config.runtime?.groupJoinAckTimeoutMs;
119
+ const ackTimeoutMs = cfg.runtime?.groupJoinAckTimeoutMs;
86
120
  const groupJoinLines: string[] = [];
87
121
  for (const message of readMailbox(manifest, "outbox")
88
122
  .filter((m) => m.data?.kind === "group_join")
@@ -92,6 +126,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
92
126
  const requestId = String(message.data?.requestId ?? "unknown");
93
127
  const timedOut = ack === "pending" && ackTimeoutMs !== undefined && Number.isFinite(ageMs) && ageMs > ackTimeoutMs;
94
128
  if (timedOut && !ackTimeoutRequestIds.has(requestId)) {
129
+ // ack_timeout: sync append (byte-identical to pre-extract api.ts).
95
130
  appendEvent(manifest.eventsPath, {
96
131
  type: "agent.group_join.ack_timeout",
97
132
  runId: manifest.runId,
@@ -116,7 +151,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
116
151
  manifest,
117
152
  tasks,
118
153
  executeWorkers: manifest.runtimeResolution?.kind !== "scaffold",
119
- runtimeConfig: loadConfig(ctx.cwd).config.runtime,
154
+ runtimeConfig: cfg.runtime,
120
155
  });
121
156
  const noObservedWorkTasks = effectiveness.noObservedWorkTaskIds
122
157
  .map((id) => tasks.find((task) => task.id === id))
@@ -10,6 +10,8 @@ export interface TeamToolDetails {
10
10
  resumedIds?: string[];
11
11
  retriedTaskIds?: string[];
12
12
  mailboxIds?: string[];
13
+ /** Whether a config write actually persisted (false on no-op/skip-write). */
14
+ written?: boolean;
13
15
  /** Resource scope affected by the action (e.g. cleanup: "project"). */
14
16
  scope?: string;
15
17
  /** Run metrics for compact display in TUI tool result rendering. */
@@ -10,11 +10,12 @@ import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
10
10
  import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
11
11
  import { withRunLock } from "../state/coordination/locks.ts";
12
12
  import { replayPendingMailboxMessages } from "../state/coordination/mailbox.ts";
13
- import { appendEvent, appendEventFireAndForget } from "../state/event-log/event-log.ts";
13
+ import { appendEventAsync, appendEventFireAndForget } from "../state/event-log/event-log.ts";
14
14
  import { writeArtifact } from "../state/stores/artifact-store.ts";
15
15
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
16
16
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
17
17
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
18
+ import { logInternalError } from "../utils/internal-error.ts";
18
19
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
19
20
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
20
21
  import { listRuns } from "./run-index.ts";
@@ -388,7 +389,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
388
389
  updatedAt: new Date().toISOString(),
389
390
  };
390
391
  await saveRunManifestAsync(runtimeManifest);
391
- appendEvent(runtimeManifest.eventsPath, {
392
+ await appendEventAsync(runtimeManifest.eventsPath, {
392
393
  type: "runtime.resolved",
393
394
  runId: runtimeManifest.runId,
394
395
  message: `Runtime resolved for resume: ${runtime.kind} safety=${runtime.safety}`,
@@ -401,7 +402,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
401
402
  "blocked",
402
403
  runtime.reason ?? "Child worker execution is disabled; refusing to resume with no-op scaffold subagents.",
403
404
  );
404
- appendEvent(blocked.eventsPath, {
405
+ await appendEventAsync(blocked.eventsPath, {
405
406
  type: "run.blocked",
406
407
  runId: blocked.runId,
407
408
  message: blocked.summary,
@@ -442,7 +443,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
442
443
  );
443
444
  saveRunTasks(runtimeManifest, resetTasks);
444
445
  const replay = replayPendingMailboxMessages(runtimeManifest);
445
- appendEvent(runtimeManifest.eventsPath, {
446
+ await appendEventAsync(runtimeManifest.eventsPath, {
446
447
  type: "run.resume_requested",
447
448
  runId: runtimeManifest.runId,
448
449
  data: {
@@ -451,14 +452,14 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
451
452
  },
452
453
  });
453
454
  if (recovered.recovered.length)
454
- appendEvent(runtimeManifest.eventsPath, {
455
+ await appendEventAsync(runtimeManifest.eventsPath, {
455
456
  type: "task.checkpoint_recovered",
456
457
  runId: runtimeManifest.runId,
457
458
  message: `Recovered ${recovered.recovered.length} task(s) from artifact-written checkpoints.`,
458
459
  data: { taskIds: recovered.recovered },
459
460
  });
460
461
  if (replay.messages.length)
461
- appendEvent(runtimeManifest.eventsPath, {
462
+ await appendEventAsync(runtimeManifest.eventsPath, {
462
463
  type: "mailbox.replayed",
463
464
  runId: runtimeManifest.runId,
464
465
  message: `Replayed ${replay.messages.length} pending inbox message(s).`,
@@ -571,12 +572,13 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
571
572
  } catch {
572
573
  // Best-effort: file write failure doesn't block the steer from pending array
573
574
  }
574
- appendEvent(loaded.manifest.eventsPath, {
575
+ // H1 (2026-08-10): handleSteer is a SYNC function — fire-and-forget async.
576
+ void appendEventAsync(loaded.manifest.eventsPath, {
575
577
  type: "task.steer_queued",
576
578
  runId,
577
579
  taskId,
578
580
  data: { message },
579
- });
581
+ }).catch((error) => logInternalError("steer.event", error instanceof Error ? error : new Error(String(error)), `runId=${runId}`));
580
582
  return result(`Steer queued for task '${taskId}'. It will be delivered when the task's session is ready.`, {
581
583
  action: "steer",
582
584
  status: "ok",