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