pi-crew 0.9.30 → 0.9.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.30",
3
+ "version": "0.9.31",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -139,6 +139,9 @@ export { __test__subagentSpawnParams };
139
139
  export function registerPiTeams(pi: ExtensionAPI): void {
140
140
  const disposeI18n = initI18n(pi);
141
141
  resetTimings();
142
+ // S06: Verbose/debug flags for pi-crew diagnostics
143
+ const verbose = process.env.PI_CREW_VERBOSE === "1" || process.argv.includes("--verbose");
144
+ const debug = process.env.PI_CREW_DEBUG === "1" || process.argv.includes("--debug");
142
145
  time("register:start");
143
146
  // Cold-start race fix (general): pre-warm the hot module graph NOW, during
144
147
  // single-threaded registration, before any concurrent subagent can spawn.
@@ -2,13 +2,14 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentConfig } from "../agents/agent-config.ts";
4
4
  import type { CrewLimitsConfig, CrewReliabilityConfig, CrewRuntimeConfig } from "../config/config.ts";
5
+ import { CrewError, ErrorCode } from "../errors.ts";
5
6
  import { appendHookEvent, executeHook } from "../hooks/registry.ts";
6
7
  import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
7
8
  import type { MetricRegistry } from "../observability/metric-registry.ts";
8
9
  import { PluginRegistry } from "../plugins/plugin-registry.ts";
9
10
  import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
10
11
  import { writeArtifact } from "../state/artifact-store.ts";
11
- import { appendEvent, appendEventAsync, appendEventBuffered, flushEventLogBuffer } from "../state/event-log.ts";
12
+ import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
12
13
  import { HealthStore } from "../state/health-store.ts";
13
14
  import { withRunLock } from "../state/locks.ts";
14
15
  import { loadRunManifestById, saveRunManifest, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
@@ -190,13 +191,19 @@ export function checkPerTaskBudget(
190
191
 
191
192
  function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
192
193
  const step = workflow.steps.find((candidate) => candidate.id === task.stepId);
193
- if (!step) throw new Error(`Workflow step '${task.stepId}' not found for task '${task.id}'.`);
194
+ if (!step)
195
+ throw new CrewError(ErrorCode.ResourceNotFound, `Workflow step '${task.stepId}' not found for task '${task.id}'.`).withContext(
196
+ `workflow step lookup (task=${task.id})`,
197
+ );
194
198
  return step;
195
199
  }
196
200
 
197
201
  function findAgent(agents: AgentConfig[], task: TeamTaskState): AgentConfig {
198
202
  const agent = agents.find((candidate) => candidate.name === task.agent);
199
- if (!agent) throw new Error(`Agent '${task.agent}' not found for task '${task.id}'.`);
203
+ if (!agent)
204
+ throw new CrewError(ErrorCode.ResourceNotFound, `Agent '${task.agent}' not found for task '${task.id}'.`).withContext(
205
+ `agent lookup (task=${task.id})`,
206
+ );
200
207
  return agent;
201
208
  }
202
209
 
@@ -1301,7 +1308,9 @@ async function executeTeamRunCore(
1301
1308
  };
1302
1309
  if (failed) {
1303
1310
  lastFailed = enriched;
1304
- throw new Error(failed.error ?? `Task ${task.id} failed.`);
1311
+ throw new CrewError(ErrorCode.TaskNotFound, failed.error ?? `Task ${task.id} failed.`).withContext(
1312
+ `retry evaluation (run=${manifest.runId})`,
1313
+ );
1305
1314
  }
1306
1315
  input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe(
1307
1316
  {
@@ -1652,6 +1661,29 @@ async function executeTeamRunCore(
1652
1661
  const waiting = tasks.find((task) => task.status === "waiting");
1653
1662
  const running = tasks.find((task) => task.status === "running");
1654
1663
  manifest = applyPolicy(manifest, tasks, input.limits);
1664
+
1665
+ // S02: Verify workflow-declared output files exist before marking completed
1666
+ if (input.workflow?.steps) {
1667
+ const missingOutputs: string[] = [];
1668
+ for (const step of input.workflow.steps) {
1669
+ if (step.output && typeof step.output === "string") {
1670
+ const outputPath = path.join(manifest.artifactsRoot, step.output);
1671
+ if (!fs.existsSync(outputPath)) {
1672
+ missingOutputs.push(step.output);
1673
+ }
1674
+ }
1675
+ }
1676
+ if (missingOutputs.length > 0) {
1677
+ // Emit warning event — run still completes normally to avoid hanging
1678
+ appendEventFireAndForget(manifest.eventsPath, {
1679
+ type: "run.deliverable_warning",
1680
+ runId: manifest.runId,
1681
+ message: `Missing workflow output files: ${missingOutputs.join(", ")}`,
1682
+ data: { missingFiles: missingOutputs },
1683
+ });
1684
+ }
1685
+ }
1686
+
1655
1687
  const effectiveness = evaluateRunEffectiveness({
1656
1688
  manifest,
1657
1689
  tasks,
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as fsp from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_CACHE, DEFAULT_PATHS } from "../config/defaults.ts";
4
5
  import { errors } from "../errors.ts";
@@ -83,7 +84,7 @@ function genOf(stateRoot: string): number {
83
84
  return manifestCacheGeneration.get(stateRoot) ?? 0;
84
85
  }
85
86
 
86
- const MANIFEST_CACHE_TTL_MS = 15 * 1000; // 15 seconds (FIX: increased from 5s for read-heavy workloads; 5s was too short causing unnecessary cache invalidation)
87
+ const MANIFEST_CACHE_TTL_MS = 60 * 1000; // 60 seconds (FIX: increased from 15s for read-heavy workloads; render tick 160ms caused frequent misses)
87
88
  const LOAD_MANIFEST_RETRY_LIMIT = 5; // Configurable retry limit for mtime/size stability checks under contention
88
89
  const manifestCache = new Map<string, ManifestCacheEntry>();
89
90
 
@@ -475,7 +476,7 @@ export async function saveRunTasksAsync(manifest: TeamRunManifest, tasks: TeamTa
475
476
  // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
476
477
  invalidateRunCache(manifest.stateRoot);
477
478
  try {
478
- fs.statSync(manifest.stateRoot);
479
+ await fsp.access(manifest.stateRoot);
479
480
  } catch {
480
481
  return;
481
482
  }
@@ -800,7 +801,12 @@ export async function loadRunManifestByIdAsync(
800
801
  } else if (!validateRunManifestPaths(cwd, runId, cached.manifest, stateRoot, tasksPath)) {
801
802
  manifestCache.delete(stateRoot);
802
803
  return undefined;
803
- } else if (!fs.existsSync(tasksPath)) {
804
+ } else if (
805
+ !(await fsp.access(tasksPath).then(
806
+ () => true,
807
+ () => false,
808
+ ))
809
+ ) {
804
810
  // Tasks file was deleted after cache was populated — do not serve stale cache.
805
811
  manifestCache.delete(stateRoot);
806
812
  return undefined;
@@ -28,6 +28,17 @@ import { applyStatusColor, colorizeStatusGlyphs, iconForStatus, type RunStatus }
28
28
  import type { CrewTheme } from "./theme-adapter.ts";
29
29
  import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
30
30
 
31
+ /** S05 — wrap a pane render in try/catch so a single pane crash does not bring down the whole dashboard. */
32
+ function safeRenderPane(name: string, fn: () => string[]): string[] {
33
+ try {
34
+ return fn();
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ logInternalError("run-dashboard", new Error(`Dashboard pane '${name}' render failed: ${message}`));
38
+ return [`<error: ${name}>`];
39
+ }
40
+ }
41
+
31
42
  interface DashboardComponent {
32
43
  invalidate(): void;
33
44
  render(width: number): string[];
@@ -615,20 +626,24 @@ export class RunDashboard implements DashboardComponent {
615
626
  // Pane content (max 8 lines) — F-2: colorize embedded status glyphs.
616
627
  const paneLines = snap
617
628
  ? this.activePane === "agents"
618
- ? renderAgentsPane(snap, this.options)
629
+ ? safeRenderPane("agents", () => renderAgentsPane(snap, this.options))
619
630
  : this.activePane === "progress"
620
- ? renderProgressPane(snap)
631
+ ? safeRenderPane("progress", () => renderProgressPane(snap))
621
632
  : this.activePane === "mailbox"
622
- ? renderMailboxPane(snap)
633
+ ? safeRenderPane("mailbox", () => renderMailboxPane(snap))
623
634
  : this.activePane === "health"
624
- ? renderHealthPane(snap, {
625
- isForeground: !r.async,
626
- })
635
+ ? safeRenderPane("health", () =>
636
+ renderHealthPane(snap, {
637
+ isForeground: !r.async,
638
+ }),
639
+ )
627
640
  : this.activePane === "metrics"
628
- ? renderMetricsPane(snap, {
629
- registry: this.options.registry,
630
- })
631
- : renderTranscriptPane(snap)
641
+ ? safeRenderPane("metrics", () =>
642
+ renderMetricsPane(snap, {
643
+ registry: this.options.registry,
644
+ }),
645
+ )
646
+ : safeRenderPane("transcript", () => renderTranscriptPane(snap))
632
647
  : [...readAgentPreview(r, 4, this.options), ...readProgressPreview(r, 2)];
633
648
  const filteredPane = paneLines.filter((l) => l && !l.includes("(none)") && l.trim() !== "");
634
649
  if (filteredPane.length > 0) {
@@ -0,0 +1,34 @@
1
+ import type { WorkflowConfig } from "./workflow-config.ts";
2
+
3
+ // Rough token estimates per role (input + output combined)
4
+ const ROLE_TOKEN_ESTIMATES: Record<string, number> = {
5
+ explorer: 15000,
6
+ planner: 20000,
7
+ executor: 25000,
8
+ reviewer: 12000,
9
+ "security-reviewer": 12000,
10
+ "test-engineer": 15000,
11
+ analyst: 18000,
12
+ writer: 15000,
13
+ verifier: 10000,
14
+ critic: 12000,
15
+ };
16
+
17
+ // Rough cost per 1K tokens (USD) — using a mid-range model price
18
+ const COST_PER_1K_TOKENS = 0.003;
19
+
20
+ export interface CostEstimate {
21
+ totalTokens: number;
22
+ estimatedCostUSD: number;
23
+ perStep: Array<{ stepId: string; role: string; tokens: number }>;
24
+ }
25
+
26
+ export function estimateWorkflowCost(workflow: WorkflowConfig): CostEstimate {
27
+ const perStep = workflow.steps.map((step) => {
28
+ const tokens = ROLE_TOKEN_ESTIMATES[step.role] ?? 15000; // default 15K
29
+ return { stepId: step.id, role: step.role, tokens };
30
+ });
31
+ const totalTokens = perStep.reduce((sum, s) => sum + s.tokens, 0);
32
+ const estimatedCostUSD = (totalTokens / 1000) * COST_PER_1K_TOKENS;
33
+ return { totalTokens, estimatedCostUSD, perStep };
34
+ }
@@ -18,6 +18,16 @@ export function validateWorkflowForTeam(workflow: WorkflowConfig, team: TeamConf
18
18
  }
19
19
  }
20
20
 
21
+ // Validate output field types
22
+ for (const step of workflow.steps) {
23
+ if (step.output !== undefined && step.output !== false && typeof step.output !== "string") {
24
+ errors.push(`Step '${step.id}' has invalid 'output' field: expected string or false, got ${typeof step.output}.`);
25
+ }
26
+ if (typeof step.output === "string" && step.output.trim() === "") {
27
+ errors.push(`Step '${step.id}' has empty 'output' string.`);
28
+ }
29
+ }
30
+
21
31
  const visiting = new Set<string>();
22
32
  const visited = new Set<string>();
23
33
  const byId = new Map(workflow.steps.map((step) => [step.id, step]));