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.
- package/CHANGELOG.md +13 -0
- package/README.md +46 -1
- package/dist/index.mjs +316 -299
- package/package.json +3 -2
- package/scripts/analyze-run.mjs +1333 -0
- package/scripts/pty_probe.py +10 -8
- package/scripts/resource-sampler.mjs +482 -0
- package/skills/real-test-pi-crew/SKILL.md +6 -6
- package/src/observability/event-to-metric.ts +29 -0
- package/src/observability/metrics-primitives.ts +41 -3
- package/src/runtime/README.md +1 -1
- package/src/runtime/broker/crew-broker.ts +0 -16
- package/src/runtime/effectiveness.ts +23 -1
- package/src/runtime/merge-gate.ts +202 -0
- package/src/runtime/model/model-fallback.ts +11 -0
- package/src/runtime/model/provider-extensions.ts +31 -12
- package/src/runtime/output/progress-tracker.ts +3 -33
- package/src/runtime/scratchpad/engine.ts +40 -2
- package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
- package/src/runtime/team-runner.ts +128 -203
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/teams/discover-teams.ts +2 -0
- package/src/teams/team-config.ts +7 -0
- package/src/teams/team-serializer.ts +1 -0
- package/src/ui/mascot.ts +1 -14
- package/teams/default.team.md +1 -0
- package/teams/fast-fix.team.md +1 -0
- package/src/observability/event-bus.ts +0 -86
- package/src/plugins/plugin-define.ts +0 -6
- package/src/plugins/plugin-registry.ts +0 -32
- package/src/plugins/plugins/index.ts +0 -3
- package/src/plugins/plugins/nextjs.ts +0 -19
- package/src/plugins/plugins/vite.ts +0 -10
- package/src/plugins/plugins/vitest.ts +0 -9
- package/src/runtime/child-pi/child-pi-pool.ts +0 -68
- 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
|
|
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
|
-
|
|
245
|
-
|
|
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 {
|
package/src/teams/team-config.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/teams/default.team.md
CHANGED
package/teams/fast-fix.team.md
CHANGED
|
@@ -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,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,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
|
-
});
|