pi-crew 0.1.2 → 0.1.4
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/README.md +27 -22
- package/install.mjs +1 -1
- package/package.json +1 -1
- package/schema.json +14 -0
- package/src/config/config.ts +37 -0
- package/src/extension/team-tool.ts +5 -3
- package/src/runtime/background-runner.ts +4 -2
- package/src/runtime/green-contract.ts +46 -0
- package/src/runtime/policy-engine.ts +55 -0
- package/src/runtime/task-packet.ts +84 -0
- package/src/runtime/task-runner.ts +22 -2
- package/src/runtime/team-runner.ts +162 -135
- package/src/state/contracts.ts +10 -0
- package/src/state/state-store.ts +13 -2
- package/src/state/types.ts +68 -0
- package/src/ui/run-dashboard.ts +7 -2
package/README.md
CHANGED
|
@@ -2,17 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
`pi-crew` is a Pi extension/package for coordinated AI teams: autonomous routing, manual slash-command controls, durable run state, artifacts, async/background execution, optional worktree isolation, resource management, validation, import/export, dashboard helpers, and safe API interop.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
NPM package:
|
|
6
6
|
|
|
7
7
|
```text
|
|
8
|
-
pi-crew
|
|
8
|
+
pi-crew
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
GitHub repository:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
https://github.com/baphuongna/pi-crew
|
|
15
|
+
```
|
|
12
16
|
|
|
13
17
|
## Status
|
|
14
18
|
|
|
15
|
-
`pi-crew` is
|
|
19
|
+
`pi-crew` is published on npm and implemented with safe execution defaults and product-oriented foundations.
|
|
16
20
|
|
|
17
21
|
Current highlights:
|
|
18
22
|
|
|
@@ -33,6 +37,7 @@ Current highlights:
|
|
|
33
37
|
- retryable model fallback attempts per task
|
|
34
38
|
- aggregate usage totals in status/summary
|
|
35
39
|
- progress, summary, prompt, result, log, diff, patch, export artifacts
|
|
40
|
+
- task packets, verification/green-contract evidence, policy decision artifacts, and task graph metadata
|
|
36
41
|
- opt-in git worktree isolation per task
|
|
37
42
|
- worktree branch mismatch detection
|
|
38
43
|
- dirty worktree preservation unless `force` is explicitly set
|
|
@@ -63,7 +68,13 @@ From the workspace root for local development:
|
|
|
63
68
|
pi install ./pi-crew
|
|
64
69
|
```
|
|
65
70
|
|
|
66
|
-
Optional config bootstrap:
|
|
71
|
+
Optional config bootstrap after npm install:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pi-crew
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Optional config bootstrap from a local clone:
|
|
67
78
|
|
|
68
79
|
```bash
|
|
69
80
|
node ./pi-crew/install.mjs
|
|
@@ -144,6 +155,14 @@ Supported config:
|
|
|
144
155
|
"magicKeywords": {
|
|
145
156
|
"review": ["review", "audit", "inspect"]
|
|
146
157
|
}
|
|
158
|
+
},
|
|
159
|
+
"limits": {
|
|
160
|
+
"maxConcurrentWorkers": 3,
|
|
161
|
+
"maxTaskDepth": 2,
|
|
162
|
+
"maxChildrenPerTask": 5,
|
|
163
|
+
"maxRunMinutes": 60,
|
|
164
|
+
"maxRetriesPerTask": 1,
|
|
165
|
+
"heartbeatStaleMs": 60000
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
168
|
```
|
|
@@ -663,25 +682,11 @@ pi-crew/docs/live-mailbox-runtime.md
|
|
|
663
682
|
pi-crew/docs/publishing.md
|
|
664
683
|
```
|
|
665
684
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
```text
|
|
669
|
-
docs/pi-crew-source-review-and-lessons.md
|
|
670
|
-
docs/pi-crew-architecture.md
|
|
671
|
-
docs/pi-crew-mvp-plan.md
|
|
672
|
-
docs/pi-crew-*-progress-2026-04-26.md
|
|
673
|
-
```
|
|
674
|
-
|
|
675
|
-
## Known remaining release metadata
|
|
676
|
-
|
|
677
|
-
Package metadata still needs real project values before publishing:
|
|
685
|
+
Historical workspace-level design/progress docs may exist in the original development workspace under `docs/pi-crew-*`, but package-maintained docs live under `pi-crew/docs/`.
|
|
678
686
|
|
|
679
|
-
|
|
680
|
-
- `repository`
|
|
681
|
-
- `homepage`
|
|
682
|
-
- `bugs`
|
|
687
|
+
## Local Pi smoke
|
|
683
688
|
|
|
684
|
-
|
|
689
|
+
A local Pi smoke test requires an installed Pi CLI and a real Pi environment:
|
|
685
690
|
|
|
686
691
|
```bash
|
|
687
692
|
cd pi-crew
|
package/install.mjs
CHANGED
|
@@ -7,7 +7,7 @@ const configDir = path.join(os.homedir(), ".pi", "agent", "extensions", "pi-crew
|
|
|
7
7
|
const configPath = path.join(configDir, "config.json");
|
|
8
8
|
fs.mkdirSync(configDir, { recursive: true });
|
|
9
9
|
if (!fs.existsSync(configPath)) {
|
|
10
|
-
fs.writeFileSync(configPath, `${JSON.stringify({ asyncByDefault: false, executeWorkers: false, notifierIntervalMs: 5000, requireCleanWorktreeLeader: true, autonomous: { enabled: true, injectPolicy: true, preferAsyncForLongTasks: false, allowWorktreeSuggestion: true } }, null, 2)}\n`, "utf-8");
|
|
10
|
+
fs.writeFileSync(configPath, `${JSON.stringify({ asyncByDefault: false, executeWorkers: false, notifierIntervalMs: 5000, requireCleanWorktreeLeader: true, autonomous: { enabled: true, injectPolicy: true, preferAsyncForLongTasks: false, allowWorktreeSuggestion: true }, limits: { maxConcurrentWorkers: 3, maxTaskDepth: 2, maxChildrenPerTask: 5, maxRunMinutes: 60, maxRetriesPerTask: 1, heartbeatStaleMs: 60000 } }, null, 2)}\n`, "utf-8");
|
|
11
11
|
console.log(`Created default pi-crew config: ${configPath}`);
|
|
12
12
|
} else {
|
|
13
13
|
console.log(`pi-crew config already exists: ${configPath}`);
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -40,6 +40,20 @@
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
},
|
|
44
|
+
"limits": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"description": "Runtime safety limits for crew workers and policy decisions.",
|
|
48
|
+
"properties": {
|
|
49
|
+
"maxConcurrentWorkers": { "type": "integer", "minimum": 1 },
|
|
50
|
+
"maxTaskDepth": { "type": "integer", "minimum": 1 },
|
|
51
|
+
"maxChildrenPerTask": { "type": "integer", "minimum": 1 },
|
|
52
|
+
"maxRunMinutes": { "type": "integer", "minimum": 1 },
|
|
53
|
+
"maxRetriesPerTask": { "type": "integer", "minimum": 1 },
|
|
54
|
+
"maxTasksPerRun": { "type": "integer", "minimum": 1 },
|
|
55
|
+
"heartbeatStaleMs": { "type": "integer", "minimum": 1 }
|
|
56
|
+
}
|
|
43
57
|
}
|
|
44
58
|
}
|
|
45
59
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -13,12 +13,23 @@ export interface PiTeamsAutonomousConfig {
|
|
|
13
13
|
magicKeywords?: Record<string, string[]>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface CrewLimitsConfig {
|
|
17
|
+
maxConcurrentWorkers?: number;
|
|
18
|
+
maxTaskDepth?: number;
|
|
19
|
+
maxChildrenPerTask?: number;
|
|
20
|
+
maxRunMinutes?: number;
|
|
21
|
+
maxRetriesPerTask?: number;
|
|
22
|
+
maxTasksPerRun?: number;
|
|
23
|
+
heartbeatStaleMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export interface PiTeamsConfig {
|
|
17
27
|
asyncByDefault?: boolean;
|
|
18
28
|
executeWorkers?: boolean;
|
|
19
29
|
notifierIntervalMs?: number;
|
|
20
30
|
requireCleanWorktreeLeader?: boolean;
|
|
21
31
|
autonomous?: PiTeamsAutonomousConfig;
|
|
32
|
+
limits?: CrewLimitsConfig;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
export interface LoadedPiTeamsConfig {
|
|
@@ -60,6 +71,12 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
|
|
|
60
71
|
...withoutUndefined((override.autonomous ?? {}) as Record<string, unknown>),
|
|
61
72
|
};
|
|
62
73
|
}
|
|
74
|
+
if (base.limits || override.limits) {
|
|
75
|
+
merged.limits = {
|
|
76
|
+
...(base.limits ?? {}),
|
|
77
|
+
...withoutUndefined((override.limits ?? {}) as Record<string, unknown>),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
63
80
|
return merged;
|
|
64
81
|
}
|
|
65
82
|
|
|
@@ -110,6 +127,25 @@ function parseAutonomousConfig(value: unknown): PiTeamsAutonomousConfig | undefi
|
|
|
110
127
|
};
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
function parsePositiveInteger(value: unknown): number | undefined {
|
|
131
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseLimitsConfig(value: unknown): CrewLimitsConfig | undefined {
|
|
135
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
136
|
+
const obj = value as Record<string, unknown>;
|
|
137
|
+
const limits: CrewLimitsConfig = {
|
|
138
|
+
maxConcurrentWorkers: parsePositiveInteger(obj.maxConcurrentWorkers),
|
|
139
|
+
maxTaskDepth: parsePositiveInteger(obj.maxTaskDepth),
|
|
140
|
+
maxChildrenPerTask: parsePositiveInteger(obj.maxChildrenPerTask),
|
|
141
|
+
maxRunMinutes: parsePositiveInteger(obj.maxRunMinutes),
|
|
142
|
+
maxRetriesPerTask: parsePositiveInteger(obj.maxRetriesPerTask),
|
|
143
|
+
maxTasksPerRun: parsePositiveInteger(obj.maxTasksPerRun),
|
|
144
|
+
heartbeatStaleMs: parsePositiveInteger(obj.heartbeatStaleMs),
|
|
145
|
+
};
|
|
146
|
+
return Object.values(limits).some((entry) => entry !== undefined) ? limits : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
113
149
|
function parseConfig(raw: unknown): PiTeamsConfig {
|
|
114
150
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
115
151
|
const obj = raw as Record<string, unknown>;
|
|
@@ -119,6 +155,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
|
|
|
119
155
|
notifierIntervalMs: typeof obj.notifierIntervalMs === "number" && Number.isFinite(obj.notifierIntervalMs) && obj.notifierIntervalMs >= 1000 ? obj.notifierIntervalMs : undefined,
|
|
120
156
|
requireCleanWorktreeLeader: typeof obj.requireCleanWorktreeLeader === "boolean" ? obj.requireCleanWorktreeLeader : undefined,
|
|
121
157
|
autonomous: parseAutonomousConfig(obj.autonomous),
|
|
158
|
+
limits: parseLimitsConfig(obj.limits),
|
|
122
159
|
};
|
|
123
160
|
}
|
|
124
161
|
|
|
@@ -256,7 +256,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
|
|
259
|
-
const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers });
|
|
259
|
+
const executed = await executeTeamRun({ manifest: updatedManifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits });
|
|
260
260
|
const text = [
|
|
261
261
|
`Created pi-crew run ${executed.manifest.runId}.`,
|
|
262
262
|
`Team: ${team.name}`,
|
|
@@ -306,8 +306,10 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
306
306
|
`Artifacts: ${manifest.artifactsRoot}`,
|
|
307
307
|
...(asyncLivenessLine ? [asyncLivenessLine] : []),
|
|
308
308
|
"Tasks:",
|
|
309
|
-
...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
|
|
309
|
+
...(tasks.length ? tasks.map((task) => `- ${task.id} [${task.status}] ${task.role} -> ${task.agent}${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.modelAttempts?.length ? ` attempts=${task.modelAttempts.length}` : ""}${task.jsonEvents !== undefined ? ` jsonEvents=${task.jsonEvents}` : ""}${task.usage ? ` usage=${JSON.stringify(task.usage)}` : ""}${task.worktree ? ` worktree=${task.worktree.path}` : ""}${task.error ? ` error=${task.error}` : ""}`) : ["- (none)"]),
|
|
310
310
|
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
311
|
+
"Policy decisions:",
|
|
312
|
+
...(manifest.policyDecisions?.length ? manifest.policyDecisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`) : ["- (none)"]),
|
|
311
313
|
`Total usage: ${formatUsage(totalUsage)}`,
|
|
312
314
|
"",
|
|
313
315
|
"Recent artifacts:",
|
|
@@ -369,7 +371,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
369
371
|
appendEvent(loaded.manifest.eventsPath, { type: "run.resume_requested", runId: loaded.manifest.runId });
|
|
370
372
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
371
373
|
const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
|
|
372
|
-
const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers });
|
|
374
|
+
const executed = await executeTeamRun({ manifest: loaded.manifest, tasks: resetTasks, team, workflow, agents: allAgents(discoverAgents(ctx.cwd)), executeWorkers, limits: loadedConfig.config.limits });
|
|
373
375
|
return result([`Resumed run ${executed.manifest.runId}.`, `Status: ${executed.manifest.status}`, `Tasks: ${executed.tasks.length}`, `Artifacts: ${executed.manifest.artifactsRoot}`].join("\n"), { action: "resume", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
|
|
374
376
|
});
|
|
375
377
|
}
|
|
@@ -3,6 +3,7 @@ import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
|
3
3
|
import { appendEvent } from "../state/event-log.ts";
|
|
4
4
|
import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
|
|
5
5
|
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
6
|
+
import { loadConfig } from "../config/config.ts";
|
|
6
7
|
import { executeTeamRun } from "./team-runner.ts";
|
|
7
8
|
|
|
8
9
|
function argValue(name: string): string | undefined {
|
|
@@ -27,8 +28,9 @@ async function main(): Promise<void> {
|
|
|
27
28
|
const workflow = allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
|
|
28
29
|
if (!workflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
|
|
29
30
|
const agents = allAgents(discoverAgents(cwd));
|
|
30
|
-
const
|
|
31
|
-
const
|
|
31
|
+
const loadedConfig = loadConfig(cwd);
|
|
32
|
+
const executeWorkers = loadedConfig.config.executeWorkers === true || process.env.PI_TEAMS_EXECUTE_WORKERS === "1";
|
|
33
|
+
const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits });
|
|
32
34
|
manifest = result.manifest;
|
|
33
35
|
tasks = result.tasks;
|
|
34
36
|
appendEvent(manifest.eventsPath, { type: "async.completed", runId: manifest.runId, data: { status: manifest.status, tasks: tasks.length } });
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GreenLevel, VerificationContract, VerificationEvidence } from "../state/types.ts";
|
|
2
|
+
|
|
3
|
+
const GREEN_ORDER: Record<GreenLevel, number> = {
|
|
4
|
+
none: 0,
|
|
5
|
+
targeted: 1,
|
|
6
|
+
package: 2,
|
|
7
|
+
workspace: 3,
|
|
8
|
+
merge_ready: 4,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export interface GreenContractOutcome {
|
|
12
|
+
requiredGreenLevel: GreenLevel;
|
|
13
|
+
observedGreenLevel: GreenLevel;
|
|
14
|
+
satisfied: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function greenLevelSatisfies(observed: GreenLevel, required: GreenLevel): boolean {
|
|
18
|
+
return GREEN_ORDER[observed] >= GREEN_ORDER[required];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function evaluateGreenContract(contract: VerificationContract, evidence?: VerificationEvidence): GreenContractOutcome {
|
|
22
|
+
const observedGreenLevel = evidence?.observedGreenLevel ?? "none";
|
|
23
|
+
return {
|
|
24
|
+
requiredGreenLevel: contract.requiredGreenLevel,
|
|
25
|
+
observedGreenLevel,
|
|
26
|
+
satisfied: greenLevelSatisfies(observedGreenLevel, contract.requiredGreenLevel),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function inferGreenLevelFromTask(success: boolean, contract: VerificationContract): GreenLevel {
|
|
31
|
+
if (!success) return "none";
|
|
32
|
+
if (contract.requiredGreenLevel === "none") return "none";
|
|
33
|
+
return contract.allowManualEvidence ? contract.requiredGreenLevel : "targeted";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createVerificationEvidence(contract: VerificationContract, success: boolean, notes: string): VerificationEvidence {
|
|
37
|
+
const observedGreenLevel = inferGreenLevelFromTask(success, contract);
|
|
38
|
+
const outcome = evaluateGreenContract(contract, { requiredGreenLevel: contract.requiredGreenLevel, observedGreenLevel, satisfied: false, commands: [], notes });
|
|
39
|
+
return {
|
|
40
|
+
requiredGreenLevel: contract.requiredGreenLevel,
|
|
41
|
+
observedGreenLevel,
|
|
42
|
+
satisfied: outcome.satisfied,
|
|
43
|
+
commands: contract.commands.map((cmd) => ({ cmd, status: "not_run" as const })),
|
|
44
|
+
notes,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CrewLimitsConfig } from "../config/config.ts";
|
|
2
|
+
import type { PolicyDecision, PolicyDecisionAction, PolicyDecisionReason, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
3
|
+
import { evaluateGreenContract } from "./green-contract.ts";
|
|
4
|
+
import { isWorkerHeartbeatStale } from "./worker-heartbeat.ts";
|
|
5
|
+
|
|
6
|
+
export interface PolicyEngineInput {
|
|
7
|
+
manifest: TeamRunManifest;
|
|
8
|
+
tasks: TeamTaskState[];
|
|
9
|
+
limits?: CrewLimitsConfig;
|
|
10
|
+
now?: Date;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function decision(action: PolicyDecisionAction, reason: PolicyDecisionReason, message: string, taskId?: string): PolicyDecision {
|
|
14
|
+
return {
|
|
15
|
+
action,
|
|
16
|
+
reason,
|
|
17
|
+
message,
|
|
18
|
+
taskId,
|
|
19
|
+
createdAt: new Date().toISOString(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function evaluateCrewPolicy(input: PolicyEngineInput): PolicyDecision[] {
|
|
24
|
+
const decisions: PolicyDecision[] = [];
|
|
25
|
+
const maxTasksPerRun = input.limits?.maxTasksPerRun;
|
|
26
|
+
if (maxTasksPerRun !== undefined && input.tasks.length > maxTasksPerRun) {
|
|
27
|
+
decisions.push(decision("block", "limit_exceeded", `Run has ${input.tasks.length} tasks, exceeding maxTasksPerRun=${maxTasksPerRun}.`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const task of input.tasks) {
|
|
31
|
+
if (task.status === "failed") {
|
|
32
|
+
const retryCount = task.policy?.retryCount ?? 0;
|
|
33
|
+
const maxRetries = input.limits?.maxRetriesPerTask ?? 0;
|
|
34
|
+
decisions.push(decision(retryCount < maxRetries ? "retry" : "escalate", "task_failed", task.error ? `Task failed: ${task.error}` : "Task failed.", task.id));
|
|
35
|
+
}
|
|
36
|
+
if (task.heartbeat && isWorkerHeartbeatStale(task.heartbeat, input.limits?.heartbeatStaleMs ?? 60_000, input.now)) {
|
|
37
|
+
decisions.push(decision("escalate", "worker_stale", "Worker heartbeat is stale.", task.id));
|
|
38
|
+
}
|
|
39
|
+
if (task.taskPacket?.verification) {
|
|
40
|
+
const outcome = evaluateGreenContract(task.taskPacket.verification, task.verification);
|
|
41
|
+
if (!outcome.satisfied && task.status === "completed") {
|
|
42
|
+
decisions.push(decision("block", "green_unsatisfied", `Green contract unsatisfied: required=${outcome.requiredGreenLevel}, observed=${outcome.observedGreenLevel}.`, task.id));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (decisions.length === 0 && input.tasks.length > 0 && input.tasks.every((task) => task.status === "completed")) {
|
|
48
|
+
decisions.push(decision("closeout", "run_complete", "All tasks completed and no policy blockers were found."));
|
|
49
|
+
}
|
|
50
|
+
return decisions;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function summarizePolicyDecisions(decisions: PolicyDecision[]): string[] {
|
|
54
|
+
return decisions.map((item) => `- ${item.action} (${item.reason})${item.taskId ? ` ${item.taskId}` : ""}: ${item.message}`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { TeamRunManifest, TaskPacket, TaskScope, VerificationContract } from "../state/types.ts";
|
|
3
|
+
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
4
|
+
|
|
5
|
+
export interface BuildTaskPacketInput {
|
|
6
|
+
manifest: TeamRunManifest;
|
|
7
|
+
step: WorkflowStep;
|
|
8
|
+
taskId: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
worktreePath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TaskPacketValidationResult {
|
|
14
|
+
valid: boolean;
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function inferTaskScope(step: WorkflowStep): TaskScope {
|
|
19
|
+
const reads = step.reads === false ? [] : step.reads ?? [];
|
|
20
|
+
if (reads.length === 1) return "single_file";
|
|
21
|
+
if (reads.length > 1) return "module";
|
|
22
|
+
return "workspace";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function defaultVerificationContract(step: WorkflowStep): VerificationContract {
|
|
26
|
+
return {
|
|
27
|
+
requiredGreenLevel: step.verify ? "targeted" : "none",
|
|
28
|
+
commands: [],
|
|
29
|
+
allowManualEvidence: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
|
|
34
|
+
const scope = inferTaskScope(input.step);
|
|
35
|
+
const reads = input.step.reads === false ? [] : input.step.reads ?? [];
|
|
36
|
+
const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
|
|
37
|
+
return {
|
|
38
|
+
objective: input.step.task.replaceAll("{goal}", input.manifest.goal),
|
|
39
|
+
scope,
|
|
40
|
+
scopePath,
|
|
41
|
+
repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
|
|
42
|
+
worktree: input.worktreePath,
|
|
43
|
+
branchPolicy: input.manifest.workspaceMode === "worktree" ? "Use the assigned task worktree and avoid modifying the leader checkout." : "Use the current checkout; do not create branches unless explicitly requested.",
|
|
44
|
+
acceptanceTests: [],
|
|
45
|
+
commitPolicy: "Do not commit unless explicitly requested by the user or workflow.",
|
|
46
|
+
reportingContract: "Report changed files, verification evidence, blockers, and next recommended action.",
|
|
47
|
+
escalationPolicy: "Stop and report if scope is ambiguous, destructive action is needed, permissions are missing, or verification cannot be completed.",
|
|
48
|
+
constraints: [
|
|
49
|
+
"Stay within the assigned task scope.",
|
|
50
|
+
"Do not claim completion without verification evidence.",
|
|
51
|
+
"Use mailbox/API state for coordination when available.",
|
|
52
|
+
],
|
|
53
|
+
expectedArtifacts: ["prompt", "result", "verification"],
|
|
54
|
+
verification: defaultVerificationContract(input.step),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validateTaskPacket(packet: TaskPacket): TaskPacketValidationResult {
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
if (!packet.objective.trim()) errors.push("objective must not be empty");
|
|
61
|
+
if (!packet.repo.trim()) errors.push("repo must not be empty");
|
|
62
|
+
if (!packet.branchPolicy.trim()) errors.push("branchPolicy must not be empty");
|
|
63
|
+
if (!packet.commitPolicy.trim()) errors.push("commitPolicy must not be empty");
|
|
64
|
+
if (!packet.reportingContract.trim()) errors.push("reportingContract must not be empty");
|
|
65
|
+
if (!packet.escalationPolicy.trim()) errors.push("escalationPolicy must not be empty");
|
|
66
|
+
if ((packet.scope === "module" || packet.scope === "single_file" || packet.scope === "custom") && !packet.scopePath?.trim()) {
|
|
67
|
+
errors.push(`scopePath is required for scope '${packet.scope}'`);
|
|
68
|
+
}
|
|
69
|
+
for (const [index, test] of packet.acceptanceTests.entries()) {
|
|
70
|
+
if (!test.trim()) errors.push(`acceptanceTests contains an empty value at index ${index}`);
|
|
71
|
+
}
|
|
72
|
+
return { valid: errors.length === 0, errors };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderTaskPacket(packet: TaskPacket): string {
|
|
76
|
+
return [
|
|
77
|
+
"# Task Packet",
|
|
78
|
+
"",
|
|
79
|
+
"```json",
|
|
80
|
+
JSON.stringify(packet, null, 2),
|
|
81
|
+
"```",
|
|
82
|
+
"",
|
|
83
|
+
].join("\n");
|
|
84
|
+
}
|
|
@@ -10,6 +10,8 @@ import { captureWorktreeDiff, prepareTaskWorkspace } from "../worktree/worktree-
|
|
|
10
10
|
import { buildModelCandidates, formatModelAttemptNote, isRetryableModelFailure, type ModelAttemptSummary } from "./model-fallback.ts";
|
|
11
11
|
import { parsePiJsonOutput, type ParsedPiJsonOutput } from "./pi-json-output.ts";
|
|
12
12
|
import { runChildPi } from "./child-pi.ts";
|
|
13
|
+
import { buildTaskPacket, renderTaskPacket } from "./task-packet.ts";
|
|
14
|
+
import { createVerificationEvidence } from "./green-contract.ts";
|
|
13
15
|
|
|
14
16
|
export interface TaskRunnerInput {
|
|
15
17
|
manifest: TeamRunManifest;
|
|
@@ -43,7 +45,9 @@ function renderTaskPrompt(manifest: TeamRunManifest, step: WorkflowStep, task: T
|
|
|
43
45
|
"- Stay within the task scope unless the prompt explicitly says otherwise.",
|
|
44
46
|
"- Report blockers and verification evidence in the final result.",
|
|
45
47
|
"- Do not claim completion without evidence.",
|
|
48
|
+
"- Follow the Task Packet contract below; escalate if any contract field is impossible to satisfy.",
|
|
46
49
|
"",
|
|
50
|
+
task.taskPacket ? renderTaskPacket(task.taskPacket) : "",
|
|
47
51
|
"Task:",
|
|
48
52
|
step.task.replaceAll("{goal}", manifest.goal),
|
|
49
53
|
].join("\n");
|
|
@@ -56,10 +60,13 @@ function updateTask(tasks: TeamTaskState[], updated: TeamTaskState): TeamTaskSta
|
|
|
56
60
|
export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
57
61
|
let manifest = input.manifest;
|
|
58
62
|
const workspace = prepareTaskWorkspace(manifest, input.task);
|
|
63
|
+
const worktree = workspace.worktreePath && workspace.branch ? { path: workspace.worktreePath, branch: workspace.branch, reused: workspace.reused ?? false } : input.task.worktree;
|
|
64
|
+
const taskPacket = buildTaskPacket({ manifest, step: input.step, taskId: input.task.id, cwd: workspace.cwd, worktreePath: worktree?.path });
|
|
59
65
|
let task: TeamTaskState = {
|
|
60
66
|
...input.task,
|
|
61
67
|
cwd: workspace.cwd,
|
|
62
|
-
worktree
|
|
68
|
+
worktree,
|
|
69
|
+
taskPacket,
|
|
63
70
|
status: "running",
|
|
64
71
|
startedAt: new Date().toISOString(),
|
|
65
72
|
claim: createTaskClaim(`task-runner:${input.task.id}`),
|
|
@@ -149,6 +156,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
149
156
|
usage: parsedOutput?.usage,
|
|
150
157
|
jsonEvents: parsedOutput?.jsonEvents,
|
|
151
158
|
error,
|
|
159
|
+
verification: createVerificationEvidence(taskPacket.verification, !error, error ? `Task failed: ${error}` : input.executeWorkers ? "Worker finished without reporting a verification failure." : "Safe scaffold mode; verification commands were not executed."),
|
|
152
160
|
promptArtifact,
|
|
153
161
|
resultArtifact,
|
|
154
162
|
claim: undefined,
|
|
@@ -156,7 +164,19 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
156
164
|
...(logArtifact ? { logArtifact } : {}),
|
|
157
165
|
};
|
|
158
166
|
tasks = updateTask(tasks, task);
|
|
159
|
-
|
|
167
|
+
const packetArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
168
|
+
kind: "metadata",
|
|
169
|
+
relativePath: `metadata/${task.id}.task-packet.json`,
|
|
170
|
+
content: `${JSON.stringify(task.taskPacket, null, 2)}\n`,
|
|
171
|
+
producer: task.id,
|
|
172
|
+
});
|
|
173
|
+
const verificationArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
174
|
+
kind: "metadata",
|
|
175
|
+
relativePath: `metadata/${task.id}.verification.json`,
|
|
176
|
+
content: `${JSON.stringify(task.verification, null, 2)}\n`,
|
|
177
|
+
producer: task.id,
|
|
178
|
+
});
|
|
179
|
+
manifest = { ...manifest, updatedAt: new Date().toISOString(), artifacts: [...manifest.artifacts, promptArtifact, resultArtifact, packetArtifact, verificationArtifact, ...(logArtifact ? [logArtifact] : []), ...(diffArtifact ? [diffArtifact] : [])] };
|
|
160
180
|
saveRunManifest(manifest);
|
|
161
181
|
saveRunTasks(manifest, tasks);
|
|
162
182
|
appendEvent(manifest.eventsPath, { type: error ? "task.failed" : "task.completed", runId: manifest.runId, taskId: task.id, message: error });
|
|
@@ -1,135 +1,162 @@
|
|
|
1
|
-
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
1
|
+
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
2
|
+
import type { CrewLimitsConfig } from "../config/config.ts";
|
|
3
|
+
import { writeArtifact } from "../state/artifact-store.ts";
|
|
4
|
+
import { appendEvent } from "../state/event-log.ts";
|
|
5
|
+
import type { TeamConfig } from "../teams/team-config.ts";
|
|
6
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
7
|
+
import { saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
|
|
8
|
+
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
9
|
+
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
10
|
+
import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
|
|
11
|
+
import { runTeamTask } from "./task-runner.ts";
|
|
12
|
+
|
|
13
|
+
export interface ExecuteTeamRunInput {
|
|
14
|
+
manifest: TeamRunManifest;
|
|
15
|
+
tasks: TeamTaskState[];
|
|
16
|
+
team: TeamConfig;
|
|
17
|
+
workflow: WorkflowConfig;
|
|
18
|
+
agents: AgentConfig[];
|
|
19
|
+
executeWorkers: boolean;
|
|
20
|
+
limits?: CrewLimitsConfig;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function findReadyTask(tasks: TeamTaskState[]): TeamTaskState | undefined {
|
|
25
|
+
const completedStepIds = new Set(tasks.filter((task) => task.status === "completed").map((task) => task.stepId).filter((id): id is string => id !== undefined));
|
|
26
|
+
return tasks.find((task) => task.status === "queued" && task.dependsOn.every((dep) => completedStepIds.has(dep)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
|
|
30
|
+
const step = workflow.steps.find((candidate) => candidate.id === task.stepId);
|
|
31
|
+
if (!step) throw new Error(`Workflow step '${task.stepId}' not found for task '${task.id}'.`);
|
|
32
|
+
return step;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findAgent(agents: AgentConfig[], task: TeamTaskState): AgentConfig {
|
|
36
|
+
const agent = agents.find((candidate) => candidate.name === task.agent);
|
|
37
|
+
if (!agent) throw new Error(`Agent '${task.agent}' not found for task '${task.id}'.`);
|
|
38
|
+
return agent;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function markBlocked(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
42
|
+
return tasks.map((task) => task.status === "queued" ? { ...task, status: "skipped", error: reason, finishedAt: new Date().toISOString(), graph: task.graph ? { ...task.graph, queue: "blocked" } : undefined } : task);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatTaskProgress(task: TeamTaskState): string {
|
|
46
|
+
return `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.taskPacket ? ` scope=${task.taskPacket.scope}` : ""}${task.verification ? ` green=${task.verification.observedGreenLevel}/${task.verification.requiredGreenLevel}` : ""}${task.error ? ` - ${task.error}` : ""}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], producer: string): TeamRunManifest {
|
|
50
|
+
const counts = new Map<string, number>();
|
|
51
|
+
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
52
|
+
const progress = writeArtifact(manifest.artifactsRoot, {
|
|
53
|
+
kind: "progress",
|
|
54
|
+
relativePath: "progress.md",
|
|
55
|
+
producer,
|
|
56
|
+
content: [
|
|
57
|
+
`# pi-crew progress ${manifest.runId}`,
|
|
58
|
+
"",
|
|
59
|
+
`Status: ${manifest.status}`,
|
|
60
|
+
`Team: ${manifest.team}`,
|
|
61
|
+
`Workflow: ${manifest.workflow ?? "(none)"}`,
|
|
62
|
+
`Updated: ${new Date().toISOString()}`,
|
|
63
|
+
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
64
|
+
"",
|
|
65
|
+
"## Tasks",
|
|
66
|
+
...tasks.map(formatTaskProgress),
|
|
67
|
+
"",
|
|
68
|
+
].join("\n"),
|
|
69
|
+
});
|
|
70
|
+
return { ...manifest, updatedAt: new Date().toISOString(), artifacts: [...manifest.artifacts.filter((artifact) => !(artifact.kind === "progress" && artifact.path === progress.path)), progress] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?: CrewLimitsConfig): TeamRunManifest {
|
|
74
|
+
const decisions = evaluateCrewPolicy({ manifest, tasks, limits });
|
|
75
|
+
const policyArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
76
|
+
kind: "metadata",
|
|
77
|
+
relativePath: "policy-decisions.json",
|
|
78
|
+
producer: "policy-engine",
|
|
79
|
+
content: `${JSON.stringify(decisions, null, 2)}\n`,
|
|
80
|
+
});
|
|
81
|
+
for (const item of decisions) appendEvent(manifest.eventsPath, { type: item.action === "escalate" ? "policy.escalated" : "policy.action", runId: manifest.runId, taskId: item.taskId, message: item.message, data: { action: item.action, reason: item.reason } });
|
|
82
|
+
return { ...manifest, updatedAt: new Date().toISOString(), policyDecisions: decisions, artifacts: [...manifest.artifacts.filter((artifact) => !(artifact.kind === "metadata" && artifact.path.endsWith("policy-decisions.json"))), policyArtifact] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
86
|
+
let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
|
|
87
|
+
let tasks = input.tasks;
|
|
88
|
+
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
89
|
+
saveRunManifest(manifest);
|
|
90
|
+
|
|
91
|
+
while (tasks.some((task) => task.status === "queued")) {
|
|
92
|
+
if (input.signal?.aborted) {
|
|
93
|
+
tasks = tasks.map((task) => task.status === "queued" || task.status === "running" ? { ...task, status: "cancelled", finishedAt: new Date().toISOString(), error: "Run cancelled." } : task);
|
|
94
|
+
saveRunTasks(manifest, tasks);
|
|
95
|
+
manifest = updateRunStatus(manifest, "cancelled", "Run cancelled.");
|
|
96
|
+
return { manifest, tasks };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const failed = tasks.find((task) => task.status === "failed");
|
|
100
|
+
if (failed) {
|
|
101
|
+
tasks = markBlocked(tasks, `Blocked by failed task '${failed.id}'.`);
|
|
102
|
+
saveRunTasks(manifest, tasks);
|
|
103
|
+
manifest = updateRunStatus(manifest, "failed", `Failed at task '${failed.id}'.`);
|
|
104
|
+
return { manifest, tasks };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const task = findReadyTask(tasks);
|
|
108
|
+
if (!task) {
|
|
109
|
+
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
110
|
+
saveRunTasks(manifest, tasks);
|
|
111
|
+
manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
|
|
112
|
+
return { manifest, tasks };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const step = findStep(input.workflow, task);
|
|
116
|
+
const agent = findAgent(input.agents, task);
|
|
117
|
+
const result = await runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers });
|
|
118
|
+
manifest = result.manifest;
|
|
119
|
+
tasks = result.tasks;
|
|
120
|
+
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
121
|
+
saveRunManifest(manifest);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const failed = tasks.find((task) => task.status === "failed");
|
|
125
|
+
manifest = applyPolicy(manifest, tasks, input.limits);
|
|
126
|
+
const blockingDecision = manifest.policyDecisions?.find((item) => item.action === "block" || item.action === "escalate");
|
|
127
|
+
if (failed) {
|
|
128
|
+
manifest = updateRunStatus(manifest, "failed", `Failed at task '${failed.id}'.`);
|
|
129
|
+
} else if (blockingDecision) {
|
|
130
|
+
manifest = updateRunStatus(manifest, "blocked", blockingDecision.message);
|
|
131
|
+
} else {
|
|
132
|
+
manifest = updateRunStatus(manifest, "completed", input.executeWorkers ? "Team workflow completed." : "Team workflow scaffold completed without launching child workers.");
|
|
133
|
+
}
|
|
134
|
+
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
135
|
+
saveRunManifest(manifest);
|
|
136
|
+
const usage = aggregateUsage(tasks);
|
|
137
|
+
const summaryArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
138
|
+
kind: "summary",
|
|
139
|
+
relativePath: "summary.md",
|
|
140
|
+
producer: "team-runner",
|
|
141
|
+
content: [
|
|
142
|
+
`# pi-crew run ${manifest.runId}`,
|
|
143
|
+
"",
|
|
144
|
+
`Status: ${manifest.status}`,
|
|
145
|
+
`Team: ${manifest.team}`,
|
|
146
|
+
`Workflow: ${manifest.workflow ?? "(none)"}`,
|
|
147
|
+
`Goal: ${manifest.goal}`,
|
|
148
|
+
`Usage: ${formatUsage(usage)}`,
|
|
149
|
+
"",
|
|
150
|
+
"## Tasks",
|
|
151
|
+
...tasks.map(formatTaskProgress),
|
|
152
|
+
"",
|
|
153
|
+
"## Policy decisions",
|
|
154
|
+
...(manifest.policyDecisions?.length ? summarizePolicyDecisions(manifest.policyDecisions) : ["- (none)"]),
|
|
155
|
+
"",
|
|
156
|
+
].join("\n"),
|
|
157
|
+
});
|
|
158
|
+
manifest = { ...manifest, updatedAt: new Date().toISOString(), artifacts: [...manifest.artifacts, summaryArtifact] };
|
|
159
|
+
saveRunManifest(manifest);
|
|
160
|
+
saveRunTasks(manifest, tasks);
|
|
161
|
+
return { manifest, tasks };
|
|
162
|
+
}
|
package/src/state/contracts.ts
CHANGED
|
@@ -36,9 +36,19 @@ export const TEAM_EVENT_TYPES = [
|
|
|
36
36
|
"run.failed",
|
|
37
37
|
"run.cancelled",
|
|
38
38
|
"task.started",
|
|
39
|
+
"task.progress",
|
|
40
|
+
"task.blocked",
|
|
41
|
+
"task.green",
|
|
42
|
+
"task.red",
|
|
39
43
|
"task.completed",
|
|
40
44
|
"task.failed",
|
|
45
|
+
"review.approved",
|
|
46
|
+
"review.rejected",
|
|
47
|
+
"policy.action",
|
|
48
|
+
"policy.escalated",
|
|
49
|
+
"mailbox.timeout",
|
|
41
50
|
"worktree.cleanup",
|
|
51
|
+
"worktree.dirty",
|
|
42
52
|
"async.spawned",
|
|
43
53
|
"async.started",
|
|
44
54
|
"async.completed",
|
package/src/state/state-store.ts
CHANGED
|
@@ -39,18 +39,29 @@ export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig, team: TeamConfig, cwd: string): TeamTaskState[] {
|
|
42
|
+
const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
|
|
42
43
|
return workflow.steps.map((step, index) => {
|
|
43
44
|
const role = team.roles.find((candidate) => candidate.name === step.role);
|
|
45
|
+
const id = stepToTaskId.get(step.id) ?? createTaskId(step.id, index);
|
|
46
|
+
const dependencies = step.dependsOn ?? [];
|
|
47
|
+
const children = workflow.steps.filter((candidate) => candidate.dependsOn?.includes(step.id)).map((candidate) => stepToTaskId.get(candidate.id)).filter((childId): childId is string => childId !== undefined);
|
|
44
48
|
return {
|
|
45
|
-
id
|
|
49
|
+
id,
|
|
46
50
|
runId,
|
|
47
51
|
stepId: step.id,
|
|
48
52
|
role: step.role,
|
|
49
53
|
agent: role?.agent ?? step.role,
|
|
50
54
|
title: step.id,
|
|
51
55
|
status: "queued",
|
|
52
|
-
dependsOn:
|
|
56
|
+
dependsOn: dependencies,
|
|
53
57
|
cwd,
|
|
58
|
+
graph: {
|
|
59
|
+
taskId: id,
|
|
60
|
+
parentId: dependencies[0] ? stepToTaskId.get(dependencies[0]) : undefined,
|
|
61
|
+
children,
|
|
62
|
+
dependencies: dependencies.map((dep) => stepToTaskId.get(dep) ?? dep),
|
|
63
|
+
queue: dependencies.length ? "blocked" : "ready",
|
|
64
|
+
},
|
|
54
65
|
};
|
|
55
66
|
});
|
|
56
67
|
}
|
package/src/state/types.ts
CHANGED
|
@@ -14,6 +14,66 @@ export interface ArtifactDescriptor {
|
|
|
14
14
|
expiresAt?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export type TaskScope = "workspace" | "module" | "single_file" | "custom";
|
|
18
|
+
export type GreenLevel = "none" | "targeted" | "package" | "workspace" | "merge_ready";
|
|
19
|
+
|
|
20
|
+
export interface VerificationCommandResult {
|
|
21
|
+
cmd: string;
|
|
22
|
+
status: "passed" | "failed" | "not_run";
|
|
23
|
+
exitCode?: number | null;
|
|
24
|
+
outputArtifact?: ArtifactDescriptor;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VerificationContract {
|
|
28
|
+
requiredGreenLevel: GreenLevel;
|
|
29
|
+
commands: string[];
|
|
30
|
+
allowManualEvidence: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VerificationEvidence {
|
|
34
|
+
requiredGreenLevel: GreenLevel;
|
|
35
|
+
observedGreenLevel: GreenLevel;
|
|
36
|
+
satisfied: boolean;
|
|
37
|
+
commands: VerificationCommandResult[];
|
|
38
|
+
notes?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TaskPacket {
|
|
42
|
+
objective: string;
|
|
43
|
+
scope: TaskScope;
|
|
44
|
+
scopePath?: string;
|
|
45
|
+
repo: string;
|
|
46
|
+
worktree?: string;
|
|
47
|
+
branchPolicy: string;
|
|
48
|
+
acceptanceTests: string[];
|
|
49
|
+
commitPolicy: string;
|
|
50
|
+
reportingContract: string;
|
|
51
|
+
escalationPolicy: string;
|
|
52
|
+
constraints: string[];
|
|
53
|
+
expectedArtifacts: string[];
|
|
54
|
+
verification: VerificationContract;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type PolicyDecisionAction = "retry" | "reassign" | "escalate" | "block" | "notify" | "cleanup" | "closeout";
|
|
58
|
+
export type PolicyDecisionReason = "task_failed" | "worker_stale" | "green_unsatisfied" | "limit_exceeded" | "run_complete" | "mailbox_timeout" | "review_rejected" | "branch_stale" | "scope_mismatch";
|
|
59
|
+
|
|
60
|
+
export interface PolicyDecision {
|
|
61
|
+
action: PolicyDecisionAction;
|
|
62
|
+
reason: PolicyDecisionReason;
|
|
63
|
+
message: string;
|
|
64
|
+
taskId?: string;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TaskGraphNode {
|
|
69
|
+
taskId: string;
|
|
70
|
+
parentId?: string;
|
|
71
|
+
children: string[];
|
|
72
|
+
dependencies: string[];
|
|
73
|
+
queue: "ready" | "blocked" | "running" | "done";
|
|
74
|
+
sessionForkFrom?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
17
77
|
export interface AsyncRunState {
|
|
18
78
|
pid?: number;
|
|
19
79
|
logPath: string;
|
|
@@ -38,6 +98,7 @@ export interface TeamRunManifest {
|
|
|
38
98
|
artifacts: ArtifactDescriptor[];
|
|
39
99
|
async?: AsyncRunState;
|
|
40
100
|
summary?: string;
|
|
101
|
+
policyDecisions?: PolicyDecision[];
|
|
41
102
|
}
|
|
42
103
|
|
|
43
104
|
export interface UsageState {
|
|
@@ -85,4 +146,11 @@ export interface TeamTaskState {
|
|
|
85
146
|
error?: string;
|
|
86
147
|
claim?: TaskClaimState;
|
|
87
148
|
heartbeat?: WorkerHeartbeatState;
|
|
149
|
+
taskPacket?: TaskPacket;
|
|
150
|
+
verification?: VerificationEvidence;
|
|
151
|
+
graph?: TaskGraphNode;
|
|
152
|
+
policy?: {
|
|
153
|
+
retryCount?: number;
|
|
154
|
+
lastDecision?: PolicyDecision;
|
|
155
|
+
};
|
|
88
156
|
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import type { Component } from "@mariozechner/pi-tui";
|
|
3
2
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
4
3
|
|
|
4
|
+
interface DashboardComponent {
|
|
5
|
+
invalidate(): void;
|
|
6
|
+
render(width: number): string[];
|
|
7
|
+
handleInput(data: string): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "reload";
|
|
6
11
|
export interface RunDashboardSelection {
|
|
7
12
|
runId: string;
|
|
@@ -41,7 +46,7 @@ function countByStatus(runs: TeamRunManifest[]): string {
|
|
|
41
46
|
return [...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none";
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
export class RunDashboard implements
|
|
49
|
+
export class RunDashboard implements DashboardComponent {
|
|
45
50
|
private selected = 0;
|
|
46
51
|
private showFullProgress = false;
|
|
47
52
|
private readonly runs: TeamRunManifest[];
|