pi-crew 0.9.64 → 0.9.65

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 (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +46 -1
  3. package/dist/index.mjs +316 -299
  4. package/package.json +3 -2
  5. package/scripts/analyze-run.mjs +1333 -0
  6. package/scripts/pty_probe.py +10 -8
  7. package/scripts/resource-sampler.mjs +482 -0
  8. package/skills/real-test-pi-crew/SKILL.md +6 -6
  9. package/src/observability/event-to-metric.ts +29 -0
  10. package/src/observability/metrics-primitives.ts +41 -3
  11. package/src/runtime/README.md +1 -1
  12. package/src/runtime/broker/crew-broker.ts +0 -16
  13. package/src/runtime/effectiveness.ts +23 -1
  14. package/src/runtime/merge-gate.ts +202 -0
  15. package/src/runtime/model/model-fallback.ts +11 -0
  16. package/src/runtime/model/provider-extensions.ts +31 -12
  17. package/src/runtime/output/progress-tracker.ts +3 -33
  18. package/src/runtime/scratchpad/engine.ts +40 -2
  19. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  20. package/src/runtime/team-runner.ts +128 -203
  21. package/src/schema/team-tool-schema.ts +2 -0
  22. package/src/teams/discover-teams.ts +2 -0
  23. package/src/teams/team-config.ts +7 -0
  24. package/src/teams/team-serializer.ts +1 -0
  25. package/src/ui/mascot.ts +1 -14
  26. package/teams/default.team.md +1 -0
  27. package/teams/fast-fix.team.md +1 -0
  28. package/src/observability/event-bus.ts +0 -86
  29. package/src/plugins/plugin-define.ts +0 -6
  30. package/src/plugins/plugin-registry.ts +0 -32
  31. package/src/plugins/plugins/index.ts +0 -3
  32. package/src/plugins/plugins/nextjs.ts +0 -19
  33. package/src/plugins/plugins/vite.ts +0 -10
  34. package/src/plugins/plugins/vitest.ts +0 -9
  35. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  36. package/src/runtime/iteration-hooks.ts +0 -305
@@ -1,15 +1,15 @@
1
+ import { spawn } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import type { AgentConfig } from "../agents/agent-config.ts";
4
6
  import type { CrewLimitsConfig, CrewReliabilityConfig, CrewRuntimeConfig } from "../config/config.ts";
5
7
  import { CrewError, ErrorCode } from "../errors.ts";
6
8
  import { appendHookEvent, executeHook } from "../hooks/registry.ts";
7
9
  import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
8
10
  import type { MetricRegistry } from "../observability/metric-registry.ts";
9
- import { PluginRegistry } from "../plugins/plugin-registry.ts";
10
- import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
11
11
  import { atomicWriteFile, flushPendingAtomicWrites } from "../state/atomic-write.ts";
12
- import { canTransitionRunStatus, TEAM_TASK_STATUSES, TEAM_TERMINAL_TASK_STATUSES, type TeamTaskStatus } from "../state/contracts.ts";
12
+ import { canTransitionRunStatus } from "../state/contracts.ts";
13
13
  import { withRunLock } from "../state/coordination/locks.ts";
14
14
  import {
15
15
  appendEvent,
@@ -35,6 +35,7 @@ import { effectivenessPolicyDecision, evaluateRunEffectiveness, formatRunEffecti
35
35
  import { applyGoalAchievement, assessGoalAchievement } from "./goal-workflow/goal-achievement.ts";
36
36
  import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
37
37
  import { terminateLiveAgentsForRun } from "./live-session/live-agent-manager.ts";
38
+ import { isNonTerminalTaskStatus, mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
38
39
  import { resolveTaskRuntimeKind } from "./model/runtime-policy.ts";
39
40
  import type { CrewRuntimeCapabilities } from "./model/runtime-resolver.ts";
40
41
  import { filterReadyByWriteOverlap } from "./path-overlap.ts";
@@ -70,16 +71,6 @@ import {
70
71
  type WorkflowStateMachine,
71
72
  } from "./workflow-state.ts";
72
73
 
73
- // Built-in plugin registry for framework awareness.
74
- // NOTE: This registry is registered here for future use. The integration
75
- // point (reading package.json deps, computing active plugin context, and
76
- // passing it into RunConfig / task-runner) is not yet implemented; see
77
- // `getPluginContext` in src/plugins/plugin-context.ts (planned).
78
- const builtInRegistry = new PluginRegistry();
79
- builtInRegistry.register(NextJsPlugin);
80
- builtInRegistry.register(VitestPlugin);
81
- builtInRegistry.register(VitePlugin);
82
-
83
74
  /**
84
75
  * Start a periodic heartbeat for the team-level run.
85
76
  *
@@ -128,6 +119,113 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
128
119
  return () => clearInterval(interval);
129
120
  }
130
121
 
122
+ // ─── Perf observability (auto-attach, toggle per team) ─────────────────────
123
+ // "Bộ đo luôn hoạt động khi pi-crew chạy": unless the team frontmatter says
124
+ // `observability: false`, every run spawns the external resource sampler
125
+ // (scripts/resource-sampler.mjs --watch-run — auto-resolves the runner PID
126
+ // from state and auto-stops when the runner dies) and, after completion,
127
+ // runs analyze-run to emit the perf report. Both are detached child
128
+ // processes so a failure in the tooling never affects the run itself.
129
+ const OBSERVABILITY_INTERVAL_MS = 2000;
130
+ const OBSERVABILITY_ANALYZE_DELAY_MS = 3000;
131
+
132
+ function perfScriptPath(scriptName: string): string | undefined {
133
+ try {
134
+ // Two layouts: dev (src/runtime/team-runner.ts → ../../scripts) and
135
+ // bundled (dist/index.mjs → ../scripts). Try both, use whichever exists.
136
+ const candidates = [
137
+ fileURLToPath(new URL(`../../scripts/${scriptName}`, import.meta.url)),
138
+ fileURLToPath(new URL(`../scripts/${scriptName}`, import.meta.url)),
139
+ ];
140
+ return candidates.find((p) => fs.existsSync(p));
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ }
145
+
146
+ function startPerfSampler(manifest: TeamRunManifest, team: TeamConfig): void {
147
+ // DIRECT fs marker (console may be swallowed by the host) — every branch
148
+ // writes artifacts/<runId>/perf-obs.log with the exact reason.
149
+ const marker = (msg: string): void => {
150
+ try {
151
+ fs.appendFileSync(path.join(manifest.artifactsRoot, "perf-obs.log"), `[${new Date().toISOString()}] ${msg}\n`);
152
+ } catch {
153
+ /* best-effort */
154
+ }
155
+ };
156
+ marker(`startPerfSampler entered (team=${team.name} observability=${String(team.observability)} importMetaUrl=${import.meta.url})`);
157
+ // Strict true: parsed team files default observability to true (parseTeamFile),
158
+ // while direct-object TeamConfig fixtures (unit tests) stay undefined and
159
+ // therefore do NOT spawn the sampler — keeps test isolation.
160
+ if (team.observability !== true) {
161
+ marker(`SKIP: observability=${String(team.observability)} !== true`);
162
+ return;
163
+ }
164
+ const samplerPath = perfScriptPath("resource-sampler.mjs");
165
+ if (!samplerPath) {
166
+ marker(`SKIP: resource-sampler.mjs not found (importMetaUrl=${import.meta.url})`);
167
+ return;
168
+ }
169
+ marker(`spawning sampler from ${samplerPath}`);
170
+ const crewRoot = path.dirname(path.dirname(path.dirname(manifest.stateRoot)));
171
+ const outPath = path.join(manifest.artifactsRoot, "resources.jsonl");
172
+ const logPath = path.join(manifest.artifactsRoot, "perf-obs.log");
173
+ try {
174
+ const child = spawn(
175
+ process.execPath,
176
+ [
177
+ "--experimental-strip-types",
178
+ samplerPath,
179
+ "--watch-run",
180
+ manifest.runId,
181
+ "--crew-root",
182
+ crewRoot,
183
+ "--interval",
184
+ String(OBSERVABILITY_INTERVAL_MS),
185
+ "--out",
186
+ outPath,
187
+ ],
188
+ { detached: true, stdio: ["ignore", "ignore", "pipe"] },
189
+ );
190
+ // diagnostics: sampler stderr → perf-obs.log (best-effort; never affects the run)
191
+ child.stderr?.on("data", (d: Buffer) => {
192
+ try {
193
+ fs.appendFileSync(logPath, String(d));
194
+ } catch {
195
+ /* best-effort */
196
+ }
197
+ });
198
+ child.unref();
199
+ } catch (err) {
200
+ console.warn(`[perf-obs] sampler spawn failed for ${manifest.runId}: ${String(err)}`);
201
+ }
202
+ }
203
+
204
+ function schedulePerfAnalyze(manifest: TeamRunManifest, team: TeamConfig): void {
205
+ // Strict true — same test-isolation rationale as startPerfSampler.
206
+ if (team.observability !== true) return;
207
+ const analyzePath = perfScriptPath("analyze-run.mjs");
208
+ const resourcesPath = path.join(manifest.artifactsRoot, "resources.jsonl");
209
+ // Only analyze when the sampler actually produced data (sampler may have
210
+ // been skipped/failed to spawn) AND the scripts exist.
211
+ if (!analyzePath || !fs.existsSync(resourcesPath)) return;
212
+ const crewRoot = path.dirname(path.dirname(path.dirname(manifest.stateRoot)));
213
+ // Delay so child-worker transcripts are fully flushed before analyze reads them.
214
+ const timer = setTimeout(() => {
215
+ try {
216
+ const child = spawn(
217
+ process.execPath,
218
+ ["--experimental-strip-types", analyzePath, manifest.runId, "--crew-root", crewRoot, "--resources", resourcesPath],
219
+ { detached: true, stdio: "ignore" },
220
+ );
221
+ child.unref();
222
+ } catch (err) {
223
+ console.warn(`[perf-obs] analyze spawn failed for ${manifest.runId}: ${String(err)}`);
224
+ }
225
+ }, OBSERVABILITY_ANALYZE_DELAY_MS);
226
+ timer.unref();
227
+ }
228
+
131
229
  export interface ExecuteTeamRunInput {
132
230
  manifest: TeamRunManifest;
133
231
  tasks: TeamTaskState[];
@@ -241,9 +339,12 @@ function markBlocked(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
241
339
  );
242
340
  }
243
341
 
244
- function isNonTerminalTaskStatus(status: TeamTaskState["status"]): boolean {
245
- return status === "queued" || status === "running" || status === "waiting";
246
- }
342
+ // isNonTerminalTaskStatus, safeFinishedAt, isMalformedFinishedAtReplacement,
343
+ // statusMergeKey, REJECTED_STATUS_MERGE_TRANSITIONS, shouldMergeTaskUpdate,
344
+ // __test__shouldMergeTaskUpdate, mergeTaskUpdatesPreservingTerminal,
345
+ // __test__mergeTaskUpdates — moved to ./merge-gate.ts (2026-08-10
346
+ // improvement-plan Tier 2 team-runner split, self-contained portion).
347
+ // Re-imported below to preserve all in-file callers.
247
348
 
248
349
  /**
249
350
  * CORE-6: Unified cancel/fail of non-terminal tasks. Replaces hand-rolled
@@ -281,193 +382,6 @@ export function cancelNonTerminalTasks(
281
382
  });
282
383
  }
283
384
 
284
- /**
285
- * Returns the finishedAt timestamp as a number, or Infinity for invalid/malformed dates.
286
- * This makes comparison logic in shouldMergeTaskUpdate more readable by abstracting
287
- * the NaN handling into a single well-named function.
288
- */
289
- function safeFinishedAt(task: TeamTaskState): number {
290
- if (!task.finishedAt) return -Infinity;
291
- const ms = new Date(task.finishedAt).getTime();
292
- return Number.isNaN(ms) ? Infinity : ms;
293
- }
294
-
295
- /**
296
- * Returns true when the current task has a malformed finishedAt (NaN/Infinity)
297
- * and the updated task has a valid finite finishedAt. Malformed finishedAt
298
- * should be replaced rather than persisting corruption.
299
- */
300
- function isMalformedFinishedAtReplacement(currentTime: number, updatedTime: number): boolean {
301
- return !Number.isFinite(currentTime) && Number.isFinite(updatedTime);
302
- }
303
-
304
- /**
305
- * RT-16: status-level gate for shouldMergeTaskUpdate. Returns the stable
306
- * "from->to" key used by REJECTED_STATUS_MERGE_TRANSITIONS.
307
- */
308
- function statusMergeKey(from: TeamTaskStatus, to: TeamTaskStatus): string {
309
- return `${from}->${to}`;
310
- }
311
-
312
- /**
313
- * RT-16 — derived merge-gate transition table.
314
- *
315
- * The set of old->new status pairs that shouldMergeTaskUpdate must REJECT based
316
- * solely on the status transition (before any field-level comparison). It
317
- * replaces the former 13 hand-written status guards that hand-duplicated the
318
- * lifecycle table. Built once from the single source-of-truth table
319
- * (TEAM_TASK_STATUSES + TEAM_TERMINAL_TASK_STATUSES — the terminal half of
320
- * TEAM_TASK_STATUS_TRANSITIONS) plus two merge-specific policies that are
321
- * STRICTER than the lifecycle table on the parallel-merge path:
322
- *
323
- * P1 Terminal preservation — every terminal->non-terminal pair is rejected.
324
- * The lifecycle table permits retries (e.g. completed->queued), but a stale
325
- * worker snapshot must never resurrect a settled task.
326
- * P2 Completed integrity — five terminal->terminal flips that touch the
327
- * "completed" success terminal are rejected: completed->failed,
328
- * completed->needs_attention, failed->completed, cancelled->completed,
329
- * needs_attention->completed. (completed may still move to
330
- * cancelled/skipped; that is intentionally allowed, so these are NOT simply
331
- * "every illegal terminal->terminal flip".)
332
- * P3 waiting->running regression — the single stale-snapshot case.
333
- *
334
- * The decision for every old->new pair is byte-for-byte identical to the former
335
- * 7 status guards (verified exhaustively in
336
- * test/unit/team-runner-should-merge-table.test.ts).
337
- */
338
- const REJECTED_STATUS_MERGE_TRANSITIONS: ReadonlySet<string> = (() => {
339
- const rejected = new Set<string>();
340
- // P1 — terminal preservation: reject every terminal->non-terminal pair.
341
- for (const from of TEAM_TASK_STATUSES) {
342
- if (!TEAM_TERMINAL_TASK_STATUSES.has(from)) continue;
343
- for (const to of TEAM_TASK_STATUSES) {
344
- if (!TEAM_TERMINAL_TASK_STATUSES.has(to)) rejected.add(statusMergeKey(from, to));
345
- }
346
- }
347
- // P3 — waiting->running stale-snapshot regression.
348
- rejected.add(statusMergeKey("waiting", "running"));
349
- // P2 — completed integrity flips (bespoke terminal->terminal policy).
350
- const completedIntegrityFlips: ReadonlyArray<[TeamTaskStatus, TeamTaskStatus]> = [
351
- ["completed", "failed"],
352
- ["completed", "needs_attention"],
353
- ["failed", "completed"],
354
- ["cancelled", "completed"],
355
- ["needs_attention", "completed"],
356
- ];
357
- for (const [from, to] of completedIntegrityFlips) rejected.add(statusMergeKey(from, to));
358
- return rejected;
359
- })();
360
-
361
- function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState): boolean {
362
- // RT-16: status-level gate — reject stale/dangerous transitions via the
363
- // derived transition table (REJECTED_STATUS_MERGE_TRANSITIONS) instead of
364
- // hand-written guards. Parallel workers receive the same input snapshot; a
365
- // later result may still carry stale copies. The table encodes three
366
- // merge-specific policies stricter than the lifecycle table: terminal
367
- // preservation (no terminal->non-terminal resurrection), completed integrity
368
- // (no flipping the "completed" success terminal to/from failed or
369
- // needs_attention), and the waiting->running stale-snapshot regression.
370
- if (REJECTED_STATUS_MERGE_TRANSITIONS.has(statusMergeKey(current.status, updated.status))) return false;
371
- // Guard: when current is "running" but has resultArtifact (another worker already
372
- // completed it), a stale updated with status="running" and no resultArtifact
373
- // must not overwrite the actual completed state.
374
- if (current.status === updated.status && updated.status === "running" && current.resultArtifact && !updated.resultArtifact)
375
- return false;
376
- // Guard: when current is "completed" and has resultArtifact but updated is also
377
- // "completed" without resultArtifact, block the stale update from overwriting
378
- // a task that successfully produced output.
379
- if (current.status === updated.status && current.status === "completed" && current.resultArtifact && !updated.resultArtifact)
380
- return false;
381
- // Prevent a stale completed task from overwriting a fresher one.
382
- // Restructure to handle undefined current.finishedAt as a special case:
383
- // - undefined current + valid updated: allow the update
384
- // - valid current + undefined updated: block the update (don't lose completion time)
385
- // - both undefined: finishedAt guard does not apply, fall through to heartbeat check
386
- // - both valid: compare timestamps as before
387
- if (current.finishedAt !== undefined && updated.finishedAt !== undefined) {
388
- const currentTime = safeFinishedAt(current);
389
- const updatedTime = safeFinishedAt(updated);
390
- // Malformed finishedAt (NaN) is treated as Infinity — invalid state should be
391
- // replaced rather than persisting corruption. Log warning for visibility.
392
- if (!Number.isFinite(currentTime)) {
393
- console.warn(`[team-runner] Task ${current.id} has malformed finishedAt: ${current.finishedAt}`);
394
- }
395
- if (isMalformedFinishedAtReplacement(currentTime, updatedTime)) {
396
- return true;
397
- }
398
- if (updatedTime < currentTime) return false;
399
- }
400
- // Block if updated is trying to establish a terminal status without a finishedAt
401
- // timestamp. Heartbeat-only updates (status='running', no finishedAt) are
402
- // allowed if heartbeat has changed (checked separately in hasMeaningfulUpdate).
403
- if (!updated.finishedAt && !isNonTerminalTaskStatus(updated.status)) return false;
404
- // Explicitly enumerate all fields that constitute a meaningful update so that
405
- // adding a new important field requires updating this list (rather than silently
406
- // losing data if a field is forgotten in the boolean OR chain below).
407
- const hasMeaningfulUpdate =
408
- updated.status !== current.status ||
409
- updated.finishedAt !== current.finishedAt ||
410
- updated.startedAt !== current.startedAt ||
411
- Boolean(updated.resultArtifact) !== Boolean(current.resultArtifact) ||
412
- (Boolean(updated.resultArtifact) && updated.resultArtifact !== current.resultArtifact) ||
413
- Boolean(updated.error) ||
414
- Boolean(updated.modelAttempts?.length) ||
415
- Boolean(updated.usage) ||
416
- Boolean(updated.attempts?.length) ||
417
- updated.heartbeat?.lastSeenAt !== current.heartbeat?.lastSeenAt ||
418
- updated.jsonEvents !== current.jsonEvents ||
419
- updated.agentProgress?.lastActivityAt !== current.agentProgress?.lastActivityAt;
420
- return hasMeaningfulUpdate;
421
- }
422
- /** Exposed for the exhaustive status-merge table test (RT-16). */
423
- export const __test__shouldMergeTaskUpdate = shouldMergeTaskUpdate;
424
-
425
- // H4 fix: rename to descriptive name. Kept __test__ as alias for backward
426
- // compat test imports.
427
- // FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
428
- // single-pass Map-based merge. Build an index of `merged` once, then for each
429
- // incoming updated task do O(1) lookup; the final pass reassembles `merged`
430
- // preserving original order. For a 20-task run × 5-batch merger with
431
- // ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
432
- // Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
433
- // leave the existing task in place.
434
- export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
435
- // Index current merged state by id for O(1) lookup during the merge pass.
436
- const indexById = new Map<string, TeamTaskState>();
437
- for (const task of base) indexById.set(task.id, task);
438
-
439
- let skipped = 0;
440
- for (const result of results) {
441
- for (const updated of result.tasks) {
442
- const current = indexById.get(updated.id);
443
- if (!current) continue;
444
- if (!shouldMergeTaskUpdate(current, updated)) {
445
- // Log skipped merges for visibility into rejected parallel updates.
446
- // In distributed systems with parallel workers, rejected merges may
447
- // indicate bugs (wrong status, timestamp corruption) if they accumulate.
448
- console.debug("[team-runner] Skipping stale merge for task", updated.id, {
449
- currentStatus: current.status,
450
- updatedStatus: updated.status,
451
- currentFinishedAt: current.finishedAt,
452
- updatedFinishedAt: updated.finishedAt,
453
- });
454
- skipped += 1;
455
- continue;
456
- }
457
- indexById.set(updated.id, updated);
458
- }
459
- }
460
- // Reassemble in original `base` order so downstream snapshots stay stable.
461
- const merged = base.map((task) => indexById.get(task.id) ?? task);
462
- // `skipped` is intentional visibility — currently no caller reads it but
463
- // we'd rather leave the count available for future instrumentation than
464
- // remove the cumulative silent-rejection signal it provides.
465
- void skipped;
466
- return refreshTaskGraphQueues(merged);
467
- }
468
- /** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
469
- export const __test__mergeTaskUpdates = mergeTaskUpdatesPreservingTerminal;
470
-
471
385
  // 2.8: adaptive-plan parsing/repair/injection moved to src/runtime/goal-workflow/adaptive-plan.ts.
472
386
  // Re-export the test-only helpers so existing test imports still resolve.
473
387
  export {
@@ -475,6 +389,10 @@ export {
475
389
  __test__repairAdaptivePlan,
476
390
  } from "./goal-workflow/adaptive-plan.ts";
477
391
 
392
+ // Merge-gate extracted to ./merge-gate.ts (2026-08-10 improvement-plan Tier 2).
393
+ // Re-export the test-only helpers so existing test imports still resolve.
394
+ export { __test__mergeTaskUpdates, __test__shouldMergeTaskUpdate } from "./merge-gate.ts";
395
+
478
396
  import { injectAdaptivePlanIfReady } from "./goal-workflow/adaptive-plan.ts";
479
397
 
480
398
  function formatTaskProgress(task: TeamTaskState): string {
@@ -912,6 +830,10 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
912
830
  // heartbeats; the team-level run had no heartbeat, so any multi-phase
913
831
  // workflow lasting >5min was marked stale and cancelled.
914
832
  const stopTeamHeartbeat = startTeamRunHeartbeat(manifest.stateRoot, manifest.runId);
833
+ // Perf observability: auto-attach the resource sampler for this run (toggle:
834
+ // team frontmatter `observability: false`). Detached + unref'd — the sampler
835
+ // auto-stops when the runner dies, so no explicit cleanup needed.
836
+ startPerfSampler(manifest, input.team);
915
837
 
916
838
  const cleanupUsage = (): void => {
917
839
  for (const task of input.tasks) clearTrackedTaskUsage(task.id);
@@ -994,6 +916,9 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
994
916
  // this single flush at run-end coalesces any pending progress bursts
995
917
  // before manifest updates are observed by readers.
996
918
  await flushEventLogBuffer();
919
+ // Perf observability: emit the post-run perf report (detached, delayed so
920
+ // child transcripts are flushed). Never affects run outcome.
921
+ schedulePerfAnalyze(manifest, input.team);
997
922
  return result;
998
923
  } catch (error) {
999
924
  // Round 27 (BUG 1): the success path calls stopTeamHeartbeat() but this
@@ -268,10 +268,12 @@ const sharedFields = {
268
268
  }),
269
269
  ),
270
270
  budgetTotal: Type.Optional(
271
+ // Empty-string unset marker accepted (Tier-9: models emit "" when unset).
271
272
  // 0 accepted as "unset/disabled" (models emit 0 for off); still rejects 1-999
272
273
  // as the MISCONFIGURATION GUARD against typo'd silent-abort configs.
273
274
  Type.Union(
274
275
  [
276
+ Type.Literal(""),
275
277
  Type.Literal(0),
276
278
  Type.Number({
277
279
  minimum: 1000,
@@ -111,6 +111,8 @@ function parseTeamFile(filePath: string, source: ResourceSource): TeamConfig | u
111
111
  defaultWorkflow: frontmatter.defaultWorkflow || frontmatter.workflow || undefined,
112
112
  workspaceMode: frontmatter.workspaceMode?.trim() === "worktree" ? "worktree" : "single",
113
113
  maxConcurrency: frontmatter.maxConcurrency ? Number.parseInt(frontmatter.maxConcurrency, 10) : undefined,
114
+ // observability defaults ON ("luôn hoạt động"); explicit `observability: false` disables.
115
+ observability: frontmatter.observability === undefined ? true : frontmatter.observability !== "false",
114
116
  routing: triggers || useWhen || avoidWhen || cost || category ? { triggers, useWhen, avoidWhen, cost, category } : undefined,
115
117
  };
116
118
  } catch {
@@ -23,6 +23,13 @@ export interface TeamConfig {
23
23
  defaultWorkflow?: string;
24
24
  workspaceMode?: "single" | "worktree";
25
25
  maxConcurrency?: number;
26
+ /**
27
+ * Perf observability: when true (default), the run auto-attaches the
28
+ * resource sampler (scripts/resource-sampler.mjs --watch-run) and runs
29
+ * analyze-run after completion. Set `observability: false` in the team
30
+ * frontmatter to disable.
31
+ */
32
+ observability?: boolean;
26
33
  routing?: RoutingMetadata;
27
34
  /**
28
35
  * Optional git-based source URL when this team config is sourced from a remote URL.
@@ -24,6 +24,7 @@ export function serializeTeam(team: TeamConfig): string {
24
24
  team.defaultWorkflow ? `defaultWorkflow: ${team.defaultWorkflow}` : undefined,
25
25
  team.workspaceMode ? `workspaceMode: ${team.workspaceMode}` : undefined,
26
26
  team.maxConcurrency !== undefined ? `maxConcurrency: ${team.maxConcurrency}` : undefined,
27
+ team.observability !== undefined ? `observability: ${team.observability}` : undefined,
27
28
  line("triggers", team.routing?.triggers),
28
29
  line("useWhen", team.routing?.useWhen),
29
30
  line("avoidWhen", team.routing?.avoidWhen),
package/src/ui/mascot.ts CHANGED
@@ -99,7 +99,6 @@ export class AnimatedMascot {
99
99
  private currentArminGrid: string[][];
100
100
  private effectState: EffectState = {};
101
101
  private effectDone = false;
102
- private visible = true;
103
102
  private frame = 0;
104
103
  private effectPhase = 0;
105
104
  private gridVersion = 0;
@@ -200,10 +199,7 @@ export class AnimatedMascot {
200
199
  this.gridVersion++;
201
200
  }
202
201
  this.invalidate();
203
- // Only request a re-render when the mascot is visible (not obscured by
204
- // another overlay such as the dashboard or live sidebar). This avoids
205
- // pointless repaints on every animation frame while hidden.
206
- if (this.visible) this.requestRender?.();
202
+ this.requestRender?.();
207
203
  }
208
204
 
209
205
  private tickArminEffect(): boolean {
@@ -432,15 +428,6 @@ export class AnimatedMascot {
432
428
  }
433
429
  }
434
430
 
435
- /**
436
- * Set whether the mascot is currently visible (not obscured by another
437
- * overlay). When invisible, tick() skips requestRender so the animation
438
- * does not trigger needless repaints while hidden.
439
- */
440
- setVisible(visible: boolean): void {
441
- this.visible = visible;
442
- }
443
-
444
431
  dispose(): void {
445
432
  this.doneGuard.called = true;
446
433
  if (this.interval) clearInterval(this.interval);
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: default
3
+ observability: true
3
4
  description: Balanced team for ordinary implementation tasks
4
5
  defaultWorkflow: default
5
6
  workspaceMode: single
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: fast-fix
3
+ observability: true
3
4
  description: Small team for quick bug fixes
4
5
  defaultWorkflow: fast-fix
5
6
  workspaceMode: single
@@ -1,86 +0,0 @@
1
- import type { AgentProgress } from "../runtime/output/progress-tracker.ts";
2
- import { logInternalError } from "../utils/internal-error.ts";
3
-
4
- export type CrewEventType = "agent:progress" | "agent:complete" | "agent:error" | "run:start" | "run:complete";
5
-
6
- export interface CrewEvent {
7
- type: CrewEventType;
8
- runId: string;
9
- agentId?: string;
10
- payload?: AgentProgress | string;
11
- timestamp: number;
12
- }
13
-
14
- type CrewEventListener = (event: CrewEvent) => void;
15
-
16
- class EventBus {
17
- private static readonly MAX_LISTENERS_PER_EVENT = 100;
18
- private listeners = new Map<CrewEventType, Set<CrewEventListener>>();
19
- private static _instance?: EventBus;
20
-
21
- static getInstance(): EventBus {
22
- if (!EventBus._instance) {
23
- EventBus._instance = new EventBus();
24
- }
25
- return EventBus._instance;
26
- }
27
-
28
- /**
29
- * Dispose of the EventBus instance and clear all listeners.
30
- * Resets the singleton so a new instance can be created.
31
- */
32
- dispose(): void {
33
- this.listeners.clear();
34
- EventBus._instance = undefined;
35
- }
36
-
37
- emit(event: CrewEvent): void {
38
- const listeners = this.listeners.get(event.type);
39
- if (listeners) {
40
- for (const listener of listeners) {
41
- try {
42
- listener(event);
43
- } catch (e) {
44
- // FIX (Round 15, L1): Use logInternalError for consistency with
45
- // the rest of the codebase. Previously console.error may not be
46
- // visible in all environments (e.g. JSON-RPC mode, redirected
47
- // stderr).
48
- logInternalError("event-bus.listener", e, `type=${event.type} runId=${event.runId}`);
49
- }
50
- }
51
- }
52
- }
53
-
54
- on(type: CrewEventType, listener: CrewEventListener): () => void {
55
- if (!this.listeners.has(type)) {
56
- this.listeners.set(type, new Set());
57
- }
58
- const set = this.listeners.get(type)!;
59
- if (set.size >= EventBus.MAX_LISTENERS_PER_EVENT) {
60
- logInternalError(
61
- "event-bus.listener-cap",
62
- new Error(`Max ${EventBus.MAX_LISTENERS_PER_EVENT} listeners reached for event type '${type}'`),
63
- `type=${type} currentCount=${set.size}`,
64
- );
65
- }
66
- set.add(listener);
67
-
68
- return () => {
69
- this.listeners.get(type)?.delete(listener);
70
- };
71
- }
72
-
73
- off(type: CrewEventType, listener: CrewEventListener): void {
74
- this.listeners.get(type)?.delete(listener);
75
- }
76
- }
77
-
78
- /**
79
- * Global event bus for crew lifecycle events.
80
- *
81
- * NOTE: Currently only emits — no production subscribers yet.
82
- * The `runEventBus` (from `ui/run-event-bus.ts`) is the active event system.
83
- * This bus is retained for future observability/SIEM integration.
84
- * See also: progress-tracker.ts which emits agent:progress events.
85
- */
86
- export const crewEventBus = EventBus.getInstance();
@@ -1,6 +0,0 @@
1
- /**
2
- * Simplifies plugin struct definition.
3
- */
4
- export function definePlugin<T extends { name: string; enablers: readonly string[] }>(spec: T): T {
5
- return spec;
6
- }
@@ -1,32 +0,0 @@
1
- export interface Plugin {
2
- readonly name: string;
3
- readonly enablers: readonly string[];
4
- readonly entryPatterns?: readonly string[];
5
- readonly configPatterns?: readonly string[];
6
- readonly toolingDependencies?: readonly string[];
7
- readonly pathAliases?: readonly string[][];
8
- readonly virtualModulePrefixes?: readonly string[];
9
- }
10
-
11
- export class PluginRegistry {
12
- private plugins: Plugin[] = [];
13
-
14
- register(plugin: Plugin): void {
15
- this.plugins.push(plugin);
16
- }
17
-
18
- activePlugins(allDeps: string[]): Plugin[] {
19
- return this.plugins.filter((p) =>
20
- p.enablers.some((enabler) => {
21
- if (enabler.endsWith("/")) {
22
- return allDeps.some((d) => d.startsWith(enabler));
23
- }
24
- return allDeps.includes(enabler);
25
- }),
26
- );
27
- }
28
-
29
- allPlugins(): Plugin[] {
30
- return [...this.plugins];
31
- }
32
- }
@@ -1,3 +0,0 @@
1
- export { NextJsPlugin } from "./nextjs.ts";
2
- export { VitePlugin } from "./vite.ts";
3
- export { VitestPlugin } from "./vitest.ts";
@@ -1,19 +0,0 @@
1
- import { definePlugin } from "../plugin-define.ts";
2
-
3
- export const NextJsPlugin = definePlugin({
4
- name: "nextjs",
5
- enablers: ["next"],
6
- entryPatterns: [
7
- "src/app/**/*.{ts,tsx}",
8
- "src/pages/**/*.{ts,tsx}",
9
- "src/app/**/page.{ts,tsx}",
10
- "src/app/**/layout.{ts,tsx}",
11
- "src/app/**/route.{ts,tsx}",
12
- "middleware.{ts,js}",
13
- "next.config.{ts,js,mjs}",
14
- ],
15
- configPatterns: ["next.config.{ts,js,mjs}"],
16
- toolingDependencies: ["next", "@next/font", "@next/mdx"],
17
- pathAliases: [["~", "src"]],
18
- virtualModulePrefixes: ["next:"],
19
- });
@@ -1,10 +0,0 @@
1
- import { definePlugin } from "../plugin-define.ts";
2
-
3
- export const VitePlugin = definePlugin({
4
- name: "vite",
5
- enablers: ["vite", "rolldown-vite"],
6
- entryPatterns: ["src/main.{ts,tsx,js,jsx}", "src/index.{ts,tsx,js,jsx}", "index.html"],
7
- configPatterns: ["vite.config.{ts,js,mts,mjs}"],
8
- toolingDependencies: ["vite"],
9
- virtualModulePrefixes: ["virtual:"],
10
- });
@@ -1,9 +0,0 @@
1
- import { definePlugin } from "../plugin-define.ts";
2
-
3
- export const VitestPlugin = definePlugin({
4
- name: "vitest",
5
- enablers: ["vitest"],
6
- entryPatterns: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}", "src/**/*.test.{ts,tsx}", "src/**/*.spec.{ts,tsx}"],
7
- configPatterns: ["vitest.config.{ts,js,mjs}", "vite.config.ts"],
8
- toolingDependencies: ["vitest"],
9
- });