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.
Files changed (54) hide show
  1. package/README.md +182 -2
  2. package/extensions/agents/read-agent-report.ts +11 -2
  3. package/extensions/agents/read-agent-session-lifecycle.ts +25 -10
  4. package/extensions/agents/read-agent.ts +387 -65
  5. package/extensions/agents/write-agent.ts +3 -0
  6. package/extensions/events/register-events.ts +22 -10
  7. package/extensions/index.ts +132 -38
  8. package/extensions/internal/pi-check-operations.ts +24 -0
  9. package/extensions/internal/session-files.ts +10 -2
  10. package/extensions/runtime/teammate-interrupt.ts +11 -5
  11. package/extensions/runtime/types.ts +4 -0
  12. package/extensions/team/lifecycle.ts +26 -0
  13. package/extensions/team/session-cost.ts +155 -0
  14. package/extensions/tools/agent-communication-tools.ts +24 -8
  15. package/extensions/tools/agent-status-tool.ts +50 -7
  16. package/extensions/tools/coordination-tools.ts +78 -8
  17. package/extensions/tools/task-runtime-tools.ts +23 -4
  18. package/extensions/tools/team-tools.ts +287 -40
  19. package/extensions/ui/activity-colors.ts +59 -0
  20. package/extensions/ui/agent-follow-view.ts +22 -12
  21. package/extensions/ui/agent-navigation.ts +35 -1
  22. package/extensions/ui/checkpoints-command.ts +28 -0
  23. package/extensions/ui/extensions-command.ts +5 -1
  24. package/extensions/ui/onboarding-command.ts +218 -0
  25. package/extensions/ui/status-widget.ts +22 -14
  26. package/package.json +1 -1
  27. package/skills/teams.md +16 -0
  28. package/src/orchestration/index.ts +19 -1
  29. package/src/orchestration/status-projection.ts +2 -3
  30. package/src/orchestration/types.ts +9 -1
  31. package/src/results/check-journal.ts +149 -0
  32. package/src/results/check-policy.ts +86 -0
  33. package/src/results/check-runner.ts +110 -0
  34. package/src/results/checkpoint-assignment.ts +60 -0
  35. package/src/results/checkpoint-policy.ts +20 -0
  36. package/src/results/checkpoint-report.ts +109 -0
  37. package/src/results/checkpoint-retention.ts +15 -0
  38. package/src/results/completion-group-delivery.ts +66 -0
  39. package/src/results/completion-group-recovery.ts +61 -0
  40. package/src/results/completion-group-wake.ts +62 -0
  41. package/src/results/completion-group.ts +290 -0
  42. package/src/results/continuation-recipient.ts +37 -0
  43. package/src/results/durable-json.ts +29 -0
  44. package/src/results/repair-policy.ts +24 -0
  45. package/src/results/report-result.ts +77 -0
  46. package/src/results/source-identity.ts +115 -0
  47. package/src/results/specialist-checkpoint.ts +227 -0
  48. package/src/results/verification-controller.ts +308 -0
  49. package/src/utils/messaging.ts +47 -10
  50. package/src/utils/models.ts +13 -0
  51. package/src/utils/paths.ts +4 -0
  52. package/src/utils/report-events.ts +95 -10
  53. package/src/utils/teams.ts +3 -1
  54. package/src/utils/write-queue.ts +3 -0
@@ -0,0 +1,109 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import type { Member, TeamReportEvent } from "../utils/models";
3
+ import { normalizeFavoriteModelSlot } from "../utils/settings";
4
+ import { readStoredTeamReportEvent, recordReportCheckpoint, resyncStoredTeamReportEvent } from "../utils/report-events";
5
+ import { captureSourceIdentity, type SourceIdentity } from "./source-identity";
6
+ import { checkpointId, checkpointReportPayload, isSpecialistCheckpoint, MAX_CHECKPOINT_BYTES, MAX_CHECKPOINT_REPORT_PATH_LENGTH, saveCheckpoint, type SpecialistCheckpoint } from "./specialist-checkpoint";
7
+ import type { ReportResult } from "./report-result";
8
+
9
+ function author(teamName: string, member: Member) {
10
+ return { teamName, agentName: member.name, runId: member.lifecycleRunId!, modelSlot: normalizeFavoriteModelSlot(member.modelSlot)! };
11
+ }
12
+
13
+ export function checkpointReference(teamName: string, member: Member): TeamReportEvent["checkpoint"] {
14
+ return member.checkpointAssignment ? { id: checkpointId(author(teamName, member)) } : undefined;
15
+ }
16
+
17
+ function requireDraft(event: TeamReportEvent): SpecialistCheckpoint {
18
+ const draft = event.checkpoint?.draft;
19
+ if (!isSpecialistCheckpoint(draft) || draft.id !== event.checkpoint?.id || draft.reportId !== event.id
20
+ || draft.author.teamName !== event.teamName || draft.author.agentName !== event.agentName || draft.author.runId !== event.result?.runId
21
+ || draft.reports.find(report => report.id === event.id)?.path !== event.reportPath) throw new Error("Checkpoint report provenance is unavailable or changed.");
22
+ return draft;
23
+ }
24
+
25
+ function checkpointBytes(record: SpecialistCheckpoint): number {
26
+ return Buffer.byteLength(JSON.stringify(record, null, 2));
27
+ }
28
+
29
+ export function preflightReportCheckpoint(teamName: string, member: Member, result: ReportResult): void {
30
+ const assignment = member.checkpointAssignment;
31
+ if (!assignment) return;
32
+ if (!assignment.sourceBefore) throw new Error("Checkpoint assignment provenance is unavailable.");
33
+ // JSON may escape each path character as six bytes; the final versioned path is allocated later.
34
+ const reservedBytes = 6 * MAX_CHECKPOINT_REPORT_PATH_LENGTH;
35
+ const event: TeamReportEvent = {
36
+ id: result.reportId, teamName, agentName: member.name, status: "completed", source: "read-agent", report: "",
37
+ createdAt: Number.MAX_SAFE_INTEGER - assignment.policy.retentionDays * 86_400_000,
38
+ reportPath: "/", checkpoint: checkpointReference(teamName, member),
39
+ result: { ...result, verification: { ...result.verification, state: "not-requested" }, acceptance: { state: "accepted" } },
40
+ };
41
+ // Native capture uses SHA-1/SHA-256 and an array length; stable path/scope fields must match sourceBefore.
42
+ const after = { ...assignment.sourceBefore, head: "f".repeat(64), fileCount: Number.MAX_SAFE_INTEGER };
43
+ const record = snapshot(teamName, member, event, after, reservedBytes);
44
+ if (checkpointBytes(record) + reservedBytes > MAX_CHECKPOINT_BYTES) {
45
+ throw new Error(`Checkpoint exceeds ${MAX_CHECKPOINT_BYTES} bytes including assignment and retained provenance. Correct the report fields and resubmit report_and_exit.`);
46
+ }
47
+ }
48
+
49
+ function snapshot(teamName: string, member: Member, event: TeamReportEvent, after: SourceIdentity, reservedBytes = 0): SpecialistCheckpoint {
50
+ const { originalPrompt, policy, sourceBefore, parent } = member.checkpointAssignment!;
51
+ const result = event.result!;
52
+ if (!sourceBefore || (parent && !isSpecialistCheckpoint(parent))) throw new Error("Checkpoint assignment provenance is unavailable.");
53
+ const previous = (parent ? [parent.reports[0], ...parent.reports.slice(1).slice(-14)] : []).map(report => ({ ...report,
54
+ ...(report.id === parent?.reportId && !report.leadDecisions ? { leadDecisions: parent.policy.decisions } : {}) }));
55
+ const retained = new Set(previous.map(report => report.id));
56
+ const { findings, inspectedEvidence: inspected, questions } = checkpointReportPayload(result);
57
+ const updated = new Set(findings.map(finding => finding.id));
58
+ const record: SpecialistCheckpoint = {
59
+ version: 1, state: "ready", id: event.checkpoint!.id, author: author(teamName, member),
60
+ createdAt: event.createdAt, expiresAt: event.createdAt + policy.retentionDays * 86_400_000,
61
+ ...(parent ? { parentId: parent.id } : {}), assignment: { original: originalPrompt, current: member.prompt! }, policy,
62
+ reportId: event.id, reports: [...previous, { id: event.id, path: event.reportPath!, source: { before: sourceBefore, after },
63
+ verification: result.verification.state, acceptance: result.acceptance.state, leadDecisions: policy.decisions }],
64
+ findings: [...findings, ...(parent?.findings.filter(finding => retained.has(finding.reportId) && !updated.has(finding.id)) ?? []).slice(0, Math.max(0, 100 - findings.length))],
65
+ inspectedEvidence: [...inspected, ...(parent?.inspectedEvidence.filter(item => retained.has(item.reportId)) ?? []).slice(0, Math.max(0, 128 - inspected.length))],
66
+ questions: [...questions, ...(parent?.questions.filter(question => !questions.includes(question)) ?? []).slice(0, Math.max(0, 32 - questions.length))],
67
+ };
68
+ const oversized = () => checkpointBytes(record) + reservedBytes > MAX_CHECKPOINT_BYTES;
69
+ while (oversized() && record.reports.length > 2) {
70
+ const removed = record.reports.splice(1, 1)[0].id;
71
+ record.findings = record.findings.filter(finding => finding.reportId !== removed);
72
+ record.inspectedEvidence = record.inspectedEvidence.filter(item => item.reportId !== removed);
73
+ }
74
+ while (oversized() && record.questions.length > questions.length) record.questions.pop();
75
+ return structuredClone(record);
76
+ }
77
+
78
+ export async function saveReportCheckpoint(teamName: string, member: Member, event: TeamReportEvent, signal?: AbortSignal) {
79
+ const reference = checkpointReference(teamName, member);
80
+ if (!reference && !event.checkpoint) return undefined;
81
+ if (!reference || event.checkpoint?.id !== reference.id || event.teamName !== teamName || event.agentName !== member.name
82
+ || !event.result || event.result.runId !== member.lifecycleRunId || event.id !== event.result.reportId) throw new Error("Checkpoint report binding changed.");
83
+ let stored = await readStoredTeamReportEvent(teamName, event.id);
84
+ if (stored?.checkpoint?.id !== reference.id) throw new Error("Checkpoint report provenance is unavailable.");
85
+ if (!stored.checkpoint.draft) {
86
+ const assignment = structuredClone(member.checkpointAssignment!);
87
+ if (!assignment.sourceBefore) throw new Error("Checkpoint assignment provenance is unavailable.");
88
+ const captureSignal = AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]);
89
+ captureSignal.throwIfAborted();
90
+ const after = await captureSourceIdentity(member.cwd, assignment.policy.inputs, captureSignal);
91
+ captureSignal.throwIfAborted();
92
+ await resyncStoredTeamReportEvent(teamName, event.id);
93
+ stored = await recordReportCheckpoint(teamName, event.id, snapshot(teamName, { ...member, checkpointAssignment: assignment }, stored, after));
94
+ } else stored = await resyncStoredTeamReportEvent(teamName, event.id);
95
+ const draft = requireDraft(stored);
96
+ if (!isDeepStrictEqual(draft.author, author(teamName, member)) || !isDeepStrictEqual(draft.policy, member.checkpointAssignment!.policy)
97
+ || draft.assignment.original !== member.checkpointAssignment!.originalPrompt || draft.assignment.current !== member.prompt
98
+ || !isDeepStrictEqual(draft.reports.find(report => report.id === event.id)?.source.before, member.checkpointAssignment!.sourceBefore)) {
99
+ throw new Error("Checkpoint assignment binding changed.");
100
+ }
101
+ return saveCheckpoint(draft);
102
+ }
103
+
104
+ export async function resyncReportCheckpoint(event: TeamReportEvent) {
105
+ if (!event.checkpoint) return undefined;
106
+ const stored = await resyncStoredTeamReportEvent(event.teamName, event.id);
107
+ if (stored.checkpoint?.id !== event.checkpoint.id) throw new Error("Checkpoint report binding changed.");
108
+ return saveCheckpoint(requireDraft(stored));
109
+ }
@@ -0,0 +1,15 @@
1
+ import { listCheckpointRecords, retireCheckpoint } from "./specialist-checkpoint";
2
+
3
+ export async function expireCheckpoints(now = Date.now()): Promise<string[]> {
4
+ const { records, errors } = listCheckpointRecords();
5
+ const diagnostics = errors.map(error => error.message);
6
+ for (const record of records) {
7
+ if (record.state !== "ready" || record.expiresAt > now) continue;
8
+ try {
9
+ await retireCheckpoint(record.id, "expired", now);
10
+ } catch (error) {
11
+ diagnostics.push(`Checkpoint ${record.id}: ${String(error)}`);
12
+ }
13
+ }
14
+ return diagnostics;
15
+ }
@@ -0,0 +1,66 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import * as messaging from "../utils/messaging";
3
+ import { resyncStoredTeamReportEvent } from "../utils/report-events";
4
+ import type { TeamReportEvent } from "../utils/models";
5
+ import { effectiveTaskOutcome } from "./report-result";
6
+ import { CompletionGroup, type CompletionGroupState } from "./completion-group";
7
+
8
+ type GroupMember = CompletionGroupState["members"][number];
9
+ const compact = (text: string | undefined, limit = 400) => text && text.length > limit ? `${text.slice(0, limit)}…` : text;
10
+
11
+ function assertReportBinding(group: CompletionGroup, member: GroupMember, event: TeamReportEvent): void {
12
+ if (event.teamName !== group.teamName || event.agentName !== member.name || !event.result
13
+ || event.result.reportId !== event.id || event.result.runId !== member.runId
14
+ || !isDeepStrictEqual(event.completionGroup, { groupId: group.groupId, slotId: member.slotId })) {
15
+ throw new Error("Completion report provenance binding has changed.");
16
+ }
17
+ }
18
+
19
+ async function indexMember(group: CompletionGroup, member: GroupMember) {
20
+ const identity = { name: member.name, slotId: member.slotId, status: member.status, runId: member.runId };
21
+ if (!member.report) return { ...identity, reason: compact(member.reason) };
22
+ const event = await resyncStoredTeamReportEvent(group.teamName, member.report.reportId);
23
+ assertReportBinding(group, member, event);
24
+ const result = event.result!;
25
+ if (result.outcome !== member.report.outcome || effectiveTaskOutcome(result) !== member.report.effectiveOutcome
26
+ || result.verification.state !== member.report.verification || event.status !== member.report.runtimeStatus) {
27
+ throw new Error("Completion report evidence differs from its receipt.");
28
+ }
29
+ return { ...identity, taskId: result.taskId, runtimeStatus: event.status, role: event.role, summary: compact(event.summary),
30
+ outcome: result.outcome, effectiveOutcome: effectiveTaskOutcome(result), verification: result.verification,
31
+ acceptance: result.acceptance, findings: result.findings?.map(finding => ({ ...finding, text: compact(finding.text) })),
32
+ questions: result.questions, report: { id: event.id, path: event.reportPath } };
33
+ }
34
+
35
+ export async function enqueueCompletionGroupDeliveries(group: CompletionGroup): Promise<number> {
36
+ let enqueued = 0;
37
+ for (const delivery of await group.prepareDeliveries()) {
38
+ const state = group.read();
39
+ const members = await Promise.all(delivery.slotIds.map(slotId => {
40
+ const member = state.members.find(candidate => candidate.slotId === slotId);
41
+ if (!member || member.suppressed) throw new Error("Invalid completion index member.");
42
+ return indexMember(group, member);
43
+ }));
44
+ const index = { version: 1, groupId: group.groupId, deliveryId: delivery.id, kind: delivery.kind, members };
45
+ const receipt = await messaging.sendPlainMessageOnce(group.teamName, "system", "team-lead", JSON.stringify(index),
46
+ `Completion group ${delivery.kind}: ${members.length} result(s)`, { id: delivery.id, operationId: delivery.id,
47
+ metadata: { finalReport: true, completionGroup: { groupId: group.groupId, deliveryId: delivery.id, journalPath: group.journalPath } } });
48
+ await group.markEnqueued(delivery.id);
49
+ if (receipt.delivered) enqueued++;
50
+ }
51
+ return enqueued;
52
+ }
53
+
54
+ export async function deliverCompletionGroupReport(event: TeamReportEvent, onDurableReport?: () => void): Promise<boolean> {
55
+ const binding = event.completionGroup;
56
+ if (!binding) return false;
57
+ const stored = await resyncStoredTeamReportEvent(event.teamName, event.id);
58
+ const group = new CompletionGroup(event.teamName, binding.groupId);
59
+ const member = group.read().members.find(candidate => candidate.slotId === binding.slotId);
60
+ if (!member) throw new Error("Unknown completion report slot.");
61
+ assertReportBinding(group, member, stored);
62
+ onDurableReport?.();
63
+ await group.recordReport(binding.slotId, stored.result!, stored.status);
64
+ await enqueueCompletionGroupDeliveries(group);
65
+ return true;
66
+ }
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import * as paths from "../utils/paths";
4
+ import { peekInbox } from "../utils/messaging";
5
+ import { listStoredTeamReportEvents } from "../utils/report-events";
6
+ import type { Member } from "../utils/models";
7
+ import { CompletionGroup } from "./completion-group";
8
+ import { deliverCompletionGroupReport, enqueueCompletionGroupDeliveries } from "./completion-group-delivery";
9
+
10
+ type Reference = { session?: string; completionGroup?: { groupId?: string; teamName?: string } };
11
+ type HistoryReference = { type?: string; details?: Reference; message?: { role?: string; toolName?: string; details?: Reference } };
12
+ export async function recoverCompletionGroups(
13
+ teamName: string, sessionId: string, members: readonly Pick<Member, "name" | "lifecycleRunId" | "completionGroup">[],
14
+ entries: readonly HistoryReference[], currentGroupIds: ReadonlySet<string>,
15
+ ): Promise<string[]> {
16
+ const root = paths.teamDir(teamName);
17
+ const directory = path.join(root, "completion-groups");
18
+ const reports = fs.existsSync(path.join(root, "reports.json")) ? await listStoredTeamReportEvents(teamName) : [];
19
+ const inbox = await peekInbox(teamName, "team-lead");
20
+ const ids = new Set(fs.existsSync(directory) ? fs.readdirSync(directory)
21
+ .filter(file => /^[a-f0-9]{64}\.json$/.test(file)).map(file => `group:${file.slice(0, -5)}`) : []);
22
+ const references = [...members.map(member => member.completionGroup), ...reports.map(report => report.completionGroup),
23
+ ...inbox.map(message => message.metadata?.completionGroup), ...entries.flatMap(entry => [
24
+ entry.type === "custom_message" && entry.details?.completionGroup?.teamName === teamName ? entry.details.completionGroup : undefined,
25
+ entry.message?.role === "toolResult" && entry.message.toolName === "spawn_swarm_agents" && entry.message.details?.session === teamName
26
+ ? entry.message.details.completionGroup : undefined])];
27
+ for (const reference of references) if (reference?.groupId) ids.add(reference.groupId);
28
+ const errors: string[] = [];
29
+ for (const id of ids) {
30
+ try {
31
+ const group = new CompletionGroup(teamName, id);
32
+ const state = group.read();
33
+ if (state.sessionId !== sessionId || currentGroupIds.has(id)) continue;
34
+ for (const slot of state.members) {
35
+ if (["cancelled", "interrupted", "rejected"].includes(slot.status)) continue;
36
+ const owner = members.find(member => member.name === slot.name
37
+ && member.completionGroup?.groupId === id && member.completionGroup.slotId === slot.slotId);
38
+ if (!slot.runId && owner?.lifecycleRunId) {
39
+ await group.apply({ type: "running", slotId: slot.slotId, runId: owner.lifecycleRunId });
40
+ }
41
+ const current = group.read().members.find(member => member.slotId === slot.slotId)!;
42
+ const report = reports.find(event => event.agentName === current.name && event.result?.runId === current.runId
43
+ && event.completionGroup?.groupId === id && event.completionGroup.slotId === current.slotId);
44
+ if (report) await deliverCompletionGroupReport(report);
45
+ else if (["pending", "queued"].includes(current.status) && !owner) {
46
+ await group.apply({ type: "interrupted", slotId: current.slotId,
47
+ reason: "Assignment was not retained across extension reload; no work was restarted." });
48
+ } else if (current.status === "reported") throw new Error(`Full grouped report is unavailable: ${current.report?.reportId}.`);
49
+ else errors.push(`Completion group ${id}: ${current.name} (${current.runId ?? "unbound"}) remains unresolved after reload; no work was restarted.`);
50
+ }
51
+ await group.seal();
52
+ await enqueueCompletionGroupDeliveries(group);
53
+ for (const delivery of group.read().deliveries) if (delivery.wake?.state === "pending") {
54
+ errors.push(`Completion group wake is unconfirmed (${delivery.id}). Use read_inbox for the saved index; no automatic retry. Journal: ${group.journalPath}`);
55
+ }
56
+ } catch (error) {
57
+ errors.push(error instanceof Error ? error.message : String(error));
58
+ }
59
+ }
60
+ return errors;
61
+ }
@@ -0,0 +1,62 @@
1
+ import { findInboxMessageByOperation, markInboxMessagesRead } from "../utils/messaging";
2
+ import type { InboxMessage } from "../utils/models";
3
+ import { CompletionGroup } from "./completion-group";
4
+
5
+ type WakeBinding = { teamName: string; groupId: string; deliveryId: string };
6
+ export interface CompletionWakeEntry {
7
+ type: string; id: string; customType?: string; content?: unknown;
8
+ details?: { completionGroup?: WakeBinding };
9
+ }
10
+ const wakeContent = (message: InboxMessage) => `Completion group index:\n${message.text}\nUse the referenced full reports as needed, integrate the evidence, and continue the active task. Group settlement is not task success or lead acceptance. Do not stop self-exiting reporting agents.`;
11
+
12
+ async function boundIndex(teamName: string, sessionId: string, binding: Pick<WakeBinding, "groupId" | "deliveryId">) {
13
+ const group = new CompletionGroup(teamName, binding?.groupId);
14
+ const state = group.read();
15
+ const delivery = state.deliveries.find(item => item.id === binding.deliveryId);
16
+ const message = await findInboxMessageByOperation(teamName, "team-lead", binding.deliveryId);
17
+ if (state.sessionId !== sessionId || delivery?.status !== "enqueued" || !message
18
+ || message.id !== delivery.id || message.operationId !== delivery.id
19
+ || message.metadata?.completionGroup?.groupId !== group.groupId
20
+ || message.metadata?.completionGroup?.deliveryId !== delivery.id) {
21
+ throw new Error("Completion wake index binding is unavailable or changed.");
22
+ }
23
+ return { group, delivery, message };
24
+ }
25
+
26
+ export async function requestCompletionGroupWake(
27
+ teamName: string, sessionId: string, message: InboxMessage,
28
+ send: (content: string, details: { completionGroup: WakeBinding }) => void,
29
+ ): Promise<boolean> {
30
+ const binding = message.metadata?.completionGroup as WakeBinding;
31
+ const bound = await boundIndex(teamName, sessionId, binding);
32
+ if (message.id !== bound.message.id || message.text !== bound.message.text) throw new Error("Completion wake message binding has changed.");
33
+ if (!await bound.group.reserveWake(binding.deliveryId)) return false;
34
+ try {
35
+ send(wakeContent(bound.message), { completionGroup: { teamName, groupId: bound.group.groupId, deliveryId: binding.deliveryId } });
36
+ } catch (error) {
37
+ await bound.group.recordWakeEvidence(binding.deliveryId, { error: error instanceof Error ? error.message : String(error) });
38
+ throw error;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ export async function observeCompletionGroupWakes(
44
+ teamName: string, sessionId: string, entries: readonly CompletionWakeEntry[],
45
+ ): Promise<{ observed: string[]; errors: string[] }> {
46
+ const observed: string[] = [];
47
+ const errors: string[] = [];
48
+ for (const entry of entries) {
49
+ const binding = entry.details?.completionGroup;
50
+ if (entry.type !== "custom_message" || entry.customType !== "pi-extended-teams-wake" || binding?.teamName !== teamName) continue;
51
+ try {
52
+ const bound = await boundIndex(teamName, sessionId, binding);
53
+ if (entry.content !== wakeContent(bound.message)) throw new Error("Completion wake history binding has changed.");
54
+ await bound.group.recordWakeEvidence(binding.deliveryId, { entryId: entry.id });
55
+ await markInboxMessagesRead(teamName, "team-lead", [binding.deliveryId]);
56
+ observed.push(entry.id);
57
+ } catch (error) {
58
+ errors.push(error instanceof Error ? error.message : String(error));
59
+ }
60
+ }
61
+ return { observed, errors };
62
+ }
@@ -0,0 +1,290 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { Type, type Static } from "@sinclair/typebox";
6
+ import { Value } from "@sinclair/typebox/value";
7
+ import * as paths from "../utils/paths";
8
+ import { withLock } from "../utils/lock";
9
+ import { createReportResult, effectiveTaskOutcome, ReportedTaskDetailsSchema, type ReportResult } from "./report-result";
10
+ import { syncPathAndParents, writeJsonDurably } from "./durable-json";
11
+
12
+ const reference = Type.String({ minLength: 1 });
13
+ const variants = <T extends string>(...values: T[]) => Type.Union(values.map(value => Type.Literal(value)));
14
+ export const CompletionGroupPolicySchema = Type.Object({
15
+ delivery: variants("immediate", "all-settled"),
16
+ }, { additionalProperties: false });
17
+ export type CompletionGroupPolicy = Static<typeof CompletionGroupPolicySchema>;
18
+ export function normalizeCompletionGroupPolicy(value: unknown): CompletionGroupPolicy | undefined {
19
+ if (value === undefined) return undefined;
20
+ if (!Value.Check(CompletionGroupPolicySchema, value)) throw new Error("Invalid completion group policy.");
21
+ return structuredClone(value);
22
+ }
23
+ const SeedSchema = Type.Object({
24
+ name: reference, suppressed: Type.Optional(Type.Boolean()), assignmentKey: Type.Optional(reference),
25
+ }, { additionalProperties: false });
26
+ const seed = (member: Static<typeof SeedSchema>) => ({
27
+ name: member.name, suppressed: member.suppressed === true,
28
+ ...(member.assignmentKey ? { assignmentKey: member.assignmentKey } : {}),
29
+ });
30
+ const OptionsSchema = Type.Object({
31
+ teamName: reference, sessionId: reference, submissionId: reference,
32
+ policy: CompletionGroupPolicySchema, members: Type.Array(SeedSchema, { minItems: 1 }),
33
+ }, { additionalProperties: false });
34
+ export interface CompletionGroupOptions extends Omit<Static<typeof OptionsSchema>, "policy"> {
35
+ policy?: CompletionGroupPolicy;
36
+ }
37
+ export interface CompletionGroupBinding { groupId: string; slotId: string; }
38
+ const ReportSchema = Type.Object({
39
+ runId: reference, reportId: reference, runtimeStatus: Type.Optional(variants("completed", "failed")),
40
+ outcome: ReportedTaskDetailsSchema.properties.outcome,
41
+ effectiveOutcome: ReportedTaskDetailsSchema.properties.outcome,
42
+ verification: variants("not-requested", "pending", "passed", "failed", "stale"),
43
+ }, { additionalProperties: false });
44
+ const MemberSchema = Type.Object({
45
+ slotId: reference, name: reference, suppressed: Type.Boolean(), assignmentKey: Type.Optional(reference),
46
+ status: variants("pending", "queued", "running", "reported", "rejected", "cancelled", "interrupted"),
47
+ queueId: Type.Optional(reference), runId: Type.Optional(reference),
48
+ report: Type.Optional(ReportSchema), reason: Type.Optional(reference),
49
+ }, { additionalProperties: false });
50
+ const DeliverySchema = Type.Object({
51
+ id: reference, kind: variants("member", "urgent", "settled"),
52
+ slotIds: Type.Array(reference, { minItems: 1, uniqueItems: true }), status: variants("pending", "enqueued"),
53
+ wake: Type.Optional(Type.Object({ state: variants("pending", "observed"), entryId: Type.Optional(reference),
54
+ error: Type.Optional(reference) }, { additionalProperties: false })),
55
+ }, { additionalProperties: false });
56
+ const LedgerSchema = Type.Object({
57
+ version: Type.Literal(1), groupId: reference, teamName: reference, sessionId: reference, submissionId: reference,
58
+ policy: CompletionGroupPolicySchema, sealed: Type.Boolean(),
59
+ members: Type.Array(MemberSchema, { minItems: 1 }), deliveries: Type.Array(DeliverySchema),
60
+ }, { additionalProperties: false });
61
+ export type CompletionGroupState = Static<typeof LedgerSchema>;
62
+ export type CompletionDelivery = Static<typeof DeliverySchema>;
63
+ const eventBinding = { slotId: reference, groupId: Type.Optional(reference) };
64
+ const EventSchema = Type.Union([
65
+ Type.Object({ ...eventBinding, type: Type.Literal("queued"), queueId: reference }, { additionalProperties: false }),
66
+ Type.Object({ ...eventBinding, type: Type.Literal("running"), runId: reference }, { additionalProperties: false }),
67
+ Type.Object({ ...eventBinding, type: Type.Literal("reported"), report: ReportSchema }, { additionalProperties: false }),
68
+ Type.Object({ ...eventBinding, type: variants("rejected", "cancelled", "interrupted"),
69
+ runId: Type.Optional(reference), reason: reference }, { additionalProperties: false }),
70
+ Type.Object({ ...eventBinding, type: variants("rejected", "cancelled"),
71
+ queueId: reference, reason: reference }, { additionalProperties: false }),
72
+ ]);
73
+ export type CompletionGroupEvent = Static<typeof EventSchema>;
74
+ const digest = (value: unknown) => crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
75
+ export const completionGroupIdentity = (options: Pick<CompletionGroupOptions, "teamName" | "sessionId" | "submissionId">) =>
76
+ `group:${digest([options.teamName, options.sessionId, options.submissionId])}`;
77
+ const terminal = (member: CompletionGroupState["members"][number]) =>
78
+ !["pending", "queued", "running"].includes(member.status);
79
+ const urgent = (member: CompletionGroupState["members"][number]) => member.status === "rejected" || (!!member.report &&
80
+ (member.report.runtimeStatus === "failed" || ["blocked", "failed"].includes(member.report.effectiveOutcome ?? member.report.outcome ?? "") || ["failed", "stale"].includes(member.report.verification)));
81
+ const deliveryIdentity = (groupId: string, kind: CompletionDelivery["kind"], slots: string[]) =>
82
+ `delivery:${digest([groupId, kind, slots])}`;
83
+
84
+ export class CompletionGroup {
85
+ readonly journalPath: string;
86
+ private newlyCreated = false;
87
+ get created(): boolean { return this.newlyCreated; }
88
+
89
+ constructor(readonly teamName: string, readonly groupId: string) {
90
+ if (!teamName || !/^group:[a-f0-9]{64}$/.test(groupId)) throw new Error("Invalid completion group identity.");
91
+ this.journalPath = path.join(paths.teamDir(teamName), "completion-groups", `${groupId.slice(6)}.json`);
92
+ }
93
+
94
+ static async create(options: CompletionGroupOptions): Promise<CompletionGroup | undefined> {
95
+ if (normalizeCompletionGroupPolicy(options.policy) === undefined) return undefined;
96
+ if (!Value.Check(OptionsSchema, options)) throw new Error("Invalid completion group assignment.");
97
+ const bound = structuredClone(options);
98
+ const group = new CompletionGroup(bound.teamName, completionGroupIdentity(bound));
99
+ const seeds = bound.members.map(seed);
100
+ fs.mkdirSync(path.dirname(group.journalPath), { recursive: true, mode: 0o700 });
101
+ await withLock(group.journalPath, async () => {
102
+ if (fs.existsSync(group.journalPath)) {
103
+ const existing = group.read();
104
+ if (!isDeepStrictEqual(existing.policy, bound.policy)
105
+ || !isDeepStrictEqual(existing.members.map(seed), seeds)) {
106
+ throw new Error("Completion group assignment binding has changed.");
107
+ }
108
+ syncPathAndParents(group.journalPath);
109
+ return;
110
+ }
111
+ writeJsonDurably(group.journalPath, {
112
+ version: 1, groupId: group.groupId, teamName: bound.teamName, sessionId: bound.sessionId,
113
+ submissionId: bound.submissionId, policy: bound.policy, sealed: false, deliveries: [],
114
+ members: seeds.map((member, index) => ({ ...member, slotId: `${group.groupId}:${index}`, status: "pending" })),
115
+ });
116
+ group.newlyCreated = true;
117
+ });
118
+ return group;
119
+ }
120
+
121
+ read(): CompletionGroupState {
122
+ let state: unknown;
123
+ try { state = JSON.parse(fs.readFileSync(this.journalPath, "utf8")); }
124
+ catch (error) {
125
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
126
+ throw new Error(`Referenced completion group is unavailable; inspect ${this.journalPath}.`);
127
+ }
128
+ throw new Error(`Corrupt completion group; inspect ${this.journalPath}.`);
129
+ }
130
+ if (!Value.Check(LedgerSchema, state) || state.groupId !== this.groupId
131
+ || state.teamName !== this.teamName || completionGroupIdentity(state) !== this.groupId) {
132
+ throw new Error("Corrupt completion group binding.");
133
+ }
134
+ for (const [index, member] of state.members.entries()) {
135
+ const reportId = member.runId && createReportResult(state.teamName, member.name, member.runId, {}).reportId;
136
+ if (member.slotId !== `${this.groupId}:${index}`
137
+ || (member.status === "pending" && (member.runId || member.queueId))
138
+ || (member.status === "queued" && (!member.queueId || member.runId))
139
+ || (member.status === "running" && !member.runId)
140
+ || ((member.status === "reported") !== !!member.report)
141
+ || (member.report && (member.report.runId !== member.runId || member.report.reportId !== reportId))) {
142
+ throw new Error("Corrupt completion member binding.");
143
+ }
144
+ }
145
+ if (new Set(state.deliveries.map(delivery => delivery.id)).size !== state.deliveries.length) {
146
+ throw new Error("Corrupt completion delivery identity.");
147
+ }
148
+ for (const delivery of state.deliveries) {
149
+ if ((delivery.wake && (delivery.status !== "enqueued" || ((delivery.wake.state === "observed") !== !!delivery.wake.entryId)))
150
+ || delivery.id !== deliveryIdentity(this.groupId, delivery.kind, delivery.slotIds)
151
+ || (delivery.kind !== "settled" && delivery.slotIds.length !== 1)
152
+ || (delivery.kind === "settled" && (!state.sealed || !state.members.every(terminal)))
153
+ || delivery.slotIds.some(id => !state.members.some(member => member.slotId === id && terminal(member) && !member.suppressed))) {
154
+ throw new Error("Corrupt completion delivery binding.");
155
+ }
156
+ }
157
+ return state;
158
+ }
159
+
160
+ binding(index: number): CompletionGroupBinding {
161
+ const member = this.read().members[index];
162
+ if (!member) throw new Error("Unknown completion group slot.");
163
+ return { groupId: this.groupId, slotId: member.slotId };
164
+ }
165
+
166
+ private plan(state: CompletionGroupState): void {
167
+ const visible = state.members.filter(member => terminal(member) && !member.suppressed);
168
+ const covered = new Set(state.deliveries.flatMap(delivery => delivery.slotIds));
169
+ const due = visible.filter(member => !covered.has(member.slotId));
170
+ const append = (kind: CompletionDelivery["kind"], slotIds: string[]) => {
171
+ state.deliveries.push({ id: deliveryIdentity(this.groupId, kind, slotIds), kind, slotIds, status: "pending" });
172
+ };
173
+ if (due.length === 0) return;
174
+ if (state.policy.delivery === "all-settled" && state.sealed && state.members.every(terminal)) {
175
+ append("settled", visible.map(member => member.slotId));
176
+ return;
177
+ }
178
+ for (const member of due) {
179
+ if (state.policy.delivery === "immediate") append("member", [member.slotId]);
180
+ else if (urgent(member)) append("urgent", [member.slotId]);
181
+ }
182
+ }
183
+
184
+ private async update(change: (state: CompletionGroupState) => void): Promise<CompletionGroupState> {
185
+ this.read();
186
+ return withLock(this.journalPath, async () => {
187
+ const state = this.read();
188
+ const before = JSON.stringify(state);
189
+ change(state);
190
+ this.plan(state);
191
+ if (JSON.stringify(state) !== before) writeJsonDurably(this.journalPath, state);
192
+ else syncPathAndParents(this.journalPath);
193
+ return state;
194
+ });
195
+ }
196
+
197
+ async apply(event: CompletionGroupEvent): Promise<void> {
198
+ if (!Value.Check(EventSchema, event) || (event.groupId && event.groupId !== this.groupId)) {
199
+ throw new Error("Invalid completion event binding.");
200
+ }
201
+ const input = structuredClone(event);
202
+ if (input.type === "reported") {
203
+ if (input.report.outcome === undefined) delete input.report.outcome;
204
+ if (input.report.effectiveOutcome === undefined) delete input.report.effectiveOutcome;
205
+ }
206
+ await this.update(state => {
207
+ const member = state.members.find(item => item.slotId === input.slotId);
208
+ if (!member) throw new Error("Unknown completion slot binding.");
209
+ if (input.type === "queued") {
210
+ if (member.queueId === input.queueId) return;
211
+ if (member.status !== "pending") throw new Error("Completion slot is already admitted or settled.");
212
+ Object.assign(member, { queueId: input.queueId, status: "queued" });
213
+ } else if (input.type === "running") {
214
+ if (member.runId) {
215
+ if (member.runId !== input.runId) throw new Error("Completion run binding has changed.");
216
+ return;
217
+ }
218
+ if (terminal(member)) throw new Error("Completion slot is already settled.");
219
+ Object.assign(member, { runId: input.runId, status: "running" });
220
+ } else if (input.type === "reported") {
221
+ const expected = member.runId && createReportResult(this.teamName, member.name, member.runId, {}).reportId;
222
+ if (input.report.runId !== member.runId || input.report.reportId !== expected) {
223
+ throw new Error("Completion report binding has changed.");
224
+ }
225
+ if (member.report) {
226
+ if (!isDeepStrictEqual(member.report, input.report)) throw new Error("Conflicting completion report binding.");
227
+ return;
228
+ }
229
+ if (member.status !== "running") throw new Error("Completion slot is already settled.");
230
+ Object.assign(member, { report: input.report, status: "reported" });
231
+ } else {
232
+ if ("queueId" in input) {
233
+ // Queue identity is immutable across admission. Resolve its terminal
234
+ // state under this lock, retaining any run bound while removal waited.
235
+ if (member.queueId !== input.queueId) throw new Error("Completion settlement queue binding has changed.");
236
+ } else if (member.runId !== input.runId) throw new Error("Completion settlement run binding has changed.");
237
+ if (terminal(member)) return;
238
+ Object.assign(member, { status: input.type, reason: input.reason });
239
+ }
240
+ });
241
+ }
242
+
243
+ async recordReport(slotId: string, result: ReportResult, runtimeStatus?: "completed" | "failed"): Promise<void> {
244
+ await this.apply({ type: "reported", slotId, report: { reportId: result.reportId, runId: result.runId,
245
+ ...(runtimeStatus ? { runtimeStatus } : {}),
246
+ outcome: result.outcome, effectiveOutcome: effectiveTaskOutcome(result), verification: result.verification.state } });
247
+ }
248
+
249
+ async seal(): Promise<void> { await this.update(state => { state.sealed = true; }); }
250
+
251
+ async prepareDeliveries(): Promise<CompletionDelivery[]> {
252
+ const state = await this.update(() => {});
253
+ return state.deliveries.filter(delivery => delivery.status === "pending");
254
+ }
255
+
256
+ async reserveWake(deliveryId: string): Promise<boolean> {
257
+ let reserved = false;
258
+ await this.update(state => {
259
+ const delivery = state.deliveries.find(item => item.id === deliveryId);
260
+ if (delivery?.status !== "enqueued") throw new Error("Completion wake requires an enqueued index.");
261
+ if (!delivery.wake) {
262
+ delivery.wake = { state: "pending" };
263
+ reserved = true;
264
+ }
265
+ });
266
+ return reserved;
267
+ }
268
+
269
+ async recordWakeEvidence(deliveryId: string, evidence: { entryId: string } | { error: string }): Promise<void> {
270
+ await this.update(state => {
271
+ const wake = state.deliveries.find(item => item.id === deliveryId)?.wake;
272
+ if (!wake) throw new Error("Completion wake has no reserved request.");
273
+ if ("entryId" in evidence) {
274
+ if (!evidence.entryId || (wake.entryId && wake.entryId !== evidence.entryId)) throw new Error("Completion wake observation binding has changed.");
275
+ Object.assign(wake, { state: "observed", entryId: evidence.entryId });
276
+ } else {
277
+ if (!evidence.error) throw new Error("Completion wake error is unavailable.");
278
+ wake.error = evidence.error;
279
+ }
280
+ });
281
+ }
282
+
283
+ async markEnqueued(deliveryId: string): Promise<void> {
284
+ await this.update(state => {
285
+ const delivery = state.deliveries.find(item => item.id === deliveryId);
286
+ if (!delivery) throw new Error("Unknown completion delivery identity.");
287
+ delivery.status = "enqueued";
288
+ });
289
+ }
290
+ }
@@ -0,0 +1,37 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import * as paths from "../utils/paths";
5
+
6
+ export function freshContinuationName(prefix: string, nonce?: string): string {
7
+ paths.sanitizeName(prefix);
8
+ if (!prefix || prefix.length > 128) throw new Error("Continuation name prefix must contain 1–128 characters.");
9
+ const suffix = nonce === undefined ? crypto.randomUUID() : crypto.createHash("sha256").update(nonce).digest("hex").slice(0, 32);
10
+ return `${prefix}-${suffix}`;
11
+ }
12
+
13
+ export function assertUnusedContinuationRecipient(teamName: string, candidate: string): void {
14
+ paths.sanitizeName(candidate);
15
+ const directory = paths.teamDir(teamName);
16
+ const collision = () => { throw new Error(`Continuation recipient ${candidate} is not unused; choose a new prefix and retry.`); };
17
+ const exists = (file: string) => !!fs.lstatSync(file, { throwIfNoEntry: false });
18
+ for (const file of [paths.inboxPath(teamName, candidate), paths.runtimeStatusPath(teamName, candidate), paths.lifecycleTombstonePath(teamName, candidate),
19
+ path.join(directory, `${candidate}.pid`), path.join(directory, "agent-sessions", candidate)]) if (exists(file)) collision();
20
+ for (const [folder, matches] of [
21
+ [path.join(directory, "session-context"), (name: string) => name.startsWith(`${candidate.toLowerCase()}--`)],
22
+ [path.join(paths.reportFilesDir(), paths.sanitizeName(teamName)), (name: string) => name === `${candidate.toLowerCase()}.md` || name.startsWith(`${candidate.toLowerCase()}-v`)],
23
+ ] as const) if (exists(folder) && fs.readdirSync(folder).some(name => matches(name.toLowerCase()))) collision();
24
+ for (const [file, key, array] of [[paths.claimsPath(teamName), "agent", false], [paths.writeQueuePath(teamName), "name", true],
25
+ [paths.readHelperQueuePath(teamName), "name", true], [path.join(directory, "reports.json"), "agentName", true]] as const) {
26
+ if (!exists(file)) continue;
27
+ let rows: unknown[];
28
+ try {
29
+ if (!fs.lstatSync(file).isFile() || fs.lstatSync(file).isSymbolicLink()) throw new Error("Not a regular namespace record");
30
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
31
+ if (!value || typeof value !== "object" || Array.isArray(value) !== array) throw new Error("Invalid namespace record");
32
+ rows = array ? value : Object.values(value);
33
+ if (rows.some(row => !row || typeof row !== "object" || typeof Reflect.get(row, key) !== "string")) throw new Error("Invalid namespace owner");
34
+ } catch { throw new Error(`Cannot establish an unused continuation namespace; inspect ${file}.`); }
35
+ if (rows.some(row => String(Reflect.get(row as object, key)).toLowerCase() === candidate.toLowerCase())) collision();
36
+ }
37
+ }