pi-crew 0.9.64 → 0.9.66

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 (43) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +46 -1
  3. package/dist/index.mjs +423 -330
  4. package/package.json +3 -2
  5. package/scripts/pty_probe.py +10 -8
  6. package/skills/real-test-pi-crew/SKILL.md +6 -6
  7. package/src/config/config.ts +19 -3
  8. package/src/config/types.ts +2 -0
  9. package/src/extension/team-tool/cancel.ts +34 -0
  10. package/src/extension/team-tool/dispatch/manage.ts +7 -4
  11. package/src/extension/team-tool/explain.ts +3 -1
  12. package/src/extension/team-tool/lifecycle-actions.ts +4 -1
  13. package/src/extension/team-tool-types.ts +2 -0
  14. package/src/observability/event-to-metric.ts +29 -0
  15. package/src/observability/metrics-primitives.ts +41 -3
  16. package/src/runtime/README.md +1 -1
  17. package/src/runtime/broker/crew-broker.ts +0 -16
  18. package/src/runtime/effectiveness.ts +23 -1
  19. package/src/runtime/merge-gate.ts +202 -0
  20. package/src/runtime/model/model-fallback.ts +11 -0
  21. package/src/runtime/model/provider-extensions.ts +31 -12
  22. package/src/runtime/output/output-validator.ts +34 -6
  23. package/src/runtime/output/progress-tracker.ts +3 -33
  24. package/src/runtime/scheduling/scheduler.ts +67 -19
  25. package/src/runtime/scratchpad/engine.ts +40 -2
  26. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  27. package/src/runtime/team-runner.ts +128 -203
  28. package/src/schema/team-tool-schema.ts +2 -0
  29. package/src/teams/discover-teams.ts +2 -0
  30. package/src/teams/team-config.ts +7 -0
  31. package/src/teams/team-serializer.ts +1 -0
  32. package/src/ui/mascot.ts +1 -14
  33. package/teams/default.team.md +1 -0
  34. package/teams/fast-fix.team.md +1 -0
  35. package/src/observability/event-bus.ts +0 -86
  36. package/src/plugins/plugin-define.ts +0 -6
  37. package/src/plugins/plugin-registry.ts +0 -32
  38. package/src/plugins/plugins/index.ts +0 -3
  39. package/src/plugins/plugins/nextjs.ts +0 -19
  40. package/src/plugins/plugins/vite.ts +0 -10
  41. package/src/plugins/plugins/vitest.ts +0 -9
  42. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  43. package/src/runtime/iteration-hooks.ts +0 -305
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Snapshot HMAC helper — opt-in integrity for scratchpad snapshots.
3
+ *
4
+ * Closes the E.2 gap declared in docs/improvement-plan-2026-08-09.md and
5
+ * docs/runtime/scratchpad/README.md:120 ("v8.deserialize of restore content
6
+ * is unauthenticated (no HMAC)"). The threat model — a same-uid attacker
7
+ * plants a crafted V8 blob at the snapshot path to run deserialize gadgets
8
+ * in the guest — does not cross the existing same-uid boundary, but HMAC
9
+ * hardening is required before snapshots ever land in a shared/networked
10
+ * store.
11
+ *
12
+ * Migration window (see docs/decisions/2026-08-10-scratchpad-snapshot-hmac.md):
13
+ *
14
+ * Phase 1 (this module): HMAC sign-on-write + verify-on-read are OPT-IN
15
+ * via PI_CREW_SNAPSHOT_HMAC_KEY. When the key is unset, behaviour is
16
+ * unchanged (snapshots remain unsigned). When the key is set, writes
17
+ * attach a signature and reads verify it; an unsigned/failed snapshot
18
+ * is ACCEPTED with a warning so existing snapshots remain readable.
19
+ * Phase 2 (after one release): unsigned snapshots are REJECTED when the
20
+ * key is set (configurable via PI_CREW_SNAPSHOT_HMAC_STRICT=1).
21
+ * Phase 3 (after snapshots move to a shared store): the key becomes
22
+ * required and unsigned snapshots are always rejected.
23
+ *
24
+ * Wire-up into the actual write/read paths is intentionally deferred to a
25
+ * follow-up that audits the snapshot envelope format (V8 base64 vs raw
26
+ * bytes) and the writeArtifact redaction interaction. See ADR for the plan.
27
+ */
28
+ import { createHmac, timingSafeEqual } from "node:crypto";
29
+
30
+ /**
31
+ * Env var holding the HMAC secret. When unset, HMAC is disabled (Phase 0
32
+ * behaviour — snapshots unsigned). When set, sign-on-write and
33
+ * verify-on-read are enabled.
34
+ */
35
+ export const SNAPSHOT_HMAC_KEY_ENV = "PI_CREW_SNAPSHOT_HMAC_KEY";
36
+
37
+ /**
38
+ * Opt-in strict mode (Phase 2): when the key is set AND this is "1",
39
+ * unsigned or signature-mismatched snapshots are REJECTED on read instead
40
+ * of accepted with a warning.
41
+ */
42
+ export const SNAPSHOT_HMAC_STRICT_ENV = "PI_CREW_SNAPSHOT_HMAC_STRICT";
43
+
44
+ /**
45
+ * Header prefix for an inline signature. When snapshots are signed, the
46
+ * signature is prepended to the blob as `PI_CREW_SIG=<hex>\n` so a single
47
+ * read yields both the signature and the payload. (Sidecar files would
48
+ * require writeArtifact coordination that the envelope format does not
49
+ * currently support.)
50
+ */
51
+ export const SNAPSHOT_SIG_PREFIX = "PI_CREW_SIG=";
52
+
53
+ export function getSnapshotHmacKey(env: NodeJS.ProcessEnv = process.env): Buffer | undefined {
54
+ const raw = env[SNAPSHOT_HMAC_KEY_ENV];
55
+ if (!raw) return undefined;
56
+ // Accept hex-encoded keys directly; otherwise encode the string as utf8.
57
+ // A key shorter than 32 bytes is rejected to prevent trivial brute-force.
58
+ const buf = /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0 ? Buffer.from(raw, "hex") : Buffer.from(raw, "utf8");
59
+ if (buf.length < 32) {
60
+ throw new Error(
61
+ `${SNAPSHOT_HMAC_KEY_ENV} must be at least 32 bytes (got ${buf.length}); use a longer key or generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`,
62
+ );
63
+ }
64
+ return buf;
65
+ }
66
+
67
+ export function isSnapshotHmacStrict(env: NodeJS.ProcessEnv = process.env): boolean {
68
+ return env[SNAPSHOT_HMAC_STRICT_ENV] === "1" || env[SNAPSHOT_HMAC_STRICT_ENV] === "true";
69
+ }
70
+
71
+ /**
72
+ * Compute the HMAC-SHA256 signature of `content` under `key`. Returns a
73
+ * lowercase hex string.
74
+ */
75
+ export function signSnapshot(content: Buffer | string, key: Buffer): string {
76
+ return createHmac("sha256", key).update(content).digest("hex");
77
+ }
78
+
79
+ /**
80
+ * Constant-time signature comparison. Both signatures must be lowercase
81
+ * hex of the same length; anything else returns false without throwing.
82
+ */
83
+ export function snapshotSignatureMatches(content: Buffer | string, signature: string, key: Buffer): boolean {
84
+ const expected = signSnapshot(content, key);
85
+ const a = Buffer.from(expected);
86
+ const b = Buffer.from(signature);
87
+ if (a.length !== b.length) return false;
88
+ return timingSafeEqual(a, b);
89
+ }
90
+
91
+ /**
92
+ * Outcome of {@link verifySnapshotPayload}. Callers decide how to react
93
+ * based on the strict-mode flag.
94
+ */
95
+ export type SnapshotVerifyOutcome =
96
+ | { kind: "unsigned"; strict: boolean }
97
+ | { kind: "verified" }
98
+ | { kind: "mismatch"; strict: boolean }
99
+ | { kind: "hmac-disabled" };
100
+
101
+ /**
102
+ * Verify a snapshot payload that may or may not carry an inline signature.
103
+ *
104
+ * @param payload The raw bytes read from disk (possibly including the
105
+ * `PI_CREW_SIG=<hex>\n` prefix).
106
+ * @param key The HMAC key, or `undefined` when HMAC is disabled.
107
+ * @param strict When true, unsigned/mismatched payloads are reported as
108
+ * rejectable. When false (the Phase 1 default), the caller is expected
109
+ * to accept the payload with a warning.
110
+ */
111
+ export function verifySnapshotPayload(payload: Buffer, key: Buffer | undefined, strict: boolean): SnapshotVerifyOutcome {
112
+ if (!key) return { kind: "hmac-disabled" };
113
+ const prefixStr = SNAPSHOT_SIG_PREFIX;
114
+ if (payload.length < prefixStr.length + 1 || payload.subarray(0, prefixStr.length).toString("utf8") !== prefixStr) {
115
+ return { kind: "unsigned", strict };
116
+ }
117
+ const newlineIdx = payload.indexOf(0x0a, prefixStr.length);
118
+ if (newlineIdx < 0) return { kind: "unsigned", strict };
119
+ const sigHex = payload.subarray(prefixStr.length, newlineIdx).toString("utf8");
120
+ const body = payload.subarray(newlineIdx + 1);
121
+ return snapshotSignatureMatches(body, sigHex, key) ? { kind: "verified" } : { kind: "mismatch", strict };
122
+ }
123
+
124
+ /**
125
+ * Strip the inline signature prefix and return the bare payload. Returns
126
+ * the original buffer when no prefix is present. Used by read paths that
127
+ * have already called {@link verifySnapshotPayload} and decided to accept.
128
+ */
129
+ export function stripSnapshotSignature(payload: Buffer): Buffer {
130
+ if (payload.length < SNAPSHOT_SIG_PREFIX.length) return payload;
131
+ if (payload.subarray(0, SNAPSHOT_SIG_PREFIX.length).toString("utf8") !== SNAPSHOT_SIG_PREFIX) return payload;
132
+ const newlineIdx = payload.indexOf(0x0a, SNAPSHOT_SIG_PREFIX.length);
133
+ if (newlineIdx < 0) return payload;
134
+ return payload.subarray(newlineIdx + 1);
135
+ }
136
+
137
+ /**
138
+ * Attach an inline signature to a payload. Returns a new buffer shaped as
139
+ * `PI_CREW_SIG=<hex>\n<payload>`. Used by write paths that have HMAC
140
+ * enabled.
141
+ */
142
+ export function attachSnapshotSignature(payload: Buffer, key: Buffer): Buffer {
143
+ const sig = signSnapshot(payload, key);
144
+ return Buffer.concat([Buffer.from(`${SNAPSHOT_SIG_PREFIX}${sig}\n`, "utf8"), payload]);
145
+ }
146
+
147
+ /**
148
+ * Should the caller reject the snapshot based on the verify outcome?
149
+ * Centralises the strict-vs-migration decision so callers stay simple.
150
+ */
151
+ export function shouldRejectSnapshot(outcome: SnapshotVerifyOutcome): boolean {
152
+ switch (outcome.kind) {
153
+ case "hmac-disabled":
154
+ return false;
155
+ case "verified":
156
+ return false;
157
+ case "unsigned":
158
+ case "mismatch":
159
+ return outcome.strict;
160
+ }
161
+ }
@@ -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