pi-extended-teams 2.2.9 → 2.3.0
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 +182 -2
- package/extensions/agents/read-agent-report.ts +11 -2
- package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
- package/extensions/agents/read-agent.ts +387 -65
- package/extensions/agents/write-agent.ts +3 -0
- package/extensions/events/register-events.ts +22 -10
- package/extensions/index.ts +132 -38
- package/extensions/internal/pi-check-operations.ts +24 -0
- package/extensions/internal/session-files.ts +10 -2
- package/extensions/runtime/teammate-interrupt.ts +11 -5
- package/extensions/runtime/types.ts +4 -0
- package/extensions/team/lifecycle.ts +26 -0
- package/extensions/team/session-cost.ts +155 -0
- package/extensions/tools/agent-communication-tools.ts +24 -8
- package/extensions/tools/agent-status-tool.ts +50 -7
- package/extensions/tools/coordination-tools.ts +78 -8
- package/extensions/tools/task-runtime-tools.ts +23 -4
- package/extensions/tools/team-tools.ts +287 -40
- package/extensions/ui/activity-colors.ts +59 -0
- package/extensions/ui/agent-follow-view.ts +22 -12
- package/extensions/ui/agent-navigation.ts +35 -1
- package/extensions/ui/checkpoints-command.ts +28 -0
- package/extensions/ui/extensions-command.ts +5 -1
- package/extensions/ui/onboarding-command.ts +218 -0
- package/extensions/ui/status-widget.ts +22 -14
- package/package.json +1 -1
- package/skills/teams.md +16 -0
- package/src/orchestration/index.ts +19 -1
- package/src/orchestration/status-projection.ts +2 -3
- package/src/orchestration/types.ts +9 -1
- package/src/results/check-journal.ts +149 -0
- package/src/results/check-policy.ts +86 -0
- package/src/results/check-runner.ts +110 -0
- package/src/results/checkpoint-assignment.ts +60 -0
- package/src/results/checkpoint-policy.ts +20 -0
- package/src/results/checkpoint-report.ts +109 -0
- package/src/results/checkpoint-retention.ts +15 -0
- package/src/results/completion-group-delivery.ts +66 -0
- package/src/results/completion-group-recovery.ts +61 -0
- package/src/results/completion-group-wake.ts +62 -0
- package/src/results/completion-group.ts +290 -0
- package/src/results/continuation-recipient.ts +37 -0
- package/src/results/durable-json.ts +29 -0
- package/src/results/repair-policy.ts +24 -0
- package/src/results/report-result.ts +77 -0
- package/src/results/source-identity.ts +115 -0
- package/src/results/specialist-checkpoint.ts +227 -0
- package/src/results/verification-controller.ts +308 -0
- package/src/utils/messaging.ts +47 -10
- package/src/utils/models.ts +13 -0
- package/src/utils/paths.ts +4 -0
- package/src/utils/report-events.ts +95 -10
- package/src/utils/teams.ts +3 -1
- package/src/utils/write-queue.ts +3 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
5
|
+
import { Value } from "@sinclair/typebox/value";
|
|
6
|
+
import * as paths from "../utils/paths";
|
|
7
|
+
import { withLock } from "../utils/lock";
|
|
8
|
+
import { AssignedCheckSchema, CheckJournal, assignmentBinding, checkIdentity, type AssignedCheck, type CheckRecord } from "./check-journal";
|
|
9
|
+
import { normalizeCheckPolicy, readCheckEvidence, verifyAssignedChecks, type CheckDefinition, type CheckEvidence } from "./check-policy";
|
|
10
|
+
import { observeCheckSource, runAssignedCheck, type CheckRunnerOptions } from "./check-runner";
|
|
11
|
+
import { syncPathAndParents, writeJsonDurably } from "./durable-json";
|
|
12
|
+
import { normalizeReportedTaskDetails, type ReportResult } from "./report-result";
|
|
13
|
+
import { normalizeRepairPolicy, type RepairPolicy, type RepairResult, type RepairState } from "./repair-policy";
|
|
14
|
+
|
|
15
|
+
const text = Type.String({ minLength: 1 });
|
|
16
|
+
const stageStates = ["running", "not-needed", "requested", "repaired", "exhausted", "cancelled", "declined", "blocked"] as const;
|
|
17
|
+
const StageSchema = Type.Object({
|
|
18
|
+
submissionId: text, signature: text, token: text,
|
|
19
|
+
attempt: Type.Integer({ minimum: 1, maximum: 6 }),
|
|
20
|
+
state: Type.Union(stageStates.map(state => Type.Literal(state))),
|
|
21
|
+
checkIds: Type.Array(Type.String({ pattern: "^check:[a-f0-9]{64}$" })),
|
|
22
|
+
}, { additionalProperties: false });
|
|
23
|
+
const LedgerSchema = Type.Object({
|
|
24
|
+
version: Type.Literal(1), assignments: Type.Array(AssignedCheckSchema, { minItems: 1 }),
|
|
25
|
+
maxAttempts: Type.Integer({ minimum: 1, maximum: 5 }), cancelled: Type.Boolean(),
|
|
26
|
+
stages: Type.Array(StageSchema, { maxItems: 6 }),
|
|
27
|
+
}, { additionalProperties: false });
|
|
28
|
+
type Stage = Static<typeof StageSchema>;
|
|
29
|
+
type Ledger = Static<typeof LedgerSchema>;
|
|
30
|
+
|
|
31
|
+
export interface VerificationControllerOptions {
|
|
32
|
+
teamName: string;
|
|
33
|
+
result: ReportResult;
|
|
34
|
+
cwd: string;
|
|
35
|
+
checks?: CheckDefinition[];
|
|
36
|
+
repair?: RepairPolicy;
|
|
37
|
+
}
|
|
38
|
+
export interface RepairRequest { id: string; attempt: number; checks: CheckRecord[] }
|
|
39
|
+
export interface VerificationDecision { result: ReportResult; checks: CheckRecord[]; request?: RepairRequest }
|
|
40
|
+
|
|
41
|
+
function identity(value: unknown): string {
|
|
42
|
+
return `repair:${crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function repairJournalPath(teamName: string, reportId: string): string {
|
|
46
|
+
return path.join(paths.teamDir(teamName), "repairs", `${identity(reportId).slice(7)}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readLedger(file: string): Ledger | undefined {
|
|
50
|
+
let contents: string;
|
|
51
|
+
try { contents = fs.readFileSync(file, "utf8"); }
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (typeof error === "object" && error !== null && Reflect.get(error, "code") === "ENOENT") return undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
let value: unknown;
|
|
57
|
+
try { value = JSON.parse(contents); } catch { throw new Error("Corrupt repair ledger JSON."); }
|
|
58
|
+
return parseLedger(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseLedger(value: unknown): Ledger {
|
|
62
|
+
if (!Value.Check(LedgerSchema, value)) throw new Error("Corrupt or incompatible repair ledger.");
|
|
63
|
+
const names = value.assignments.map(assignment => assignment.name);
|
|
64
|
+
if (new Set(names).size !== names.length || value.assignments.some(assignment => assignment.attempt !== 1)
|
|
65
|
+
|| value.stages.length > value.maxAttempts + 1
|
|
66
|
+
|| new Set(value.stages.map(stage => stage.submissionId)).size !== value.stages.length) throw new Error("Corrupt repair assignment binding.");
|
|
67
|
+
for (const [index, stage] of value.stages.entries()) {
|
|
68
|
+
if (stage.attempt !== index + 1 || (index > 0 && value.stages[index - 1].state !== "requested")
|
|
69
|
+
|| stage.checkIds.length !== names.length || stage.checkIds.some((id, item) =>
|
|
70
|
+
!Array.from({ length: stage.attempt }, (_, attempt) => checkIdentity({ ...value.assignments[item], attempt: attempt + 1 })).includes(id))) {
|
|
71
|
+
throw new Error("Corrupt repair stage binding.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return structuredClone(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class VerificationController {
|
|
78
|
+
readonly journalPath: string;
|
|
79
|
+
private readonly context: VerificationControllerOptions;
|
|
80
|
+
private readonly checks: CheckDefinition[] | undefined;
|
|
81
|
+
private readonly repair: RepairPolicy | undefined;
|
|
82
|
+
private readonly assignments: AssignedCheck[];
|
|
83
|
+
private observedLedger = false;
|
|
84
|
+
|
|
85
|
+
constructor(options: VerificationControllerOptions) {
|
|
86
|
+
this.context = structuredClone(options);
|
|
87
|
+
this.checks = normalizeCheckPolicy(options.checks);
|
|
88
|
+
this.repair = normalizeRepairPolicy(options.repair);
|
|
89
|
+
if (this.repair && !this.checks?.length) throw new Error("Repair policy requires explicitly assigned checks.");
|
|
90
|
+
this.assignments = (this.checks ?? []).map(check => ({
|
|
91
|
+
...check, inputs: check.inputs ? [...new Set(check.inputs)].sort() : undefined,
|
|
92
|
+
taskId: options.result.taskId, runId: options.result.runId, reportId: options.result.reportId,
|
|
93
|
+
cwd: options.cwd, attempt: 1,
|
|
94
|
+
}));
|
|
95
|
+
this.journalPath = repairJournalPath(options.teamName, options.result.reportId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static async observe(teamName: string, result: ReportResult, signal?: AbortSignal): Promise<VerificationDecision> {
|
|
99
|
+
const uncertain = async (error: unknown): Promise<VerificationDecision> => {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
const evidence = await readCheckEvidence(teamName, result, signal).catch(() => ({ verification: result.verification, checks: [] }));
|
|
102
|
+
return { result: { ...structuredClone(result), verification: { ...evidence.verification, state: "pending", error: message },
|
|
103
|
+
repair: { controllerId: identity(result.reportId), journalPath: repairJournalPath(teamName, result.reportId), state: "pending", outcome: "blocked", error: message } }, checks: evidence.checks };
|
|
104
|
+
};
|
|
105
|
+
let ledger: Ledger | undefined;
|
|
106
|
+
try { ledger = readLedger(repairJournalPath(teamName, result.reportId)); }
|
|
107
|
+
catch (error) { return uncertain(error); }
|
|
108
|
+
if (!ledger && !result.repair) {
|
|
109
|
+
const evidence = await readCheckEvidence(teamName, result, signal);
|
|
110
|
+
return { result: { ...structuredClone(result), verification: evidence.verification }, checks: evidence.checks };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
if (!ledger) throw new Error(`Referenced repair ledger is unavailable.${result.verification.error ? ` ${result.verification.error}` : ""}`);
|
|
114
|
+
if (ledger.assignments.some(assignment => assignment.taskId !== result.taskId || assignment.runId !== result.runId || assignment.reportId !== result.reportId)) {
|
|
115
|
+
throw new Error("Repair ledger belongs to another task/run binding.");
|
|
116
|
+
}
|
|
117
|
+
const controller = new VerificationController({ teamName, result, cwd: ledger.assignments[0].cwd,
|
|
118
|
+
checks: ledger.assignments.map(({ name, command, timeoutSeconds, inputs }) => ({ name, command, timeoutSeconds, inputs })),
|
|
119
|
+
repair: { maxAttempts: ledger.maxAttempts } });
|
|
120
|
+
const current = controller.read();
|
|
121
|
+
if (!current) throw new Error("Referenced repair ledger is unavailable.");
|
|
122
|
+
if (result.verification.error && ["running", "requested"].includes(current.stages.at(-1)?.state ?? "")) {
|
|
123
|
+
return uncertain(result.verification.error);
|
|
124
|
+
}
|
|
125
|
+
return await controller.decision(current, current.stages.at(-1), undefined, signal);
|
|
126
|
+
} catch (error) { return uncertain(error); }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pendingRepair(error: unknown): RepairResult | undefined {
|
|
130
|
+
if (!this.repair) return undefined;
|
|
131
|
+
return {
|
|
132
|
+
...this.context.result.repair,
|
|
133
|
+
controllerId: identity(this.context.result.reportId), journalPath: this.journalPath,
|
|
134
|
+
state: "pending", outcome: "blocked", maxAttempts: this.repair.maxAttempts,
|
|
135
|
+
error: error instanceof Error ? error.message : String(error),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private read(): Ledger | undefined {
|
|
140
|
+
const ledger = readLedger(this.journalPath);
|
|
141
|
+
if (!ledger) {
|
|
142
|
+
const result = this.context.result;
|
|
143
|
+
if (this.observedLedger || result.repair || (this.repair && (result.verification.checkIds?.length || result.verification.error))) {
|
|
144
|
+
throw new Error(`Referenced repair ledger is unavailable; inspect ${this.journalPath}.${result.verification.error ? ` ${result.verification.error}` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
this.observedLedger = true;
|
|
149
|
+
if (ledger.maxAttempts !== this.repair?.maxAttempts
|
|
150
|
+
|| JSON.stringify(ledger.assignments.map(assignmentBinding)) !== JSON.stringify(this.assignments.map(assignmentBinding))) {
|
|
151
|
+
throw new Error("Repair policy or assignment binding has changed.");
|
|
152
|
+
}
|
|
153
|
+
return ledger;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async update<T>(change: (ledger: Ledger) => { value: T; changed: boolean }): Promise<T> {
|
|
157
|
+
fs.mkdirSync(path.dirname(this.journalPath), { recursive: true, mode: 0o700 });
|
|
158
|
+
return withLock(this.journalPath, async () => {
|
|
159
|
+
let ledger = this.read();
|
|
160
|
+
if (!ledger) {
|
|
161
|
+
const journal = new CheckJournal(this.context.teamName);
|
|
162
|
+
for (const assignment of this.assignments) {
|
|
163
|
+
if (await journal.read(checkIdentity(assignment))) throw new Error(`Referenced repair ledger is unavailable; inspect ${this.journalPath}.`);
|
|
164
|
+
}
|
|
165
|
+
ledger = { version: 1, assignments: this.assignments, maxAttempts: this.repair!.maxAttempts, cancelled: false, stages: [] };
|
|
166
|
+
}
|
|
167
|
+
const changed = change(ledger);
|
|
168
|
+
this.observedLedger = true;
|
|
169
|
+
if (changed.changed) writeJsonDurably(this.journalPath, parseLedger(ledger));
|
|
170
|
+
else syncPathAndParents(this.journalPath);
|
|
171
|
+
return structuredClone(changed.value);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async cancel(): Promise<void> {
|
|
176
|
+
if (!this.repair) {
|
|
177
|
+
this.read();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await this.update(ledger => {
|
|
181
|
+
const last = ledger.stages.at(-1);
|
|
182
|
+
const terminal = last && !["running", "requested"].includes(last.state);
|
|
183
|
+
const changed = !ledger.cancelled && !terminal;
|
|
184
|
+
if (changed) ledger.cancelled = true;
|
|
185
|
+
return { value: undefined, changed };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async cancelAndRequireSettled(): Promise<void> {
|
|
190
|
+
const verificationError = this.context.result.verification.error;
|
|
191
|
+
await this.cancel();
|
|
192
|
+
const ledger = this.read();
|
|
193
|
+
const stage = ledger?.stages.at(-1);
|
|
194
|
+
if (stage?.state === "running" || (stage?.state === "requested" && verificationError)) {
|
|
195
|
+
throw new Error(`Repair verification remains unresolved; inspect ${this.journalPath}.${verificationError ? ` ${verificationError}` : ""}`);
|
|
196
|
+
}
|
|
197
|
+
const journal = new CheckJournal(this.context.teamName);
|
|
198
|
+
for (const [index, id] of (stage?.checkIds ?? []).entries()) {
|
|
199
|
+
const record = await journal.read(id);
|
|
200
|
+
if (record && assignmentBinding(record.assignment) !== assignmentBinding({ ...this.assignments[index], attempt: record.assignment.attempt })) {
|
|
201
|
+
throw new Error(`Repair evidence binding is invalid; inspect ${this.journalPath}.`);
|
|
202
|
+
}
|
|
203
|
+
if (record?.state === "claimed") throw new Error(`Repair check ownership remains unresolved; inspect ${this.journalPath}.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async evidence(checkIds: string[], signal?: AbortSignal): Promise<CheckEvidence> {
|
|
208
|
+
return readCheckEvidence(this.context.teamName, {
|
|
209
|
+
...this.context.result, verification: { state: "pending", checkIds },
|
|
210
|
+
}, signal);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private async decision(ledger: Ledger, stage: Stage | undefined, evidence?: CheckEvidence, signal?: AbortSignal): Promise<VerificationDecision> {
|
|
214
|
+
evidence ??= await this.evidence(stage?.checkIds ?? [], signal);
|
|
215
|
+
const state: RepairState = stage?.state === "running" || evidence.verification.state === "pending"
|
|
216
|
+
? "pending" : ledger.cancelled ? "cancelled" : stage?.state ?? "pending";
|
|
217
|
+
if (state === "pending") evidence.verification.state = "pending";
|
|
218
|
+
const requested = ledger.stages.filter(item => item.state === "requested");
|
|
219
|
+
const repair: RepairResult = {
|
|
220
|
+
controllerId: identity(this.context.result.reportId), journalPath: this.journalPath, state, maxAttempts: ledger.maxAttempts,
|
|
221
|
+
attemptsUsed: requested.length, requestIds: requested.map(item => identity([this.context.result.reportId, item.attempt])),
|
|
222
|
+
...(["pending", "exhausted", "cancelled", "declined", "blocked"].includes(state) ? { outcome: "blocked" as const } : {}),
|
|
223
|
+
};
|
|
224
|
+
return {
|
|
225
|
+
result: { ...structuredClone(this.context.result), verification: evidence.verification, repair }, checks: evidence.checks,
|
|
226
|
+
...(state === "requested" && stage ? { request: {
|
|
227
|
+
id: identity([this.context.result.reportId, stage.attempt]), attempt: stage.attempt,
|
|
228
|
+
checks: evidence.checks.filter(check => check.state !== "passed"),
|
|
229
|
+
} } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async runPlan(stage: Stage, previous: Stage | undefined, options: CheckRunnerOptions): Promise<CheckEvidence> {
|
|
234
|
+
const journal = new CheckJournal(this.context.teamName);
|
|
235
|
+
const prior = previous ? await Promise.all(previous.checkIds.map(id => journal.read(id))) : [];
|
|
236
|
+
for (const [index, record] of prior.entries()) {
|
|
237
|
+
if (!record || assignmentBinding(record.assignment) !== assignmentBinding({ ...this.assignments[index], attempt: record.assignment.attempt })) {
|
|
238
|
+
throw new Error("Prior repair evidence has an incompatible assignment binding.");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (prior.some(record => record?.state === "claimed")) return this.evidence(previous!.checkIds, options.signal);
|
|
242
|
+
const checkIds = [...stage.checkIds];
|
|
243
|
+
for (const [index, assignment] of this.assignments.entries()) {
|
|
244
|
+
if (options.signal?.aborted) break;
|
|
245
|
+
const observed = prior[index] && await observeCheckSource(prior[index]!, options.signal);
|
|
246
|
+
if (observed?.state === "passed") { checkIds[index] = observed.checkId; continue; }
|
|
247
|
+
const record = await runAssignedCheck(journal, { ...assignment, attempt: stage.attempt }, options);
|
|
248
|
+
checkIds[index] = record.checkId;
|
|
249
|
+
if (record.state === "claimed" || options.signal?.aborted) break;
|
|
250
|
+
}
|
|
251
|
+
return this.evidence(checkIds, options.signal);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async verify(submissionId: string, options: CheckRunnerOptions): Promise<VerificationDecision> {
|
|
255
|
+
if (!this.repair) {
|
|
256
|
+
this.read();
|
|
257
|
+
const evidence = await verifyAssignedChecks(this.context.teamName, this.context.result, this.context.cwd, this.checks, options);
|
|
258
|
+
const result = { ...structuredClone(this.context.result), verification: evidence.verification };
|
|
259
|
+
delete result.repair;
|
|
260
|
+
return { result, checks: evidence.checks };
|
|
261
|
+
}
|
|
262
|
+
if (typeof submissionId !== "string" || !submissionId.trim()) throw new Error("Repair verification requires a runtime submission identity.");
|
|
263
|
+
if (options.signal?.aborted) await this.cancel();
|
|
264
|
+
const signature = identity(normalizeReportedTaskDetails(this.context.result));
|
|
265
|
+
const reservation = await this.update(ledger => {
|
|
266
|
+
const existing = ledger.stages.find(stage => stage.submissionId === submissionId);
|
|
267
|
+
if (existing && existing.signature !== signature) throw new Error("Repair submission identity binding has changed.");
|
|
268
|
+
const previous = ledger.stages.at(-1);
|
|
269
|
+
if (ledger.cancelled || existing || (previous && previous.state !== "requested")) {
|
|
270
|
+
return { value: { ledger, stage: previous ?? existing, previous: undefined as Stage | undefined, granted: false }, changed: false };
|
|
271
|
+
}
|
|
272
|
+
const attempt = (previous?.attempt ?? 0) + 1;
|
|
273
|
+
const declined = previous && ["blocked", "failed", "cancelled"].includes(this.context.result.outcome ?? "");
|
|
274
|
+
const stage: Stage = {
|
|
275
|
+
submissionId, signature, token: crypto.randomUUID(), attempt,
|
|
276
|
+
state: declined ? "declined" : "running",
|
|
277
|
+
checkIds: declined ? [...previous.checkIds] : this.assignments.map(assignment => checkIdentity({ ...assignment, attempt })),
|
|
278
|
+
};
|
|
279
|
+
ledger.stages.push(stage);
|
|
280
|
+
return { value: { ledger, stage, previous, granted: !declined }, changed: true };
|
|
281
|
+
});
|
|
282
|
+
if (!reservation.granted || !reservation.stage) {
|
|
283
|
+
const observed = await this.decision(reservation.ledger, reservation.stage, undefined, options.signal);
|
|
284
|
+
if (!options.signal?.aborted) return observed;
|
|
285
|
+
await this.cancel();
|
|
286
|
+
const cancelled = this.read();
|
|
287
|
+
if (!cancelled) throw new Error("Repair ledger disappeared during cancellation.");
|
|
288
|
+
return this.decision(cancelled, cancelled.stages.at(-1), undefined, options.signal);
|
|
289
|
+
}
|
|
290
|
+
const evidence = await this.runPlan(reservation.stage, reservation.previous, options);
|
|
291
|
+
const finished = await this.update(ledger => {
|
|
292
|
+
const stage = ledger.stages.find(item => item.token === reservation.stage!.token);
|
|
293
|
+
if (!stage || stage.state !== "running") throw new Error("Repair reservation changed before completion.");
|
|
294
|
+
stage.checkIds = evidence.verification.checkIds ?? [];
|
|
295
|
+
if (options.signal?.aborted || evidence.checks.some(check => check.state === "cancelled")) ledger.cancelled = true;
|
|
296
|
+
const repairable = evidence.checks.length === this.assignments.length
|
|
297
|
+
&& evidence.checks.every(check => ["passed", "failed", "stale", "timed-out"].includes(check.state));
|
|
298
|
+
if (evidence.verification.state === "pending") stage.state = "running";
|
|
299
|
+
else if (ledger.cancelled) stage.state = "cancelled";
|
|
300
|
+
else if (evidence.verification.state === "passed") stage.state = stage.attempt === 1 ? "not-needed" : "repaired";
|
|
301
|
+
else if (["blocked", "failed", "cancelled"].includes(this.context.result.outcome ?? "")) stage.state = "declined";
|
|
302
|
+
else if (!repairable) stage.state = "blocked";
|
|
303
|
+
else stage.state = stage.attempt <= ledger.maxAttempts ? "requested" : "exhausted";
|
|
304
|
+
return { value: { ledger, stage }, changed: true };
|
|
305
|
+
});
|
|
306
|
+
return this.decision(finished.ledger, finished.stage, evidence);
|
|
307
|
+
}
|
|
308
|
+
}
|
package/src/utils/messaging.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import { writeJsonAtomic } from "./atomic-json";
|
|
5
|
+
import { syncPathAndParents, writeJsonDurably } from "../results/durable-json";
|
|
5
6
|
import { InboxMessage, TeamConfig } from "./models";
|
|
6
7
|
import { withLock } from "./lock";
|
|
7
8
|
import { configPath, inboxPath } from "./paths";
|
|
@@ -53,10 +54,13 @@ async function appendRunningMessage(
|
|
|
53
54
|
const messages = readInboxRaw(p);
|
|
54
55
|
if (options.operationId) {
|
|
55
56
|
const existing = messages.find(item => messageOperationMatches(item, options.operationId!, options.workflowRunId));
|
|
56
|
-
if (existing)
|
|
57
|
+
if (existing) {
|
|
58
|
+
resyncGroupedInbox(p, messages);
|
|
59
|
+
return { message: existing, delivered: false };
|
|
60
|
+
}
|
|
57
61
|
}
|
|
58
62
|
messages.push(message);
|
|
59
|
-
|
|
63
|
+
writeInboxRaw(p, messages);
|
|
60
64
|
return { message, delivered: true };
|
|
61
65
|
});
|
|
62
66
|
};
|
|
@@ -132,6 +136,22 @@ function readInboxRaw(p: string): InboxMessage[] {
|
|
|
132
136
|
return Array.isArray(parsed) ? parsed : [];
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
const hasGroupDeliveryMarker = (messages: InboxMessage[]) => messages.some(message => !!message.metadata?.completionGroup);
|
|
140
|
+
function ensureInboxDurability(p: string, ...snapshots: InboxMessage[][]): boolean {
|
|
141
|
+
const marker = `${p}.durable`;
|
|
142
|
+
if (fs.existsSync(marker)) return true;
|
|
143
|
+
if (!snapshots.some(hasGroupDeliveryMarker)) return false;
|
|
144
|
+
writeJsonDurably(marker, { version: 1 });
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
function writeInboxRaw(p: string, messages: InboxMessage[], previous = messages): void {
|
|
148
|
+
if (ensureInboxDurability(p, messages, previous)) writeJsonDurably(p, messages);
|
|
149
|
+
else writeJsonAtomic(p, messages);
|
|
150
|
+
}
|
|
151
|
+
function resyncGroupedInbox(p: string, messages: InboxMessage[]): void {
|
|
152
|
+
if (ensureInboxDurability(p, messages)) syncPathAndParents(p);
|
|
153
|
+
}
|
|
154
|
+
|
|
135
155
|
export function messageOperationMatches(message: InboxMessage, operationId: string, workflowRunId?: string): boolean {
|
|
136
156
|
const messageOperationId = message.operationId || message.metadata?.operationId;
|
|
137
157
|
const messageWorkflowRunId = message.workflowRunId || message.metadata?.workflowRunId;
|
|
@@ -192,7 +212,7 @@ export async function appendMessage(teamName: string, agentName: string, message
|
|
|
192
212
|
await withLock(p, async () => {
|
|
193
213
|
const msgs = readInboxRaw(p);
|
|
194
214
|
msgs.push(message);
|
|
195
|
-
|
|
215
|
+
writeInboxRaw(p, msgs);
|
|
196
216
|
});
|
|
197
217
|
}
|
|
198
218
|
|
|
@@ -206,10 +226,13 @@ export async function appendMessageOnce(
|
|
|
206
226
|
return await withLock(p, async () => {
|
|
207
227
|
const msgs = readInboxRaw(p);
|
|
208
228
|
const existing = msgs.find((item) => messageOperationMatches(item, message.operationId, message.workflowRunId));
|
|
209
|
-
if (existing)
|
|
229
|
+
if (existing) {
|
|
230
|
+
resyncGroupedInbox(p, msgs);
|
|
231
|
+
return { message: existing, delivered: false };
|
|
232
|
+
}
|
|
210
233
|
|
|
211
234
|
msgs.push(message);
|
|
212
|
-
|
|
235
|
+
writeInboxRaw(p, msgs);
|
|
213
236
|
return { message, delivered: true };
|
|
214
237
|
});
|
|
215
238
|
}
|
|
@@ -240,14 +263,26 @@ export async function readInbox(
|
|
|
240
263
|
const allMsgs = readInboxRaw(p);
|
|
241
264
|
const result = selectInboxMessages(allMsgs, unreadOnly);
|
|
242
265
|
|
|
243
|
-
if (markAsRead
|
|
244
|
-
|
|
266
|
+
if (markAsRead) {
|
|
267
|
+
if (markMessagesRead(result)) writeInboxRaw(p, allMsgs);
|
|
268
|
+
else resyncGroupedInbox(p, allMsgs);
|
|
245
269
|
}
|
|
246
270
|
|
|
247
271
|
return cloneInboxMessages(result);
|
|
248
272
|
});
|
|
249
273
|
}
|
|
250
274
|
|
|
275
|
+
export async function markInboxMessagesRead(teamName: string, agentName: string, ids: string[]): Promise<void> {
|
|
276
|
+
const p = inboxPath(teamName, agentName);
|
|
277
|
+
if (!fs.existsSync(p)) throw new Error("Referenced inbox is unavailable.");
|
|
278
|
+
await withLock(p, async () => {
|
|
279
|
+
const messages = readInboxRaw(p);
|
|
280
|
+
const selected = messages.filter(message => message.id && ids.includes(message.id));
|
|
281
|
+
if (markMessagesRead(selected)) writeInboxRaw(p, messages);
|
|
282
|
+
else resyncGroupedInbox(p, messages);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
251
286
|
export async function readInboxTail(
|
|
252
287
|
teamName: string,
|
|
253
288
|
agentName: string,
|
|
@@ -262,8 +297,9 @@ export async function readInboxTail(
|
|
|
262
297
|
const allMsgs = readInboxRaw(p);
|
|
263
298
|
const result = selectInboxMessages(allMsgs, options.unreadOnly === true, normalizedLimit);
|
|
264
299
|
|
|
265
|
-
if (options.markAsRead === true
|
|
266
|
-
|
|
300
|
+
if (options.markAsRead === true) {
|
|
301
|
+
if (markMessagesRead(result)) writeInboxRaw(p, allMsgs);
|
|
302
|
+
else resyncGroupedInbox(p, allMsgs);
|
|
267
303
|
}
|
|
268
304
|
|
|
269
305
|
return cloneInboxMessages(result);
|
|
@@ -284,7 +320,8 @@ export async function removeInboxMessagesByOperationUnderLifecycleLock(
|
|
|
284
320
|
const messages = readInboxRaw(p);
|
|
285
321
|
const remaining = messages.filter((message) => !messageOperationMatches(message, operationId, workflowRunId));
|
|
286
322
|
const removed = messages.length - remaining.length;
|
|
287
|
-
if (removed > 0)
|
|
323
|
+
if (removed > 0) writeInboxRaw(p, remaining, messages);
|
|
324
|
+
else resyncGroupedInbox(p, messages);
|
|
288
325
|
return removed;
|
|
289
326
|
});
|
|
290
327
|
}
|
package/src/utils/models.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { THINKING_LEVEL_NAMES, type ThinkingLevelName } from "./thinking-levels";
|
|
2
|
+
import type { ReportResult } from "../results/report-result";
|
|
3
|
+
import type { CheckDefinition } from "../results/check-policy";
|
|
4
|
+
import type { RepairPolicy } from "../results/repair-policy";
|
|
5
|
+
import type { CompletionGroupBinding } from "../results/completion-group";
|
|
6
|
+
import type { CheckpointAssignment } from "../results/checkpoint-assignment";
|
|
7
|
+
import type { SpecialistCheckpoint } from "../results/specialist-checkpoint";
|
|
2
8
|
|
|
3
9
|
export const THINKING_LEVELS = THINKING_LEVEL_NAMES;
|
|
4
10
|
export type ThinkingLevel = ThinkingLevelName;
|
|
@@ -18,6 +24,10 @@ export interface Member {
|
|
|
18
24
|
color?: string;
|
|
19
25
|
thinking?: ThinkingLevel;
|
|
20
26
|
planModeRequired?: boolean;
|
|
27
|
+
assignedChecks?: CheckDefinition[];
|
|
28
|
+
repairPolicy?: RepairPolicy;
|
|
29
|
+
checkpointAssignment?: CheckpointAssignment;
|
|
30
|
+
completionGroup?: CompletionGroupBinding;
|
|
21
31
|
backendType?: string;
|
|
22
32
|
isActive?: boolean;
|
|
23
33
|
/** Optional programmatic orchestration/idempotency metadata. */
|
|
@@ -96,6 +106,9 @@ export interface TeamReportEvent {
|
|
|
96
106
|
agentName: string;
|
|
97
107
|
role?: string;
|
|
98
108
|
status: "completed" | "failed";
|
|
109
|
+
checkpoint?: { id: string; draft?: SpecialistCheckpoint };
|
|
110
|
+
completionGroup?: CompletionGroupBinding;
|
|
111
|
+
result?: ReportResult;
|
|
99
112
|
report: string;
|
|
100
113
|
summary?: string;
|
|
101
114
|
reportPath?: string;
|
package/src/utils/paths.ts
CHANGED
|
@@ -76,6 +76,10 @@ export function reportFilesDir() {
|
|
|
76
76
|
return path.join(PI_DIR, "agent", "reports");
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export function checkpointFilesDir() {
|
|
80
|
+
return path.join(PI_DIR, "agent", "checkpoints");
|
|
81
|
+
}
|
|
82
|
+
|
|
79
83
|
export function leadSessionPath(teamName: string) {
|
|
80
84
|
return path.join(teamDir(teamName), "lead-session.json");
|
|
81
85
|
}
|
|
@@ -4,6 +4,13 @@ import crypto from "node:crypto";
|
|
|
4
4
|
import { TeamReportEvent } from "./models";
|
|
5
5
|
import * as paths from "./paths";
|
|
6
6
|
import { withLock } from "./lock";
|
|
7
|
+
import { writeJsonAtomic } from "./atomic-json";
|
|
8
|
+
import { syncPathAndParents, writeJsonDurably } from "../results/durable-json";
|
|
9
|
+
import { VerificationController } from "../results/verification-controller";
|
|
10
|
+
import type { CheckRecord } from "../results/check-journal";
|
|
11
|
+
import { isSpecialistCheckpoint, type SpecialistCheckpoint } from "../results/specialist-checkpoint";
|
|
12
|
+
|
|
13
|
+
export type ObservedTeamReportEvent = TeamReportEvent & { checks?: CheckRecord[] };
|
|
7
14
|
|
|
8
15
|
export type NewTeamReportEvent = Omit<TeamReportEvent, "id" | "teamName" | "createdAt"> & Partial<Pick<TeamReportEvent, "id" | "teamName" | "createdAt">>;
|
|
9
16
|
|
|
@@ -67,9 +74,7 @@ function compareCreatedAt(a: TeamReportEvent, b: TeamReportEvent): number {
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
function cloneTeamReportEvent(event: TeamReportEvent): TeamReportEvent {
|
|
70
|
-
|
|
71
|
-
if (event.metadata !== undefined) cloned.metadata = structuredClone(event.metadata);
|
|
72
|
-
return cloned;
|
|
77
|
+
return structuredClone(event);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
function cloneTeamReportEvents(events: readonly TeamReportEvent[]): TeamReportEvent[] {
|
|
@@ -115,7 +120,8 @@ function readEventsCache(p: string): ReportEventsCache {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
function writeEventsRaw(p: string, events: TeamReportEvent[], options: { sorted?: boolean } = {}): void {
|
|
118
|
-
|
|
123
|
+
if (events.some(event => event.completionGroup || event.checkpoint)) writeJsonDurably(p, events);
|
|
124
|
+
else writeJsonAtomic(p, events);
|
|
119
125
|
buildCache(p, events, reportEventsStatKey(p), options);
|
|
120
126
|
}
|
|
121
127
|
|
|
@@ -196,27 +202,106 @@ export async function appendTeamReportEvent(teamName: string, event: NewTeamRepo
|
|
|
196
202
|
const cache = readEventsCache(p);
|
|
197
203
|
const normalized: TeamReportEvent = cloneTeamReportEvent({
|
|
198
204
|
...event,
|
|
199
|
-
id: event.id || defaultEventId(teamName, event),
|
|
205
|
+
id: event.id || event.result?.reportId || defaultEventId(teamName, event),
|
|
200
206
|
teamName,
|
|
201
207
|
createdAt: event.createdAt || Date.now(),
|
|
202
208
|
});
|
|
203
209
|
|
|
210
|
+
if (normalized.result && normalized.result.reportId !== normalized.id) {
|
|
211
|
+
throw new Error("Structured report identity does not match its event ID.");
|
|
212
|
+
}
|
|
213
|
+
if (normalized.checkpoint && normalized.result) normalized.result.checkpointId = normalized.checkpoint.id;
|
|
204
214
|
const existing = cache.byId.get(normalized.id);
|
|
205
|
-
if (existing) return cloneTeamReportEvent(existing);
|
|
215
|
+
if (existing) return existing.completionGroup || existing.checkpoint ? resyncEvent(p, existing) : cloneTeamReportEvent(existing);
|
|
206
216
|
|
|
207
217
|
normalized.reportPath = writeStandaloneReport(teamName, normalized.agentName, normalized.report);
|
|
218
|
+
if (normalized.completionGroup || normalized.checkpoint) syncPathAndParents(normalized.reportPath);
|
|
208
219
|
writeEventsRaw(p, insertEvent(cache.events, normalized), { sorted: true });
|
|
209
220
|
return cloneTeamReportEvent(normalized);
|
|
210
221
|
});
|
|
211
222
|
}
|
|
212
223
|
|
|
213
|
-
|
|
224
|
+
function resyncEvent(p: string, event: TeamReportEvent | undefined): TeamReportEvent {
|
|
225
|
+
if (!event?.reportPath) throw new Error(`Full report is unavailable in ${p}.`);
|
|
226
|
+
syncPathAndParents(event.reportPath);
|
|
227
|
+
syncPathAndParents(p);
|
|
228
|
+
return cloneTeamReportEvent(event);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function resyncStoredTeamReportEvent(teamName: string, reportId: string): Promise<TeamReportEvent> {
|
|
232
|
+
const p = ensureReportEventsFile(teamName);
|
|
233
|
+
return withLock(p, async () => resyncEvent(p, readEventsCache(p).byId.get(reportId)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function recordReportCheckpoint(teamName: string, reportId: string, draft: SpecialistCheckpoint): Promise<TeamReportEvent> {
|
|
237
|
+
if (!isSpecialistCheckpoint(draft)) throw new Error("Invalid specialist checkpoint.");
|
|
238
|
+
const p = ensureReportEventsFile(teamName);
|
|
239
|
+
return withLock(p, async () => {
|
|
240
|
+
const cache = readEventsCache(p);
|
|
241
|
+
const existing = cache.byId.get(reportId);
|
|
242
|
+
if (!existing?.checkpoint || existing.checkpoint.id !== draft.id || draft.reportId !== reportId
|
|
243
|
+
|| draft.author.teamName !== teamName || draft.author.agentName !== existing.agentName || draft.author.runId !== existing.result?.runId
|
|
244
|
+
|| draft.reports.find(report => report.id === reportId)?.path !== existing.reportPath) throw new Error("Checkpoint report binding changed.");
|
|
245
|
+
resyncEvent(p, existing);
|
|
246
|
+
if (existing.checkpoint.draft) return cloneTeamReportEvent(existing);
|
|
247
|
+
const updated = cloneTeamReportEvent({ ...existing, checkpoint: { id: draft.id, draft } });
|
|
248
|
+
writeEventsRaw(p, cache.events.map(event => event.id === reportId ? updated : event), { sorted: true });
|
|
249
|
+
return cloneTeamReportEvent(updated);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function recordReportAcceptance(
|
|
254
|
+
teamName: string,
|
|
255
|
+
reportId: string,
|
|
256
|
+
state: "accepted" | "rejected",
|
|
257
|
+
reason?: string,
|
|
258
|
+
): Promise<TeamReportEvent> {
|
|
259
|
+
if (state !== "accepted" && state !== "rejected") throw new Error("Report acceptance must be accepted or rejected.");
|
|
260
|
+
const p = ensureReportEventsFile(teamName);
|
|
261
|
+
return withLock(p, async () => {
|
|
262
|
+
const cache = readEventsCache(p);
|
|
263
|
+
const existing = cache.byId.get(reportId);
|
|
264
|
+
if (!existing?.result || existing.result.version !== 1) throw new Error(`Structured report ${reportId} is unavailable.`);
|
|
265
|
+
const updated = cloneTeamReportEvent({
|
|
266
|
+
...existing,
|
|
267
|
+
result: { ...existing.result, acceptance: { state, reason, decidedAt: Date.now() } },
|
|
268
|
+
});
|
|
269
|
+
writeEventsRaw(p, cache.events.map(event => event.id === reportId ? updated : event), { sorted: true });
|
|
270
|
+
return cloneTeamReportEvent(updated);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function readStoredTeamReportEvent(teamName: string, reportId: string): Promise<TeamReportEvent | undefined> {
|
|
275
|
+
const p = ensureReportEventsFile(teamName);
|
|
276
|
+
return withLock(p, async () => {
|
|
277
|
+
const event = readEventsCache(p).byId.get(reportId);
|
|
278
|
+
return event ? cloneTeamReportEvent(event) : undefined;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function listStoredTeamReportEvents(
|
|
214
283
|
teamName: string,
|
|
215
284
|
options: ListTeamReportEventsOptions = {}
|
|
216
285
|
): Promise<TeamReportEvent[]> {
|
|
217
286
|
const p = ensureReportEventsFile(teamName);
|
|
287
|
+
return withLock(p, async () => selectEvents(readEventsCache(p), options));
|
|
288
|
+
}
|
|
218
289
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
290
|
+
export async function listTeamReportEvents(
|
|
291
|
+
teamName: string,
|
|
292
|
+
options: ListTeamReportEventsOptions = {}
|
|
293
|
+
): Promise<ObservedTeamReportEvent[]> {
|
|
294
|
+
const events = await listStoredTeamReportEvents(teamName, options);
|
|
295
|
+
return Promise.all(events.map(async event => {
|
|
296
|
+
if (!event.result) return event;
|
|
297
|
+
try {
|
|
298
|
+
const observed = await VerificationController.observe(teamName, event.result);
|
|
299
|
+
return { ...event, result: observed.result,
|
|
300
|
+
...(observed.result.verification.state !== "not-requested" ? { checks: observed.checks } : {}) };
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return { ...event, result: { ...event.result, verification: {
|
|
303
|
+
state: "failed" as const, error: error instanceof Error ? error.message : String(error),
|
|
304
|
+
} }, checks: [] };
|
|
305
|
+
}
|
|
306
|
+
}));
|
|
222
307
|
}
|
package/src/utils/teams.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { TeamConfig, Member } from "./models";
|
|
4
4
|
import { configPath, teamDir, taskDir } from "./paths";
|
|
5
5
|
import { withLock } from "./lock";
|
|
6
|
+
import { assertUnusedContinuationRecipient } from "../results/continuation-recipient";
|
|
6
7
|
import {
|
|
7
8
|
assertLifecycleTombstoneAbsent,
|
|
8
9
|
generateLifecycleRunId,
|
|
@@ -157,9 +158,10 @@ export async function addMember(teamName: string, member: Member) {
|
|
|
157
158
|
member.lifecycleRunId = generateLifecycleRunId();
|
|
158
159
|
await withLifecycleTombstoneLock(teamName, member.name, async lifecycleLock => {
|
|
159
160
|
assertLifecycleTombstoneAbsent(teamName, member.name, lifecycleLock.read());
|
|
161
|
+
if (member.checkpointAssignment?.parent) assertUnusedContinuationRecipient(teamName, member.name);
|
|
160
162
|
await withLock(p, async () => {
|
|
161
163
|
const config = readConfigRaw(p);
|
|
162
|
-
if (config.members.some(existing => existing.name === member.name)) {
|
|
164
|
+
if (config.members.some(existing => existing.name === member.name || (member.checkpointAssignment?.parent && existing.name.toLowerCase() === member.name.toLowerCase()))) {
|
|
163
165
|
throw new Error(`Teammate ${member.name} already exists in team ${teamName}.`);
|
|
164
166
|
}
|
|
165
167
|
config.members.push(member);
|
package/src/utils/write-queue.ts
CHANGED
|
@@ -87,6 +87,9 @@ export async function enqueueWriteSpawn(
|
|
|
87
87
|
teamName: string,
|
|
88
88
|
request: Omit<QueuedWriteSpawn, "id" | "requestedAt"> & Partial<Pick<QueuedWriteSpawn, "id" | "requestedAt">>
|
|
89
89
|
): Promise<QueuedWriteSpawn> {
|
|
90
|
+
if (["checkpoint", "checkpointAssignment", "continueFrom", "continue_from"].some(field => Reflect.get(request, field) !== undefined)) {
|
|
91
|
+
throw new Error("Checkpoint-enabled agents require in-process lead admission; the legacy terminal queue is unsupported.");
|
|
92
|
+
}
|
|
90
93
|
const queuePath = ensureQueueDir(teamName);
|
|
91
94
|
return await withLock(queuePath, async () => {
|
|
92
95
|
const queue = readQueueRaw(queuePath);
|