pi-crew 0.1.4 → 0.1.6
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/package.json +4 -2
- package/schema.json +24 -0
- package/src/config/config.ts +64 -0
- package/src/extension/team-tool.ts +79 -8
- package/src/runtime/agent-control.ts +64 -0
- package/src/runtime/async-runner.ts +30 -1
- package/src/runtime/background-runner.ts +4 -2
- package/src/runtime/child-pi.ts +78 -4
- package/src/runtime/crew-agent-records.ts +99 -0
- package/src/runtime/crew-agent-runtime.ts +53 -0
- package/src/runtime/group-join.ts +88 -0
- package/src/runtime/live-session-runtime.ts +33 -0
- package/src/runtime/policy-engine.ts +23 -0
- package/src/runtime/recovery-recipes.ts +74 -0
- package/src/runtime/role-permission.ts +28 -0
- package/src/runtime/runtime-resolver.ts +75 -0
- package/src/runtime/session-usage.ts +79 -0
- package/src/runtime/task-graph-scheduler.ts +107 -0
- package/src/runtime/task-output-context.ts +106 -0
- package/src/runtime/task-runner.ts +214 -4
- package/src/runtime/team-runner.ts +86 -14
- package/src/runtime/worker-startup.ts +57 -0
- package/src/state/contracts.ts +7 -0
- package/src/state/event-log.ts +84 -2
- package/src/state/state-store.ts +23 -2
- package/src/state/types.ts +3 -0
- package/src/worktree/branch-freshness.ts +45 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
2
|
-
import type { CrewLimitsConfig } from "../config/config.ts";
|
|
2
|
+
import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
|
|
3
|
+
import type { CrewRuntimeCapabilities } from "./runtime-resolver.ts";
|
|
3
4
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
4
5
|
import { appendEvent } from "../state/event-log.ts";
|
|
5
6
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
6
|
-
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
7
|
+
import type { ArtifactDescriptor, PolicyDecision, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
7
8
|
import { saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
|
|
8
9
|
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
9
10
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
10
11
|
import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
|
|
12
|
+
import { buildRecoveryLedger } from "./recovery-recipes.ts";
|
|
13
|
+
import { getReadyTasks, refreshTaskGraphQueues, taskGraphSnapshot } from "./task-graph-scheduler.ts";
|
|
14
|
+
import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
|
|
15
|
+
import { aggregateTaskOutputs } from "./task-output-context.ts";
|
|
16
|
+
import { recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
|
|
17
|
+
import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
|
|
11
18
|
import { runTeamTask } from "./task-runner.ts";
|
|
12
19
|
|
|
13
20
|
export interface ExecuteTeamRunInput {
|
|
@@ -18,12 +25,13 @@ export interface ExecuteTeamRunInput {
|
|
|
18
25
|
agents: AgentConfig[];
|
|
19
26
|
executeWorkers: boolean;
|
|
20
27
|
limits?: CrewLimitsConfig;
|
|
28
|
+
runtime?: CrewRuntimeCapabilities;
|
|
29
|
+
runtimeConfig?: CrewRuntimeConfig;
|
|
21
30
|
signal?: AbortSignal;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
function findReadyTask(tasks: TeamTaskState[]): TeamTaskState | undefined {
|
|
25
|
-
|
|
26
|
-
return tasks.find((task) => task.status === "queued" && task.dependsOn.every((dep) => completedStepIds.has(dep)));
|
|
34
|
+
return getReadyTasks(tasks, 1)[0];
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
|
|
@@ -42,6 +50,26 @@ function markBlocked(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
|
42
50
|
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
51
|
}
|
|
44
52
|
|
|
53
|
+
function mergeArtifacts(items: ArtifactDescriptor[]): ArtifactDescriptor[] {
|
|
54
|
+
const byPath = new Map<string, ArtifactDescriptor>();
|
|
55
|
+
for (const item of items) byPath.set(item.path, item);
|
|
56
|
+
return [...byPath.values()];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mergeTaskUpdates(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
|
|
60
|
+
let merged = base;
|
|
61
|
+
for (const result of results) {
|
|
62
|
+
for (const updated of result.tasks) {
|
|
63
|
+
const current = merged.find((task) => task.id === updated.id);
|
|
64
|
+
if (!current) continue;
|
|
65
|
+
if (updated.status !== current.status || updated.finishedAt || updated.startedAt || updated.resultArtifact || updated.error) {
|
|
66
|
+
merged = merged.map((task) => task.id === updated.id ? updated : task);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return refreshTaskGraphQueues(merged);
|
|
71
|
+
}
|
|
72
|
+
|
|
45
73
|
function formatTaskProgress(task: TeamTaskState): string {
|
|
46
74
|
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
75
|
}
|
|
@@ -49,6 +77,7 @@ function formatTaskProgress(task: TeamTaskState): string {
|
|
|
49
77
|
function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], producer: string): TeamRunManifest {
|
|
50
78
|
const counts = new Map<string, number>();
|
|
51
79
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
80
|
+
const queue = taskGraphSnapshot(tasks);
|
|
52
81
|
const progress = writeArtifact(manifest.artifactsRoot, {
|
|
53
82
|
kind: "progress",
|
|
54
83
|
relativePath: "progress.md",
|
|
@@ -61,6 +90,7 @@ function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], produc
|
|
|
61
90
|
`Workflow: ${manifest.workflow ?? "(none)"}`,
|
|
62
91
|
`Updated: ${new Date().toISOString()}`,
|
|
63
92
|
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
93
|
+
`Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
|
|
64
94
|
"",
|
|
65
95
|
"## Tasks",
|
|
66
96
|
...tasks.map(formatTaskProgress),
|
|
@@ -71,22 +101,49 @@ function writeProgress(manifest: TeamRunManifest, tasks: TeamTaskState[], produc
|
|
|
71
101
|
}
|
|
72
102
|
|
|
73
103
|
function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?: CrewLimitsConfig): TeamRunManifest {
|
|
74
|
-
const
|
|
104
|
+
const branchFreshness = checkBranchFreshness(manifest.cwd);
|
|
105
|
+
const branchArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
106
|
+
kind: "metadata",
|
|
107
|
+
relativePath: "metadata/branch-freshness.json",
|
|
108
|
+
producer: "branch-freshness",
|
|
109
|
+
content: `${JSON.stringify(branchFreshness, null, 2)}\n`,
|
|
110
|
+
});
|
|
111
|
+
let decisions: PolicyDecision[] = evaluateCrewPolicy({ manifest, tasks, limits });
|
|
112
|
+
if (branchFreshness.status === "stale" || branchFreshness.status === "diverged") {
|
|
113
|
+
const branchDecision: PolicyDecision = {
|
|
114
|
+
action: "notify",
|
|
115
|
+
reason: "branch_stale",
|
|
116
|
+
message: branchFreshness.message,
|
|
117
|
+
createdAt: new Date().toISOString(),
|
|
118
|
+
};
|
|
119
|
+
decisions = [...decisions, branchDecision];
|
|
120
|
+
appendEvent(manifest.eventsPath, { type: "branch.stale", runId: manifest.runId, message: branchFreshness.message, data: { branchFreshness } });
|
|
121
|
+
}
|
|
75
122
|
const policyArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
76
123
|
kind: "metadata",
|
|
77
124
|
relativePath: "policy-decisions.json",
|
|
78
125
|
producer: "policy-engine",
|
|
79
126
|
content: `${JSON.stringify(decisions, null, 2)}\n`,
|
|
80
127
|
});
|
|
128
|
+
const recoveryLedger = buildRecoveryLedger(decisions);
|
|
129
|
+
const recoveryArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
130
|
+
kind: "metadata",
|
|
131
|
+
relativePath: "recovery-ledger.json",
|
|
132
|
+
producer: "recovery-engine",
|
|
133
|
+
content: `${JSON.stringify(recoveryLedger, null, 2)}\n`,
|
|
134
|
+
});
|
|
81
135
|
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
|
-
|
|
136
|
+
for (const item of recoveryLedger.entries) appendEvent(manifest.eventsPath, { type: item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted", runId: manifest.runId, taskId: item.taskId, message: item.message, data: { scenario: item.scenario, steps: item.steps, attempt: item.attempt, state: item.state } });
|
|
137
|
+
return { ...manifest, updatedAt: new Date().toISOString(), policyDecisions: decisions, artifacts: [...manifest.artifacts.filter((artifact) => !(artifact.kind === "metadata" && (artifact.path.endsWith("policy-decisions.json") || artifact.path.endsWith("recovery-ledger.json") || artifact.path.endsWith("branch-freshness.json")))), branchArtifact, policyArtifact, recoveryArtifact] };
|
|
83
138
|
}
|
|
84
139
|
|
|
85
140
|
export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
86
141
|
let manifest = updateRunStatus(input.manifest, "running", input.executeWorkers ? "Executing team workflow." : "Creating workflow prompts and placeholder results.");
|
|
87
|
-
let tasks = input.tasks;
|
|
142
|
+
let tasks = refreshTaskGraphQueues(input.tasks);
|
|
88
143
|
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
89
144
|
saveRunManifest(manifest);
|
|
145
|
+
const runtimeKind = input.runtime?.kind ?? (input.executeWorkers ? "child-process" : "scaffold");
|
|
146
|
+
saveCrewAgents(manifest, tasks.map((task) => recordFromTask(manifest, task, runtimeKind)));
|
|
90
147
|
|
|
91
148
|
while (tasks.some((task) => task.status === "queued")) {
|
|
92
149
|
if (input.signal?.aborted) {
|
|
@@ -104,19 +161,34 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
104
161
|
return { manifest, tasks };
|
|
105
162
|
}
|
|
106
163
|
|
|
107
|
-
const
|
|
108
|
-
|
|
164
|
+
const maxConcurrent = Math.max(1, input.limits?.maxConcurrentWorkers ?? 1);
|
|
165
|
+
const readyBatch = getReadyTasks(tasks, maxConcurrent);
|
|
166
|
+
if (readyBatch.length === 0) {
|
|
109
167
|
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
110
168
|
saveRunTasks(manifest, tasks);
|
|
111
169
|
manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
|
|
112
170
|
return { manifest, tasks };
|
|
113
171
|
}
|
|
114
172
|
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
173
|
+
appendEvent(manifest.eventsPath, { type: "task.progress", runId: manifest.runId, message: `Starting ready batch with ${readyBatch.length} task(s).`, data: { taskIds: readyBatch.map((task) => task.id), maxConcurrent } });
|
|
174
|
+
const results = await Promise.all(readyBatch.map((task) => {
|
|
175
|
+
const step = findStep(input.workflow, task);
|
|
176
|
+
const agent = findAgent(input.agents, task);
|
|
177
|
+
return runTeamTask({ manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers });
|
|
178
|
+
}));
|
|
179
|
+
manifest = { ...results.at(-1)!.manifest, artifacts: mergeArtifacts([manifest.artifacts, ...results.map((item) => item.manifest.artifacts)].flat()) };
|
|
180
|
+
tasks = mergeTaskUpdates(tasks, results);
|
|
181
|
+
saveRunTasks(manifest, tasks);
|
|
182
|
+
saveCrewAgents(manifest, tasks.map((task) => recordFromTask(manifest, task, runtimeKind)));
|
|
183
|
+
const completedBatch = readyBatch.map((task) => tasks.find((item) => item.id === task.id) ?? task);
|
|
184
|
+
const batchArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
185
|
+
kind: "summary",
|
|
186
|
+
relativePath: `batches/${readyBatch.map((task) => task.id).join("+")}.md`,
|
|
187
|
+
producer: "team-runner",
|
|
188
|
+
content: aggregateTaskOutputs(completedBatch),
|
|
189
|
+
});
|
|
190
|
+
const groupDelivery = deliverGroupJoin({ manifest, mode: resolveGroupJoinMode(input.runtimeConfig), batch: readyBatch, allTasks: tasks });
|
|
191
|
+
manifest = { ...manifest, artifacts: mergeArtifacts([...manifest.artifacts, batchArtifact, ...(groupDelivery?.artifact ? [groupDelivery.artifact] : [])]) };
|
|
120
192
|
manifest = writeProgress(manifest, tasks, "team-runner");
|
|
121
193
|
saveRunManifest(manifest);
|
|
122
194
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type WorkerLifecycleState = "spawning" | "trust_required" | "ready_for_prompt" | "running" | "finished" | "failed";
|
|
2
|
+
export type StartupFailureClassification = "trust_required" | "prompt_misdelivery" | "prompt_acceptance_timeout" | "transport_dead" | "worker_crashed" | "unknown";
|
|
3
|
+
|
|
4
|
+
export interface WorkerStartupEvidence {
|
|
5
|
+
lastLifecycleState: WorkerLifecycleState;
|
|
6
|
+
command: string;
|
|
7
|
+
promptSentAt?: string;
|
|
8
|
+
promptAccepted: boolean;
|
|
9
|
+
trustPromptDetected: boolean;
|
|
10
|
+
transportHealthy: boolean;
|
|
11
|
+
childProcessAlive: boolean;
|
|
12
|
+
elapsedMs: number;
|
|
13
|
+
classification: StartupFailureClassification;
|
|
14
|
+
stderrPreview?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function detectTrustPrompt(text: string): boolean {
|
|
18
|
+
const lowered = text.toLowerCase();
|
|
19
|
+
return lowered.includes("do you trust") || lowered.includes("trust this") || lowered.includes("untrusted") || lowered.includes("workspace trust") || lowered.includes("allow this folder");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function classifyStartupFailure(evidence: Omit<WorkerStartupEvidence, "classification">): StartupFailureClassification {
|
|
23
|
+
if (!evidence.transportHealthy) return "transport_dead";
|
|
24
|
+
if (evidence.trustPromptDetected || evidence.lastLifecycleState === "trust_required") return "trust_required";
|
|
25
|
+
if (evidence.promptSentAt && !evidence.promptAccepted && evidence.childProcessAlive) return "prompt_acceptance_timeout";
|
|
26
|
+
if (evidence.promptSentAt && !evidence.promptAccepted && !evidence.childProcessAlive) return "worker_crashed";
|
|
27
|
+
if (evidence.stderrPreview?.toLowerCase().includes("command not found") || evidence.stderrPreview?.toLowerCase().includes("not recognized")) return "prompt_misdelivery";
|
|
28
|
+
if (!evidence.childProcessAlive && evidence.lastLifecycleState !== "finished") return "worker_crashed";
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createStartupEvidence(input: {
|
|
33
|
+
command: string;
|
|
34
|
+
startedAt: Date;
|
|
35
|
+
finishedAt?: Date;
|
|
36
|
+
promptSentAt?: Date;
|
|
37
|
+
promptAccepted?: boolean;
|
|
38
|
+
stderr?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
exitCode?: number | null;
|
|
41
|
+
}): WorkerStartupEvidence {
|
|
42
|
+
const stderrPreview = (input.error || input.stderr || "").slice(0, 500) || undefined;
|
|
43
|
+
const trustPromptDetected = detectTrustPrompt(stderrPreview ?? "");
|
|
44
|
+
const childProcessAlive = input.exitCode === undefined || input.exitCode === null ? !input.finishedAt : false;
|
|
45
|
+
const base: Omit<WorkerStartupEvidence, "classification"> = {
|
|
46
|
+
lastLifecycleState: input.error || (input.exitCode !== undefined && input.exitCode !== null && input.exitCode !== 0) ? "failed" : input.finishedAt ? "finished" : "running",
|
|
47
|
+
command: input.command,
|
|
48
|
+
promptSentAt: input.promptSentAt?.toISOString(),
|
|
49
|
+
promptAccepted: input.promptAccepted ?? !input.error,
|
|
50
|
+
trustPromptDetected,
|
|
51
|
+
transportHealthy: !input.error || !/enoent|spawn|transport/i.test(input.error),
|
|
52
|
+
childProcessAlive,
|
|
53
|
+
elapsedMs: Math.max(0, (input.finishedAt ?? new Date()).getTime() - input.startedAt.getTime()),
|
|
54
|
+
stderrPreview,
|
|
55
|
+
};
|
|
56
|
+
return { ...base, classification: classifyStartupFailure(base) };
|
|
57
|
+
}
|
package/src/state/contracts.ts
CHANGED
|
@@ -42,10 +42,15 @@ export const TEAM_EVENT_TYPES = [
|
|
|
42
42
|
"task.red",
|
|
43
43
|
"task.completed",
|
|
44
44
|
"task.failed",
|
|
45
|
+
"task.cancelled",
|
|
46
|
+
"task.skipped",
|
|
45
47
|
"review.approved",
|
|
46
48
|
"review.rejected",
|
|
47
49
|
"policy.action",
|
|
48
50
|
"policy.escalated",
|
|
51
|
+
"recovery.attempted",
|
|
52
|
+
"recovery.escalated",
|
|
53
|
+
"branch.stale",
|
|
49
54
|
"mailbox.timeout",
|
|
50
55
|
"worktree.cleanup",
|
|
51
56
|
"worktree.dirty",
|
|
@@ -64,6 +69,8 @@ export const TEAM_WAKEABLE_EVENT_TYPES: ReadonlySet<TeamEventType> = new Set([
|
|
|
64
69
|
"run.cancelled",
|
|
65
70
|
"task.completed",
|
|
66
71
|
"task.failed",
|
|
72
|
+
"task.cancelled",
|
|
73
|
+
"task.skipped",
|
|
67
74
|
"async.completed",
|
|
68
75
|
"async.failed",
|
|
69
76
|
"async.stale",
|
package/src/state/event-log.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
|
|
5
|
+
export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
|
|
6
|
+
export type TeamWatcherAction = "act" | "observe" | "ignore";
|
|
7
|
+
|
|
8
|
+
export interface TeamEventSessionIdentity {
|
|
9
|
+
title: string;
|
|
10
|
+
workspace: string;
|
|
11
|
+
purpose: string;
|
|
12
|
+
placeholderReason?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TeamEventOwnership {
|
|
16
|
+
owner: string;
|
|
17
|
+
workflowScope: string;
|
|
18
|
+
watcherAction: TeamWatcherAction;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TeamEventMetadata {
|
|
22
|
+
seq: number;
|
|
23
|
+
provenance: TeamEventProvenance;
|
|
24
|
+
sessionIdentity?: TeamEventSessionIdentity;
|
|
25
|
+
ownership?: TeamEventOwnership;
|
|
26
|
+
nudgeId?: string;
|
|
27
|
+
fingerprint?: string;
|
|
28
|
+
confidence?: "low" | "medium" | "high";
|
|
29
|
+
}
|
|
30
|
+
|
|
4
31
|
export interface TeamEvent {
|
|
5
32
|
time: string;
|
|
6
33
|
type: string;
|
|
@@ -8,11 +35,52 @@ export interface TeamEvent {
|
|
|
8
35
|
taskId?: string;
|
|
9
36
|
message?: string;
|
|
10
37
|
data?: Record<string, unknown>;
|
|
38
|
+
metadata?: TeamEventMetadata;
|
|
11
39
|
}
|
|
12
40
|
|
|
13
|
-
export
|
|
41
|
+
export type AppendTeamEvent = Omit<TeamEvent, "time" | "metadata"> & { metadata?: Partial<TeamEventMetadata> };
|
|
42
|
+
|
|
43
|
+
const TERMINAL_EVENT_TYPES = new Set(["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.cancelled", "task.skipped"]);
|
|
44
|
+
|
|
45
|
+
function nextSequence(eventsPath: string): number {
|
|
46
|
+
if (!fs.existsSync(eventsPath)) return 1;
|
|
47
|
+
let max = 0;
|
|
48
|
+
for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) {
|
|
49
|
+
if (!line.trim()) continue;
|
|
50
|
+
try {
|
|
51
|
+
const event = JSON.parse(line) as TeamEvent;
|
|
52
|
+
max = Math.max(max, event.metadata?.seq ?? 0);
|
|
53
|
+
} catch {
|
|
54
|
+
max += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return max + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId" | "taskId" | "data">): string {
|
|
61
|
+
return createHash("sha256").update(JSON.stringify({ type: event.type, runId: event.runId, taskId: event.taskId, data: event.data ?? null })).digest("hex").slice(0, 16);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
14
65
|
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
15
|
-
const
|
|
66
|
+
const baseMetadata = event.metadata;
|
|
67
|
+
let metadata: TeamEventMetadata = {
|
|
68
|
+
seq: baseMetadata?.seq ?? nextSequence(eventsPath),
|
|
69
|
+
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
70
|
+
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
71
|
+
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
72
|
+
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
73
|
+
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
|
|
74
|
+
};
|
|
75
|
+
const fullEvent: TeamEvent = {
|
|
76
|
+
time: new Date().toISOString(),
|
|
77
|
+
...event,
|
|
78
|
+
metadata,
|
|
79
|
+
};
|
|
80
|
+
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
|
|
81
|
+
metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
|
|
82
|
+
fullEvent.metadata = metadata;
|
|
83
|
+
}
|
|
16
84
|
fs.appendFileSync(eventsPath, `${JSON.stringify(fullEvent)}\n`, "utf-8");
|
|
17
85
|
return fullEvent;
|
|
18
86
|
}
|
|
@@ -25,3 +93,17 @@ export function readEvents(eventsPath: string): TeamEvent[] {
|
|
|
25
93
|
.filter(Boolean)
|
|
26
94
|
.map((line) => JSON.parse(line) as TeamEvent);
|
|
27
95
|
}
|
|
96
|
+
|
|
97
|
+
export function dedupeTerminalEvents(events: TeamEvent[]): TeamEvent[] {
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const output: TeamEvent[] = [];
|
|
100
|
+
for (const event of events) {
|
|
101
|
+
const fingerprint = event.metadata?.fingerprint;
|
|
102
|
+
if (fingerprint && TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
103
|
+
if (seen.has(fingerprint)) continue;
|
|
104
|
+
seen.add(fingerprint);
|
|
105
|
+
}
|
|
106
|
+
output.push(event);
|
|
107
|
+
}
|
|
108
|
+
return output;
|
|
109
|
+
}
|
package/src/state/state-store.ts
CHANGED
|
@@ -97,7 +97,18 @@ export function createRunManifest(params: {
|
|
|
97
97
|
fs.mkdirSync(paths.artifactsRoot, { recursive: true });
|
|
98
98
|
atomicWriteJson(paths.manifestPath, manifest);
|
|
99
99
|
atomicWriteJson(paths.tasksPath, tasks);
|
|
100
|
-
appendEvent(paths.eventsPath, {
|
|
100
|
+
appendEvent(paths.eventsPath, {
|
|
101
|
+
type: "run.created",
|
|
102
|
+
runId: paths.runId,
|
|
103
|
+
data: { team: params.team.name, workflow: params.workflow?.name },
|
|
104
|
+
metadata: {
|
|
105
|
+
seq: 1,
|
|
106
|
+
provenance: "team_runner",
|
|
107
|
+
sessionIdentity: { title: params.team.name, workspace: params.cwd, purpose: params.goal },
|
|
108
|
+
ownership: { owner: params.team.name, workflowScope: params.workflow?.name ?? "manual", watcherAction: "act" },
|
|
109
|
+
confidence: "high",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
101
112
|
return { manifest, tasks, paths };
|
|
102
113
|
}
|
|
103
114
|
|
|
@@ -115,7 +126,17 @@ export function updateRunStatus(manifest: TeamRunManifest, status: TeamRunManife
|
|
|
115
126
|
}
|
|
116
127
|
const updated: TeamRunManifest = { ...manifest, status, updatedAt: new Date().toISOString(), summary: summary ?? manifest.summary };
|
|
117
128
|
saveRunManifest(updated);
|
|
118
|
-
appendEvent(updated.eventsPath, {
|
|
129
|
+
appendEvent(updated.eventsPath, {
|
|
130
|
+
type: `run.${status}`,
|
|
131
|
+
runId: updated.runId,
|
|
132
|
+
message: summary,
|
|
133
|
+
metadata: {
|
|
134
|
+
provenance: "team_runner",
|
|
135
|
+
sessionIdentity: { title: updated.team, workspace: updated.cwd, purpose: updated.goal },
|
|
136
|
+
ownership: { owner: updated.team, workflowScope: updated.workflow ?? "manual", watcherAction: "act" },
|
|
137
|
+
confidence: "high",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
119
140
|
return updated;
|
|
120
141
|
}
|
|
121
142
|
|
package/src/state/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
|
|
2
2
|
import type { TaskClaimState } from "./task-claims.ts";
|
|
3
3
|
import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
|
|
4
|
+
import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
|
|
4
5
|
export type { TeamRunStatus, TeamTaskStatus } from "./contracts.ts";
|
|
5
6
|
|
|
6
7
|
export interface ArtifactDescriptor {
|
|
@@ -137,12 +138,14 @@ export interface TeamTaskState {
|
|
|
137
138
|
promptArtifact?: ArtifactDescriptor;
|
|
138
139
|
resultArtifact?: ArtifactDescriptor;
|
|
139
140
|
logArtifact?: ArtifactDescriptor;
|
|
141
|
+
transcriptArtifact?: ArtifactDescriptor;
|
|
140
142
|
startedAt?: string;
|
|
141
143
|
finishedAt?: string;
|
|
142
144
|
exitCode?: number | null;
|
|
143
145
|
modelAttempts?: ModelAttemptState[];
|
|
144
146
|
usage?: UsageState;
|
|
145
147
|
jsonEvents?: number;
|
|
148
|
+
agentProgress?: CrewAgentProgress;
|
|
146
149
|
error?: string;
|
|
147
150
|
claim?: TaskClaimState;
|
|
148
151
|
heartbeat?: WorkerHeartbeatState;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type BranchFreshnessStatus = "fresh" | "stale" | "diverged" | "unknown";
|
|
4
|
+
export type StaleBranchPolicy = "warn" | "block" | "auto_rebase" | "auto_merge_forward";
|
|
5
|
+
|
|
6
|
+
export interface BranchFreshness {
|
|
7
|
+
status: BranchFreshnessStatus;
|
|
8
|
+
branch?: string;
|
|
9
|
+
mainRef: string;
|
|
10
|
+
ahead: number;
|
|
11
|
+
behind: number;
|
|
12
|
+
missingFixes: string[];
|
|
13
|
+
message: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function git(cwd: string, args: string[]): string {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function count(cwd: string, range: string): number {
|
|
22
|
+
const raw = git(cwd, ["rev-list", "--count", range]);
|
|
23
|
+
const parsed = Number.parseInt(raw, 10);
|
|
24
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function checkBranchFreshness(cwd: string, mainRef = "main"): BranchFreshness {
|
|
28
|
+
try {
|
|
29
|
+
git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
30
|
+
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
31
|
+
const behind = count(cwd, `${branch}..${mainRef}`);
|
|
32
|
+
const ahead = count(cwd, `${mainRef}..${branch}`);
|
|
33
|
+
const missingFixes = behind > 0 ? git(cwd, ["log", "--format=%s", `${branch}..${mainRef}`]).split("\n").map((line) => line.trim()).filter(Boolean) : [];
|
|
34
|
+
if (behind === 0) return { status: "fresh", branch, mainRef, ahead, behind, missingFixes, message: `Branch '${branch}' is fresh against ${mainRef}.` };
|
|
35
|
+
if (ahead > 0) return { status: "diverged", branch, mainRef, ahead, behind, missingFixes, message: `Branch '${branch}' diverged from ${mainRef}: ahead=${ahead}, behind=${behind}.` };
|
|
36
|
+
return { status: "stale", branch, mainRef, ahead, behind, missingFixes, message: `Branch '${branch}' is ${behind} commit(s) behind ${mainRef}.` };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
return { status: "unknown", mainRef, ahead: 0, behind: 0, missingFixes: [], message: "Branch freshness could not be determined.", error: message };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function shouldBlockForBranchFreshness(freshness: BranchFreshness, policy: StaleBranchPolicy = "warn"): boolean {
|
|
44
|
+
return policy === "block" && (freshness.status === "stale" || freshness.status === "diverged");
|
|
45
|
+
}
|