pi-session-orchestrator 0.2.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/LICENSE +21 -0
- package/README.md +307 -0
- package/assets/pi-session-orchestrator-thumbnail-pi-sessions.png +0 -0
- package/docs/README.md +18 -0
- package/docs/pi-intercom.md +7 -0
- package/docs/prompt-injection.md +52 -0
- package/docs/reference/README.md +14 -0
- package/docs/reference/coordination-model.md +9 -0
- package/docs/reference/fast-decision-model.md +23 -0
- package/docs/reference/operational-boundaries.md +7 -0
- package/docs/release-version-preparation.md +55 -0
- package/package.json +58 -0
- package/src/binding.ts +50 -0
- package/src/index.ts +311 -0
- package/src/model.ts +164 -0
- package/src/prompt.ts +118 -0
- package/src/state.ts +639 -0
- package/src/tools.ts +275 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ASSIGNMENT_STATUSES,
|
|
8
|
+
type Assignment,
|
|
9
|
+
type AssignmentRole,
|
|
10
|
+
type AssignmentStatus,
|
|
11
|
+
type CanonicalBinding,
|
|
12
|
+
type DurableState,
|
|
13
|
+
type EventCursor,
|
|
14
|
+
type EventType,
|
|
15
|
+
type OrchestrationEventAction,
|
|
16
|
+
type HandoffRecord,
|
|
17
|
+
type OrchestrationEvent,
|
|
18
|
+
type PresenceStatus,
|
|
19
|
+
type SessionPresence,
|
|
20
|
+
type SessionRole,
|
|
21
|
+
type ScopeChangeApprovalKind,
|
|
22
|
+
type ScopeChangeProposal,
|
|
23
|
+
type ScopeChangeState,
|
|
24
|
+
type ScopeReconciliation,
|
|
25
|
+
} from "./model";
|
|
26
|
+
|
|
27
|
+
const PENDING_STATUSES: AssignmentStatus[] = ["created", "attached", "active", "handoff-submitted"];
|
|
28
|
+
const SCOPE_CHANGE_ALLOWED_STATUSES: AssignmentStatus[] = ["attached", "active", "handoff-submitted"];
|
|
29
|
+
const MAX_EVENT_LEDGER = 500;
|
|
30
|
+
const MAX_CURSOR_EVENT_IDS = 500;
|
|
31
|
+
/** Presence is stale after 30 minutes without observation; no timer is started. */
|
|
32
|
+
export const PRESENCE_STALE_AFTER_MS = 30 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
function now(): string {
|
|
35
|
+
return new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyEmptyState(): DurableState {
|
|
39
|
+
return {
|
|
40
|
+
version: 1, coordinators: [], assignments: [], handoffs: [], events: [], presence: [], eventCursors: {},
|
|
41
|
+
scopeChanges: { version: 1, proposals: [], reconciliations: [] },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requireText(value: unknown, field: string): string {
|
|
46
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`Invalid ${field}`);
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isBinding(value: unknown): value is CanonicalBinding {
|
|
51
|
+
return typeof value === "object" && value !== null
|
|
52
|
+
&& typeof (value as CanonicalBinding).repository === "string"
|
|
53
|
+
&& typeof (value as CanonicalBinding).worktree === "string";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isStatus(value: unknown): value is AssignmentStatus {
|
|
57
|
+
return typeof value === "string" && (ASSIGNMENT_STATUSES as readonly string[]).includes(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeEventSequences(state: DurableState): DurableState {
|
|
61
|
+
let nextSequence = 1;
|
|
62
|
+
for (const event of state.events) {
|
|
63
|
+
if (event.sequence === undefined) event.sequence = nextSequence;
|
|
64
|
+
nextSequence = Math.max(nextSequence, event.sequence + 1);
|
|
65
|
+
}
|
|
66
|
+
const cursorTail = Math.max(0, ...Object.values(state.eventCursors ?? {}).map((cursor) => cursor.lastSequence));
|
|
67
|
+
state.nextEventSequence = Math.max(state.nextEventSequence ?? 1, nextSequence, cursorTail + 1);
|
|
68
|
+
return state;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateState(value: unknown): DurableState {
|
|
72
|
+
if (typeof value !== "object" || value === null) throw new Error("Invalid orchestration state");
|
|
73
|
+
const state = value as DurableState;
|
|
74
|
+
if (state.version !== 1 || !Array.isArray(state.coordinators) || !Array.isArray(state.assignments)
|
|
75
|
+
|| !Array.isArray(state.handoffs) || !Array.isArray(state.events)) {
|
|
76
|
+
throw new Error("Invalid orchestration state");
|
|
77
|
+
}
|
|
78
|
+
if (state.presence === undefined) state.presence = [];
|
|
79
|
+
if (!Array.isArray(state.presence)) throw new Error("Invalid orchestration presence");
|
|
80
|
+
for (const presence of state.presence) {
|
|
81
|
+
requireText(presence.sessionId, "presence session ID");
|
|
82
|
+
requireText(presence.lastSeenAt, "presence timestamp");
|
|
83
|
+
if (!["live", "reloading", "suspended"].includes(presence.status)) throw new Error("Invalid presence status");
|
|
84
|
+
}
|
|
85
|
+
for (const coordinator of state.coordinators) requireText(coordinator, "coordinator session ID");
|
|
86
|
+
for (const assignment of state.assignments) {
|
|
87
|
+
requireText(assignment.id, "assignment ID");
|
|
88
|
+
requireText(assignment.coordinatorSessionId, "coordinator session ID");
|
|
89
|
+
requireText(assignment.focusedSessionId, "focused session ID");
|
|
90
|
+
requireText(assignment.objective, "objective");
|
|
91
|
+
requireText(assignment.allowedScope, "allowed scope");
|
|
92
|
+
requireText(assignment.createdAt, "created timestamp");
|
|
93
|
+
requireText(assignment.updatedAt, "updated timestamp");
|
|
94
|
+
if (!isBinding(assignment.binding) || !isStatus(assignment.status)) throw new Error("Invalid assignment");
|
|
95
|
+
if (assignment.scopeRevision === undefined) assignment.scopeRevision = 1;
|
|
96
|
+
if (!Number.isInteger(assignment.scopeRevision) || assignment.scopeRevision < 1) throw new Error("Invalid assignment scope revision");
|
|
97
|
+
}
|
|
98
|
+
if (state.scopeChanges === undefined) state.scopeChanges = { version: 1, proposals: [], reconciliations: [] };
|
|
99
|
+
if (typeof state.scopeChanges !== "object" || state.scopeChanges === null || state.scopeChanges.version !== 1
|
|
100
|
+
|| !Array.isArray(state.scopeChanges.proposals) || !Array.isArray(state.scopeChanges.reconciliations)) {
|
|
101
|
+
throw new Error("Invalid scope-change state");
|
|
102
|
+
}
|
|
103
|
+
for (const proposal of state.scopeChanges.proposals) {
|
|
104
|
+
requireText(proposal.id, "scope-change proposal ID");
|
|
105
|
+
requireText(proposal.assignmentId, "scope-change assignment ID");
|
|
106
|
+
requireText(proposal.requesterSessionId, "scope-change requester session ID");
|
|
107
|
+
requireText(proposal.requestedScope, "requested scope");
|
|
108
|
+
requireText(proposal.reason, "scope-change reason");
|
|
109
|
+
requireText(proposal.createdAt, "scope-change created timestamp");
|
|
110
|
+
requireText(proposal.updatedAt, "scope-change updated timestamp");
|
|
111
|
+
if (!Number.isInteger(proposal.baseScopeRevision) || proposal.baseScopeRevision < 1
|
|
112
|
+
|| !Array.isArray(proposal.evidence) || proposal.evidence.some((item) => typeof item !== "string")
|
|
113
|
+
|| !["pending", "approved"].includes(proposal.status)) throw new Error("Invalid scope-change proposal");
|
|
114
|
+
if (proposal.approvalKind !== undefined && !["coordinator", "direct-human"].includes(proposal.approvalKind)) {
|
|
115
|
+
throw new Error("Invalid scope-change approval");
|
|
116
|
+
}
|
|
117
|
+
if (proposal.approvedBy !== undefined) requireText(proposal.approvedBy, "scope-change approver");
|
|
118
|
+
}
|
|
119
|
+
for (const reconciliation of state.scopeChanges.reconciliations) {
|
|
120
|
+
requireText(reconciliation.id, "scope reconciliation ID");
|
|
121
|
+
requireText(reconciliation.assignmentId, "scope reconciliation assignment ID");
|
|
122
|
+
requireText(reconciliation.requestedScope, "reconciliation scope");
|
|
123
|
+
requireText(reconciliation.reason, "reconciliation reason");
|
|
124
|
+
requireText(reconciliation.createdAt, "reconciliation created timestamp");
|
|
125
|
+
requireText(reconciliation.updatedAt, "reconciliation updated timestamp");
|
|
126
|
+
if (!Number.isInteger(reconciliation.scopeRevision) || reconciliation.scopeRevision < 1
|
|
127
|
+
|| !Array.isArray(reconciliation.evidence) || reconciliation.evidence.some((item) => typeof item !== "string")
|
|
128
|
+
|| !["pending", "reconciled"].includes(reconciliation.status)) throw new Error("Invalid scope reconciliation");
|
|
129
|
+
}
|
|
130
|
+
for (const handoff of state.handoffs) {
|
|
131
|
+
requireText(handoff.id, "handoff ID");
|
|
132
|
+
requireText(handoff.assignmentId, "handoff assignment ID");
|
|
133
|
+
requireText(handoff.submittedAt, "handoff timestamp");
|
|
134
|
+
requireText(handoff.outcome, "handoff outcome");
|
|
135
|
+
if (!Array.isArray(handoff.changedSurfaces) || !Array.isArray(handoff.checks) || !Array.isArray(handoff.risks)) {
|
|
136
|
+
throw new Error("Invalid handoff");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (state.nextEventSequence !== undefined && (!Number.isInteger(state.nextEventSequence) || state.nextEventSequence < 1)) {
|
|
140
|
+
throw new Error("Invalid orchestration event sequence");
|
|
141
|
+
}
|
|
142
|
+
if (state.eventCursors !== undefined) {
|
|
143
|
+
if (typeof state.eventCursors !== "object" || state.eventCursors === null) throw new Error("Invalid orchestration event cursors");
|
|
144
|
+
for (const cursor of Object.values(state.eventCursors)) {
|
|
145
|
+
if (!cursor || !Number.isInteger(cursor.lastSequence) || cursor.lastSequence < 0 || !Array.isArray(cursor.eventIds)
|
|
146
|
+
|| cursor.eventIds.some((id) => typeof id !== "string")) throw new Error("Invalid orchestration event cursor");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const event of state.events) {
|
|
150
|
+
requireText(event.id, "event ID");
|
|
151
|
+
if (typeof event.assignmentId !== "string") throw new Error("Invalid event assignment ID");
|
|
152
|
+
if (event.sessionId !== undefined) requireText(event.sessionId, "event session ID");
|
|
153
|
+
requireText(event.createdAt, "event timestamp");
|
|
154
|
+
requireText(event.summary, "event summary");
|
|
155
|
+
if (!["progress", "blocker", "decision_request", "handoff", "review_result"].includes(event.type)) {
|
|
156
|
+
throw new Error("Invalid event");
|
|
157
|
+
}
|
|
158
|
+
if (event.sequence !== undefined && (!Number.isInteger(event.sequence) || event.sequence < 1)) {
|
|
159
|
+
throw new Error("Invalid event sequence");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return normalizeEventSequences(state);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function bindingsMatch(left: CanonicalBinding, right: CanonicalBinding): boolean {
|
|
166
|
+
return left.repository === right.repository && left.worktree === right.worktree;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function boundedLedgerItems<T>(items: T[], limit: number): { total: number; returned: number; truncated: boolean; items: T[] } {
|
|
170
|
+
const selected = items.slice(-limit);
|
|
171
|
+
return {
|
|
172
|
+
total: items.length,
|
|
173
|
+
returned: selected.length,
|
|
174
|
+
truncated: selected.length < items.length,
|
|
175
|
+
items: selected,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class LocalStateStore {
|
|
180
|
+
constructor(private readonly path = join(homedir(), ".pi", "pi-session-orchestrator", "state.json")) {}
|
|
181
|
+
|
|
182
|
+
read(): DurableState {
|
|
183
|
+
try {
|
|
184
|
+
return validateState(JSON.parse(readFileSync(this.path, "utf8")));
|
|
185
|
+
} catch (error: unknown) {
|
|
186
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return copyEmptyState();
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
registerCoordinator(sessionId: string): void {
|
|
192
|
+
const state = this.read();
|
|
193
|
+
const role = this.roleForState(state, sessionId);
|
|
194
|
+
if (role.kind === "focused-session") {
|
|
195
|
+
throw new Error("A focused session cannot become a coordinator or create managed children");
|
|
196
|
+
}
|
|
197
|
+
if (!state.coordinators.includes(sessionId)) {
|
|
198
|
+
state.coordinators.push(sessionId);
|
|
199
|
+
this.recordEvent(state, "", "progress", "Coordinator role recorded", undefined, { sessionId }, "coordinator_registered", sessionId);
|
|
200
|
+
this.write(state);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
createAssignment(input: Omit<Assignment, "id" | "status" | "createdAt" | "updatedAt" | "targetRole"> & { targetRole?: AssignmentRole }): Assignment {
|
|
205
|
+
return this.createAssignments([input])[0]!;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
createAssignments(inputs: (Omit<Assignment, "id" | "status" | "createdAt" | "updatedAt" | "targetRole"> & { targetRole?: AssignmentRole })[]): Assignment[] {
|
|
209
|
+
if (!inputs.length) throw new Error("At least one assignment is required");
|
|
210
|
+
const state = this.read();
|
|
211
|
+
const assignments: Assignment[] = [];
|
|
212
|
+
const assignedSessionIds = new Set(state.assignments
|
|
213
|
+
.filter((assignment) => PENDING_STATUSES.includes(assignment.status))
|
|
214
|
+
.map((assignment) => assignment.focusedSessionId));
|
|
215
|
+
|
|
216
|
+
for (const input of inputs) {
|
|
217
|
+
const coordinatorRole = this.coordinatorRole(state, input.coordinatorSessionId);
|
|
218
|
+
if (!coordinatorRole) throw new Error("Only an explicitly registered coordinator can create assignments");
|
|
219
|
+
const targetRole = input.targetRole ?? "focused-session";
|
|
220
|
+
if (targetRole === "domain-coordinator" && coordinatorRole !== "root-coordinator") {
|
|
221
|
+
throw new Error("Only a root coordinator can create a domain coordinator");
|
|
222
|
+
}
|
|
223
|
+
if (input.coordinatorSessionId === input.focusedSessionId || state.coordinators.includes(input.focusedSessionId)) {
|
|
224
|
+
throw new Error("A coordinator cannot assign itself or another coordinator as a managed child");
|
|
225
|
+
}
|
|
226
|
+
if (assignedSessionIds.has(input.focusedSessionId)) throw new Error("The managed session already has a pending assignment");
|
|
227
|
+
|
|
228
|
+
const timestamp = now();
|
|
229
|
+
assignments.push({
|
|
230
|
+
...input,
|
|
231
|
+
targetRole,
|
|
232
|
+
scopeRevision: 1,
|
|
233
|
+
id: randomUUID(),
|
|
234
|
+
status: "created",
|
|
235
|
+
createdAt: timestamp,
|
|
236
|
+
updatedAt: timestamp,
|
|
237
|
+
});
|
|
238
|
+
assignedSessionIds.add(input.focusedSessionId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
state.assignments.push(...assignments);
|
|
242
|
+
for (const assignment of assignments) {
|
|
243
|
+
this.recordEvent(state, assignment.id, "progress", "Assignment created", "Target session must attach explicitly", {
|
|
244
|
+
status: assignment.status,
|
|
245
|
+
}, "assignment_created", assignment.focusedSessionId);
|
|
246
|
+
}
|
|
247
|
+
this.write(state);
|
|
248
|
+
return assignments;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
attach(assignmentId: string, focusedSessionId: string, binding: CanonicalBinding): Assignment {
|
|
252
|
+
const state = this.read();
|
|
253
|
+
const assignment = this.assignment(state, assignmentId);
|
|
254
|
+
if (assignment.focusedSessionId !== focusedSessionId) throw new Error("Only the assigned focused session can attach");
|
|
255
|
+
if (assignment.status !== "created") throw new Error(`Cannot attach an assignment in ${assignment.status}`);
|
|
256
|
+
if (!bindingsMatch(assignment.binding, binding)) throw new Error("Assignment repository/worktree binding does not match this focused session");
|
|
257
|
+
this.transition(state, assignment, "attached", "Focused session attached", "Start the assigned objective explicitly", "assignment_attached");
|
|
258
|
+
this.write(state);
|
|
259
|
+
return assignment;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
activate(focusedSessionId: string): Assignment {
|
|
263
|
+
const state = this.read();
|
|
264
|
+
const assignment = this.requiredFocusedAssignment(state, focusedSessionId);
|
|
265
|
+
this.assertStatus(assignment, "attached");
|
|
266
|
+
this.transition(state, assignment, "active", "Focused work is active", undefined, "assignment_started");
|
|
267
|
+
this.write(state);
|
|
268
|
+
return assignment;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
submitHandoff(focusedSessionId: string, input: Omit<HandoffRecord, "id" | "assignmentId" | "submittedAt">): HandoffRecord {
|
|
272
|
+
const state = this.read();
|
|
273
|
+
const assignment = this.requiredFocusedAssignment(state, focusedSessionId);
|
|
274
|
+
this.assertStatus(assignment, "active");
|
|
275
|
+
const handoff: HandoffRecord = { ...input, id: randomUUID(), assignmentId: assignment.id, submittedAt: now() };
|
|
276
|
+
state.handoffs.push(handoff);
|
|
277
|
+
this.transition(state, assignment, "handoff-submitted", "Handoff submitted", "Coordinator must explicitly accept or return it", "handoff_submitted", {
|
|
278
|
+
handoffId: handoff.id,
|
|
279
|
+
outcome: input.outcome,
|
|
280
|
+
}, "handoff");
|
|
281
|
+
this.write(state);
|
|
282
|
+
return handoff;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
decide(focusedSessionId: string, summary: string, details: Record<string, unknown>): OrchestrationEvent {
|
|
286
|
+
const state = this.read();
|
|
287
|
+
const assignment = this.requiredFocusedAssignment(state, focusedSessionId);
|
|
288
|
+
if (!PENDING_STATUSES.includes(assignment.status)) throw new Error("Decision requests require a pending assignment");
|
|
289
|
+
const event = this.recordEvent(state, assignment.id, "decision_request", summary, "Coordinator decision required", details, "decision_requested", focusedSessionId);
|
|
290
|
+
this.write(state);
|
|
291
|
+
return event;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
proposeScopeChange(focusedSessionId: string, input: {
|
|
295
|
+
baseScopeRevision: number;
|
|
296
|
+
requestedScope: string;
|
|
297
|
+
reason: string;
|
|
298
|
+
evidence: string[];
|
|
299
|
+
}): ScopeChangeProposal {
|
|
300
|
+
const state = this.read();
|
|
301
|
+
const assignment = this.requiredFocusedAssignment(state, focusedSessionId);
|
|
302
|
+
this.assertScopeChangeLifecycle(assignment);
|
|
303
|
+
const currentRevision = this.assignmentScopeRevision(assignment);
|
|
304
|
+
if (input.baseScopeRevision !== currentRevision) throw new Error("Scope-change proposal uses a stale scope revision");
|
|
305
|
+
requireText(input.requestedScope, "requested scope");
|
|
306
|
+
requireText(input.reason, "scope-change reason");
|
|
307
|
+
if (!Array.isArray(input.evidence) || input.evidence.some((item) => typeof item !== "string")) {
|
|
308
|
+
throw new Error("Scope-change evidence must be an array of strings");
|
|
309
|
+
}
|
|
310
|
+
const scopeChanges = this.scopeChanges(state);
|
|
311
|
+
if (scopeChanges.proposals.some((proposal) => proposal.assignmentId === assignment.id
|
|
312
|
+
&& proposal.status === "pending" && proposal.baseScopeRevision === currentRevision)) {
|
|
313
|
+
throw new Error("A current scope-change proposal already exists");
|
|
314
|
+
}
|
|
315
|
+
const timestamp = now();
|
|
316
|
+
const proposal: ScopeChangeProposal = {
|
|
317
|
+
id: randomUUID(), assignmentId: assignment.id, requesterSessionId: focusedSessionId,
|
|
318
|
+
baseScopeRevision: currentRevision, requestedScope: input.requestedScope.trim(), reason: input.reason.trim(),
|
|
319
|
+
evidence: input.evidence, status: "pending", createdAt: timestamp, updatedAt: timestamp,
|
|
320
|
+
};
|
|
321
|
+
scopeChanges.proposals.push(proposal);
|
|
322
|
+
this.recordEvent(state, assignment.id, "decision_request", "Scope change proposed", "Coordinator must approve or the focused session must obtain direct human approval", {
|
|
323
|
+
proposalId: proposal.id, scopeRevision: currentRevision, requestedScope: proposal.requestedScope, reason: proposal.reason,
|
|
324
|
+
}, "scope_change_proposed", focusedSessionId);
|
|
325
|
+
this.write(state);
|
|
326
|
+
return proposal;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
approveScopeChange(coordinatorSessionId: string, assignmentId: string, proposalId: string, baseScopeRevision: number): Assignment {
|
|
330
|
+
return this.applyScopeChange(coordinatorSessionId, assignmentId, proposalId, baseScopeRevision, "coordinator");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
approveScopeChangeAsHuman(focusedSessionId: string, proposalId: string, baseScopeRevision: number): Assignment {
|
|
334
|
+
return this.applyScopeChange(focusedSessionId, undefined, proposalId, baseScopeRevision, "direct-human");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
reconcileScopeChange(coordinatorSessionId: string, reconciliationId: string, scopeRevision: number): ScopeReconciliation {
|
|
338
|
+
const state = this.read();
|
|
339
|
+
const scopeChanges = this.scopeChanges(state);
|
|
340
|
+
const reconciliation = scopeChanges.reconciliations.find((item) => item.id === reconciliationId);
|
|
341
|
+
if (!reconciliation) throw new Error("Unknown scope reconciliation");
|
|
342
|
+
const assignment = this.assignment(state, reconciliation.assignmentId);
|
|
343
|
+
if (assignment.coordinatorSessionId !== coordinatorSessionId || !state.coordinators.includes(coordinatorSessionId)) {
|
|
344
|
+
throw new Error("Only the assignment coordinator can reconcile a scope change");
|
|
345
|
+
}
|
|
346
|
+
if (reconciliation.status !== "pending") throw new Error("Scope reconciliation is no longer pending");
|
|
347
|
+
if (scopeRevision !== reconciliation.scopeRevision || this.assignmentScopeRevision(assignment) !== scopeRevision) {
|
|
348
|
+
throw new Error("Scope reconciliation uses a stale scope revision");
|
|
349
|
+
}
|
|
350
|
+
reconciliation.status = "reconciled";
|
|
351
|
+
reconciliation.updatedAt = now();
|
|
352
|
+
this.recordEvent(state, assignment.id, "progress", "Direct-human scope change reconciled", undefined, {
|
|
353
|
+
reconciliationId, scopeRevision,
|
|
354
|
+
}, "scope_change_reconciled", coordinatorSessionId);
|
|
355
|
+
this.write(state);
|
|
356
|
+
return reconciliation;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
acceptOrReturn(coordinatorSessionId: string, assignmentId: string, outcome: "accepted" | "returned", reason?: string): Assignment {
|
|
360
|
+
const state = this.read();
|
|
361
|
+
const assignment = this.assignment(state, assignmentId);
|
|
362
|
+
if (assignment.coordinatorSessionId !== coordinatorSessionId || !state.coordinators.includes(coordinatorSessionId)) {
|
|
363
|
+
throw new Error("Only the assignment coordinator can accept or return a handoff");
|
|
364
|
+
}
|
|
365
|
+
this.assertStatus(assignment, "handoff-submitted");
|
|
366
|
+
if (!state.handoffs.some((handoff) => handoff.assignmentId === assignmentId)) throw new Error("No persisted handoff exists for this assignment");
|
|
367
|
+
this.transition(state, assignment, outcome, outcome === "accepted" ? "Handoff accepted" : "Handoff returned", reason,
|
|
368
|
+
outcome === "accepted" ? "handoff_accepted" : "handoff_returned", undefined, "handoff");
|
|
369
|
+
this.write(state);
|
|
370
|
+
return assignment;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
markTerminal(sessionId: string, status: "blocked" | "abandoned", reason: string): Assignment {
|
|
374
|
+
const state = this.read();
|
|
375
|
+
const assignment = this.requiredFocusedAssignment(state, sessionId);
|
|
376
|
+
if (!["active", "handoff-submitted"].includes(assignment.status)) {
|
|
377
|
+
throw new Error(`Cannot mark ${assignment.status} as ${status}`);
|
|
378
|
+
}
|
|
379
|
+
this.transition(state, assignment, status, reason,
|
|
380
|
+
status === "blocked" ? "Coordinator decision or dependency required" : "Explicit follow-up or a new assignment is required",
|
|
381
|
+
status === "blocked" ? "assignment_blocked" : "assignment_abandoned",
|
|
382
|
+
undefined,
|
|
383
|
+
status === "blocked" ? "blocker" : "progress");
|
|
384
|
+
this.write(state);
|
|
385
|
+
return assignment;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
getRole(sessionId: string): SessionRole {
|
|
389
|
+
return this.roleForState(this.read(), sessionId);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
presenceFor(sessionId: string, referenceTime = Date.now()): (Omit<SessionPresence, "status"> & { status: PresenceStatus | "stale" }) | undefined {
|
|
393
|
+
const presence = this.read().presence?.find((item) => item.sessionId === sessionId);
|
|
394
|
+
if (!presence) return undefined;
|
|
395
|
+
return Date.parse(presence.lastSeenAt) + PRESENCE_STALE_AFTER_MS < referenceTime
|
|
396
|
+
? { ...presence, status: "stale" }
|
|
397
|
+
: presence;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
observePresence(sessionId: string, status: PresenceStatus, lastShutdownReason?: string, emitEvent = true): void {
|
|
401
|
+
const state = this.read();
|
|
402
|
+
const presence = state.presence ?? (state.presence = []);
|
|
403
|
+
const index = presence.findIndex((item) => item.sessionId === sessionId);
|
|
404
|
+
const previous = index === -1 ? undefined : presence[index];
|
|
405
|
+
const next: SessionPresence = { sessionId, status, lastSeenAt: now(), lastShutdownReason };
|
|
406
|
+
const previousEffective = previous ? this.effectivePresence(previous, Date.now()) : undefined;
|
|
407
|
+
if (index === -1) presence.push(next);
|
|
408
|
+
else presence[index] = next;
|
|
409
|
+
if (emitEvent && previousEffective !== status) {
|
|
410
|
+
const assignment = [...state.assignments].reverse().find((item) => item.focusedSessionId === sessionId);
|
|
411
|
+
this.recordEvent(state, assignment?.id ?? "", "progress", `Session presence changed to ${status}`, undefined,
|
|
412
|
+
{ status, previousStatus: previousEffective }, "presence_changed", sessionId);
|
|
413
|
+
}
|
|
414
|
+
this.write(state);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Consume only explicit events relevant to this session and persist its cursor. */
|
|
418
|
+
consumeRelevantEvents(sessionId: string, limit = 8): OrchestrationEvent[] {
|
|
419
|
+
const state = this.read();
|
|
420
|
+
const cursors = state.eventCursors ?? (state.eventCursors = {});
|
|
421
|
+
const cursor: EventCursor = cursors[sessionId] ?? { lastSequence: 0, eventIds: [] };
|
|
422
|
+
const ordered = state.events.map((event, index) => ({ event, sequence: event.sequence ?? index + 1 }))
|
|
423
|
+
.sort((left, right) => left.sequence - right.sequence);
|
|
424
|
+
const relevant = ordered.filter(({ event, sequence }) => this.eventRelevant(state, event, sessionId)
|
|
425
|
+
&& !cursor.eventIds.includes(event.id) && sequence > cursor.lastSequence);
|
|
426
|
+
const boundedItems = relevant.slice(0, Math.max(1, Math.min(limit, 20)));
|
|
427
|
+
const bounded = boundedItems.map(({ event }) => event);
|
|
428
|
+
cursor.lastSequence = boundedItems.at(-1)?.sequence ?? ordered.at(-1)?.sequence ?? cursor.lastSequence;
|
|
429
|
+
cursor.eventIds = [...new Set([...cursor.eventIds, ...bounded.map((event) => event.id)])].slice(-MAX_CURSOR_EVENT_IDS);
|
|
430
|
+
cursors[sessionId] = cursor;
|
|
431
|
+
this.write(state);
|
|
432
|
+
return bounded;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
workflowLedger(sessionId: string, limit = 20): Record<string, unknown> {
|
|
436
|
+
const state = this.read();
|
|
437
|
+
let rootSessionId = sessionId;
|
|
438
|
+
const visited = new Set<string>();
|
|
439
|
+
while (!visited.has(rootSessionId)) {
|
|
440
|
+
visited.add(rootSessionId);
|
|
441
|
+
const parent = [...state.assignments].reverse().find((assignment) => assignment.focusedSessionId === rootSessionId);
|
|
442
|
+
if (!parent) break;
|
|
443
|
+
rootSessionId = parent.coordinatorSessionId;
|
|
444
|
+
}
|
|
445
|
+
const assignments: Assignment[] = [];
|
|
446
|
+
const coordinators = [rootSessionId];
|
|
447
|
+
while (coordinators.length) {
|
|
448
|
+
const coordinator = coordinators.shift()!;
|
|
449
|
+
for (const assignment of state.assignments.filter((item) => item.coordinatorSessionId === coordinator)) {
|
|
450
|
+
assignments.push(assignment);
|
|
451
|
+
if (assignment.targetRole === "domain-coordinator") coordinators.push(assignment.focusedSessionId);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const assignmentIds = new Set(assignments.map((assignment) => assignment.id));
|
|
455
|
+
const boundedLimit = Math.max(1, Math.min(limit, 50));
|
|
456
|
+
const assignmentItems = assignments.map((assignment) => ({
|
|
457
|
+
id: assignment.id,
|
|
458
|
+
sessionId: assignment.focusedSessionId,
|
|
459
|
+
role: assignment.targetRole ?? "focused-session",
|
|
460
|
+
objective: assignment.objective,
|
|
461
|
+
allowedScope: assignment.allowedScope,
|
|
462
|
+
scopeRevision: this.assignmentScopeRevision(assignment),
|
|
463
|
+
status: assignment.status,
|
|
464
|
+
}));
|
|
465
|
+
const scopeChanges = this.scopeChanges(state);
|
|
466
|
+
const scopeChangeItems = {
|
|
467
|
+
proposals: scopeChanges.proposals.filter((proposal) => assignmentIds.has(proposal.assignmentId)),
|
|
468
|
+
reconciliations: scopeChanges.reconciliations.filter((reconciliation) => assignmentIds.has(reconciliation.assignmentId)),
|
|
469
|
+
};
|
|
470
|
+
const eventItems = state.events.filter((event) => assignmentIds.has(event.assignmentId));
|
|
471
|
+
const handoffItems = state.handoffs.filter((handoff) => assignmentIds.has(handoff.assignmentId));
|
|
472
|
+
const presenceItems = (state.presence ?? [])
|
|
473
|
+
.filter((presence) => presence.sessionId === rootSessionId || assignments.some((assignment) => assignment.focusedSessionId === presence.sessionId))
|
|
474
|
+
.map((presence) => this.presenceFor(presence.sessionId)!)
|
|
475
|
+
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt));
|
|
476
|
+
return {
|
|
477
|
+
rootSessionId,
|
|
478
|
+
limit: boundedLimit,
|
|
479
|
+
lifecycle: assignments.reduce<Record<string, number>>((counts, assignment) => ({ ...counts, [assignment.status]: (counts[assignment.status] ?? 0) + 1 }), {}),
|
|
480
|
+
assignments: boundedLedgerItems(assignmentItems, boundedLimit),
|
|
481
|
+
events: boundedLedgerItems(eventItems, boundedLimit),
|
|
482
|
+
handoffs: boundedLedgerItems(handoffItems, boundedLimit),
|
|
483
|
+
presence: boundedLedgerItems(presenceItems, boundedLimit),
|
|
484
|
+
scopeChanges: {
|
|
485
|
+
proposals: boundedLedgerItems(scopeChangeItems.proposals, boundedLimit),
|
|
486
|
+
reconciliations: boundedLedgerItems(scopeChangeItems.reconciliations, boundedLimit),
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
activeAssignments(coordinatorSessionId: string): Assignment[] {
|
|
492
|
+
return this.read().assignments.filter((assignment) => assignment.coordinatorSessionId === coordinatorSessionId
|
|
493
|
+
&& PENDING_STATUSES.includes(assignment.status));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private scopeChanges(state: DurableState): ScopeChangeState {
|
|
497
|
+
return state.scopeChanges ?? (state.scopeChanges = { version: 1, proposals: [], reconciliations: [] });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private assignmentScopeRevision(assignment: Assignment): number {
|
|
501
|
+
return assignment.scopeRevision ?? 1;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private applyScopeChange(sessionId: string, assignmentId: string | undefined, proposalId: string, baseScopeRevision: number,
|
|
505
|
+
approvalKind: ScopeChangeApprovalKind): Assignment {
|
|
506
|
+
const state = this.read();
|
|
507
|
+
const scopeChanges = this.scopeChanges(state);
|
|
508
|
+
const proposal = scopeChanges.proposals.find((item) => item.id === proposalId);
|
|
509
|
+
if (!proposal) throw new Error("Unknown scope-change proposal");
|
|
510
|
+
const assignment = this.assignment(state, proposal.assignmentId);
|
|
511
|
+
if (assignmentId !== undefined && assignment.id !== assignmentId) throw new Error("Scope-change proposal assignment does not match");
|
|
512
|
+
if (approvalKind === "coordinator") {
|
|
513
|
+
if (assignment.coordinatorSessionId !== sessionId || !state.coordinators.includes(sessionId)) {
|
|
514
|
+
throw new Error("Only the assignment coordinator can approve a scope change");
|
|
515
|
+
}
|
|
516
|
+
} else if (assignment.focusedSessionId !== sessionId) {
|
|
517
|
+
throw new Error("Only the assigned focused session can record direct human approval");
|
|
518
|
+
}
|
|
519
|
+
this.assertScopeChangeLifecycle(assignment);
|
|
520
|
+
if (proposal.status !== "pending") throw new Error("Scope-change proposal is no longer pending");
|
|
521
|
+
const currentRevision = this.assignmentScopeRevision(assignment);
|
|
522
|
+
if (proposal.baseScopeRevision !== baseScopeRevision || currentRevision !== baseScopeRevision) {
|
|
523
|
+
throw new Error("Scope-change proposal uses a stale scope revision");
|
|
524
|
+
}
|
|
525
|
+
assignment.allowedScope = proposal.requestedScope;
|
|
526
|
+
assignment.scopeRevision = currentRevision + 1;
|
|
527
|
+
assignment.updatedAt = now();
|
|
528
|
+
proposal.status = "approved";
|
|
529
|
+
proposal.updatedAt = assignment.updatedAt;
|
|
530
|
+
proposal.approvalKind = approvalKind;
|
|
531
|
+
proposal.approvedBy = approvalKind === "coordinator" ? sessionId : "human";
|
|
532
|
+
const action = approvalKind === "coordinator" ? "scope_change_approved" : "scope_change_human_approved";
|
|
533
|
+
this.recordEvent(state, assignment.id, "progress", approvalKind === "coordinator" ? "Coordinator approved scope change" : "Direct human approved scope change",
|
|
534
|
+
approvalKind === "coordinator" ? undefined : "Assignment coordinator must reconcile the direct-human scope change", {
|
|
535
|
+
proposalId, scopeRevision: assignment.scopeRevision, requestedScope: assignment.allowedScope,
|
|
536
|
+
}, action, sessionId);
|
|
537
|
+
if (approvalKind === "direct-human") {
|
|
538
|
+
const timestamp = now();
|
|
539
|
+
scopeChanges.reconciliations.push({
|
|
540
|
+
id: randomUUID(), assignmentId: assignment.id, scopeRevision: assignment.scopeRevision,
|
|
541
|
+
requestedScope: proposal.requestedScope, reason: proposal.reason, evidence: proposal.evidence,
|
|
542
|
+
status: "pending", createdAt: timestamp, updatedAt: timestamp,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
this.write(state);
|
|
546
|
+
return assignment;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private coordinatorRole(state: DurableState, sessionId: string): "root-coordinator" | "domain-coordinator" | undefined {
|
|
550
|
+
if (!state.coordinators.includes(sessionId)) return undefined;
|
|
551
|
+
const role = this.roleForState(state, sessionId);
|
|
552
|
+
return role.kind === "domain-coordinator" ? "domain-coordinator" : "root-coordinator";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private roleForState(state: DurableState, sessionId: string): SessionRole {
|
|
556
|
+
const assignment = this.focusedAssignment(state, sessionId);
|
|
557
|
+
if (assignment?.targetRole === "domain-coordinator") return { kind: "domain-coordinator", assignment };
|
|
558
|
+
if (assignment) return { kind: "focused-session", assignment };
|
|
559
|
+
return state.coordinators.includes(sessionId) ? { kind: "root-coordinator" } : { kind: "unmanaged" };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private focusedAssignment(state: DurableState, sessionId: string): Assignment | undefined {
|
|
563
|
+
return [...state.assignments].reverse().find((assignment) => assignment.focusedSessionId === sessionId
|
|
564
|
+
&& assignment.status !== "created");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private requiredFocusedAssignment(state: DurableState, sessionId: string): Assignment {
|
|
568
|
+
const assignment = this.focusedAssignment(state, sessionId);
|
|
569
|
+
if (!assignment) throw new Error("This session has no valid attached focused assignment");
|
|
570
|
+
return assignment;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private assignment(state: DurableState, assignmentId: string): Assignment {
|
|
574
|
+
const assignment = state.assignments.find((item) => item.id === assignmentId);
|
|
575
|
+
if (!assignment) throw new Error("Unknown assignment");
|
|
576
|
+
return assignment;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private assertStatus(assignment: Assignment, expected: AssignmentStatus): void {
|
|
580
|
+
if (assignment.status !== expected) throw new Error(`Expected ${expected}, found ${assignment.status}`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private assertScopeChangeLifecycle(assignment: Assignment): void {
|
|
584
|
+
if (!SCOPE_CHANGE_ALLOWED_STATUSES.includes(assignment.status)) {
|
|
585
|
+
throw new Error(`Scope changes require an attached, active, or handoff-submitted assignment; found ${assignment.status}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private transition(state: DurableState, assignment: Assignment, status: AssignmentStatus, summary: string, nextAction?: string,
|
|
590
|
+
action: OrchestrationEventAction = "assignment_started", details?: Record<string, unknown>, type: EventType = "progress"): void {
|
|
591
|
+
assignment.status = status;
|
|
592
|
+
assignment.updatedAt = now();
|
|
593
|
+
this.recordEvent(state, assignment.id, type, summary, nextAction, { state: status, ...details }, action, assignment.focusedSessionId);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private recordEvent(state: DurableState, assignmentId: string, type: EventType, summary: string, nextAction?: string,
|
|
597
|
+
details?: Record<string, unknown>, action?: OrchestrationEventAction, sessionId?: string): OrchestrationEvent {
|
|
598
|
+
const nextSequence = Math.max(state.nextEventSequence ?? 1,
|
|
599
|
+
...state.events.map((event) => event.sequence ?? 0),
|
|
600
|
+
...Object.values(state.eventCursors ?? {}).map((cursor) => cursor.lastSequence + 1));
|
|
601
|
+
const event: OrchestrationEvent = {
|
|
602
|
+
id: randomUUID(), assignmentId, type, action, sessionId, sequence: nextSequence, summary, createdAt: now(), nextAction, details,
|
|
603
|
+
};
|
|
604
|
+
state.nextEventSequence = nextSequence + 1;
|
|
605
|
+
state.events.push(event);
|
|
606
|
+
return event;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private effectivePresence(presence: SessionPresence, referenceTime: number): PresenceStatus | "stale" {
|
|
610
|
+
return Date.parse(presence.lastSeenAt) + PRESENCE_STALE_AFTER_MS < referenceTime ? "stale" : presence.status;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private eventRelevant(state: DurableState, event: OrchestrationEvent, sessionId: string): boolean {
|
|
614
|
+
if (event.sessionId === sessionId) return true;
|
|
615
|
+
if (!event.assignmentId) return false;
|
|
616
|
+
const assignmentIds = new Set<string>();
|
|
617
|
+
const queue = [sessionId];
|
|
618
|
+
while (queue.length) {
|
|
619
|
+
const owner = queue.shift()!;
|
|
620
|
+
for (const assignment of state.assignments.filter((item) => item.coordinatorSessionId === owner || item.focusedSessionId === owner)) {
|
|
621
|
+
if (assignmentIds.has(assignment.id)) continue;
|
|
622
|
+
assignmentIds.add(assignment.id);
|
|
623
|
+
if (assignment.coordinatorSessionId === owner && assignment.targetRole === "domain-coordinator") queue.push(assignment.focusedSessionId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return assignmentIds.has(event.assignmentId);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private write(state: DurableState): void {
|
|
630
|
+
state.events = state.events.slice(-MAX_EVENT_LEDGER);
|
|
631
|
+
if (state.eventCursors) {
|
|
632
|
+
for (const cursor of Object.values(state.eventCursors)) cursor.eventIds = cursor.eventIds.slice(-MAX_CURSOR_EVENT_IDS);
|
|
633
|
+
}
|
|
634
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
635
|
+
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
636
|
+
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
637
|
+
renameSync(temporary, this.path);
|
|
638
|
+
}
|
|
639
|
+
}
|