@zachwill/pi-orchestrate 0.8.0 → 0.9.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.
@@ -173,105 +173,100 @@ function fieldValue(frontmatter: unknown, field: string): unknown {
173
173
  return field in frontmatter ? frontmatter[field] : undefined;
174
174
  }
175
175
 
176
+ function hasIssuePath(issuePaths: readonly IssuePath[], ...path: readonly PropertyKey[]): boolean {
177
+ return issuePaths.some((entry) => path.every((key, index) => entry.path[index] === key));
178
+ }
179
+
180
+ function unexpectedFieldsAt(
181
+ issuePaths: readonly IssuePath[],
182
+ parentPath: readonly PropertyKey[],
183
+ ): string[] {
184
+ return issuePaths
185
+ .filter(({ path, issue }) =>
186
+ issue._tag === "UnexpectedKey" &&
187
+ path.length === parentPath.length + 1 &&
188
+ parentPath.every((key, index) => path[index] === key) &&
189
+ typeof path[parentPath.length] === "string"
190
+ )
191
+ .map(({ path }) => String(path[parentPath.length]))
192
+ .sort(compareText);
193
+ }
194
+
195
+ function unknownFieldsDiagnostic(scope: string, fields: readonly string[]): string {
196
+ return `unknown ${scope} field${fields.length === 1 ? "" : "s"}: ${fields.join(", ")}`;
197
+ }
198
+
176
199
  function listItems(value: unknown): readonly unknown[] {
177
200
  if (typeof value === "string") return value.split(",").map((item) => item.trim());
178
201
  return Array.isArray(value) ? value : [];
179
202
  }
180
203
 
204
+ const ORDERED_FRONTMATTER_FIELDS = [
205
+ "name", "description", "model", "thinking", "tools", "skills", "compaction", "lifecycle",
206
+ ] as const;
207
+
208
+ const FIXED_FIELD_DIAGNOSTICS: Partial<Record<(typeof ORDERED_FRONTMATTER_FIELDS)[number], string>> = {
209
+ name: "frontmatter field 'name' must be a non-empty string",
210
+ description: "frontmatter field 'description' must be a non-empty string",
211
+ skills: "frontmatter field 'skills' must be a comma string or string array",
212
+ lifecycle: "frontmatter field 'lifecycle' must be 'one-shot' or 'interactive'",
213
+ };
214
+
215
+ const COMPACTION_FIELD_DIAGNOSTICS = [
216
+ ["enabled", "frontmatter field 'compaction.enabled' must be a boolean"],
217
+ ["reserveTokens", "frontmatter field 'compaction.reserveTokens' must be a non-negative integer"],
218
+ ["keepRecentTokens", "frontmatter field 'compaction.keepRecentTokens' must be a non-negative integer"],
219
+ ] as const;
220
+
181
221
  function schemaDiagnostic(issue: SchemaIssue.Issue, frontmatter: unknown): string {
182
222
  const issuePaths = collectIssuePaths(issue);
183
- const unexpected = issuePaths.filter(({ issue }) => issue._tag === "UnexpectedKey");
184
- const frontmatterFields = unexpected
185
- .filter(({ path }) => path.length === 1 && typeof path[0] === "string")
186
- .map(({ path }) => String(path[0]))
187
- .sort(compareText);
223
+ const frontmatterFields = unexpectedFieldsAt(issuePaths, []);
188
224
  if (frontmatterFields.length > 0) {
189
- return `unknown frontmatter field${frontmatterFields.length === 1 ? "" : "s"}: ${frontmatterFields.join(", ")}`;
225
+ return unknownFieldsDiagnostic("frontmatter", frontmatterFields);
190
226
  }
227
+ if (!isUnknownRecord(frontmatter)) return "frontmatter must be a mapping";
191
228
 
192
- const compactionFields = unexpected
193
- .filter(
194
- ({ path }) => path.length === 2 && path[0] === "compaction" && typeof path[1] === "string",
195
- )
196
- .map(({ path }) => String(path[1]))
197
- .sort(compareText);
198
-
199
- if (typeof frontmatter !== "object" || frontmatter === null || Array.isArray(frontmatter)) {
200
- return "frontmatter must be a mapping";
201
- }
202
-
203
- const orderedFields = [
204
- "name",
205
- "description",
206
- "model",
207
- "thinking",
208
- "tools",
209
- "skills",
210
- "compaction",
211
- "lifecycle",
212
- ];
213
- const field = orderedFields.find((candidate) =>
214
- issuePaths.some(({ path }) => path[0] === candidate)
229
+ const field = ORDERED_FRONTMATTER_FIELDS.find((candidate) =>
230
+ hasIssuePath(issuePaths, candidate)
215
231
  );
216
- const value = field === undefined ? undefined : fieldValue(frontmatter, field);
232
+ if (field === undefined) return "invalid worker definition";
217
233
 
218
- if (field === "name" || field === "description") {
219
- return `frontmatter field '${field}' must be a non-empty string`;
220
- }
234
+ const fixedDiagnostic = FIXED_FIELD_DIAGNOSTICS[field];
235
+ if (fixedDiagnostic !== undefined) return fixedDiagnostic;
236
+
237
+ const value = fieldValue(frontmatter, field);
221
238
  if (field === "model") {
222
- if (typeof value !== "string" || value.trim() === "") {
223
- return "frontmatter field 'model' must be a non-empty string";
224
- }
225
- return "frontmatter field 'model' must use provider/model format";
239
+ return typeof value !== "string" || value.trim() === ""
240
+ ? "frontmatter field 'model' must be a non-empty string"
241
+ : "frontmatter field 'model' must use provider/model format";
226
242
  }
227
243
  if (field === "thinking") {
228
- if (typeof value !== "string" || value.trim() === "") {
229
- return "frontmatter field 'thinking' must be a non-empty string";
230
- }
231
- return `unsupported thinking level '${value.trim()}'`;
244
+ return typeof value !== "string" || value.trim() === ""
245
+ ? "frontmatter field 'thinking' must be a non-empty string"
246
+ : `unsupported thinking level '${value.trim()}'`;
232
247
  }
233
248
  if (field === "tools") {
234
249
  const items = listItems(value);
235
250
  const validList = items.length > 0 && items.every(
236
251
  (item) => typeof item === "string" && item !== "",
237
252
  );
238
- const unsupported = validList ? items.find(
239
- (item) => typeof item === "string" && !isSupportedToolName(item),
240
- ) : undefined;
253
+ const unsupported = validList
254
+ ? items.find((item) => typeof item === "string" && !isSupportedToolName(item))
255
+ : undefined;
241
256
  if (typeof unsupported === "string") return `unsupported tool '${unsupported}'`;
242
- if (value === undefined) return "frontmatter field 'tools' is required";
243
- return "frontmatter field 'tools' must be a non-empty comma string or string array";
244
- }
245
- if (field === "skills") {
246
- return "frontmatter field 'skills' must be a comma string or string array";
257
+ return value === undefined
258
+ ? "frontmatter field 'tools' is required"
259
+ : "frontmatter field 'tools' must be a non-empty comma string or string array";
247
260
  }
248
- if (field === "compaction") {
249
- if (compactionFields.length > 0) {
250
- return `unknown compaction field${compactionFields.length === 1 ? "" : "s"}: ${compactionFields.join(", ")}`;
251
- }
252
- if (issuePaths.some(({ path }) => path[0] === "compaction" && path[1] === "enabled")) {
253
- return "frontmatter field 'compaction.enabled' must be a boolean";
254
- }
255
- if (
256
- issuePaths.some(({ path }) =>
257
- path[0] === "compaction" && path[1] === "reserveTokens"
258
- )
259
- ) {
260
- return "frontmatter field 'compaction.reserveTokens' must be a non-negative integer";
261
- }
262
- if (
263
- issuePaths.some(({ path }) =>
264
- path[0] === "compaction" && path[1] === "keepRecentTokens"
265
- )
266
- ) {
267
- return "frontmatter field 'compaction.keepRecentTokens' must be a non-negative integer";
268
- }
269
- return "frontmatter field 'compaction' must be a mapping";
270
- }
271
- if (field === "lifecycle") {
272
- return "frontmatter field 'lifecycle' must be 'one-shot' or 'interactive'";
261
+
262
+ const compactionFields = unexpectedFieldsAt(issuePaths, ["compaction"]);
263
+ if (compactionFields.length > 0) {
264
+ return unknownFieldsDiagnostic("compaction", compactionFields);
273
265
  }
274
- return "invalid worker definition";
266
+ const nestedDiagnostic = COMPACTION_FIELD_DIAGNOSTICS.find(([nestedField]) =>
267
+ hasIssuePath(issuePaths, "compaction", nestedField)
268
+ );
269
+ return nestedDiagnostic?.[1] ?? "frontmatter field 'compaction' must be a mapping";
275
270
  }
276
271
 
277
272
  function parseWorker(
@@ -1,4 +1,6 @@
1
- import type { WorkerSettlement } from "./runtime.js";
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { Orchestration } from "./runtime.js";
3
+ import type { WorkerSettlement } from "./worker-settlement.js";
2
4
 
3
5
  export const MAX_DELIVERY_MARKDOWN_BYTES = 50 * 1024;
4
6
  export const MAX_WORKER_DELIVERY_MARKDOWN_BYTES = 16 * 1024;
@@ -38,14 +40,33 @@ interface BoundParent {
38
40
  interface SynthesisGroupState {
39
41
  expected: number;
40
42
  readonly acceptedEventIds: string[];
43
+ finalEventId?: string;
41
44
  }
42
45
 
43
- export class DeliveryCoordinator {
46
+ export interface DeliveryService {
47
+ bind(binding: ParentBinding): void;
48
+ unbind(ownerSessionId: string, generation: ParentBindingGeneration): void;
49
+ markAgentStarted(ownerSessionId: string, generation: ParentBindingGeneration): void;
50
+ markAgentSettled(ownerSessionId: string, generation: ParentBindingGeneration): void;
51
+ accept(settlement: WorkerSettlement): boolean;
52
+ skipSynthesisGroupMember(
53
+ ownerSessionId: string,
54
+ synthesisGroupId: string,
55
+ synthesisGroupSize: number,
56
+ ): void;
57
+ pendingCount(ownerSessionId: string): number;
58
+ clear(): void;
59
+ }
60
+
61
+ export class Delivery extends Context.Service<Delivery, DeliveryService>()(
62
+ "@zachwill/pi-orchestrate/Delivery",
63
+ ) {}
64
+
65
+ export class DeliveryCoordinator implements DeliveryService {
44
66
  private readonly boundParents = new Map<string, BoundParent>();
45
67
  private readonly pendingSettlements: WorkerSettlement[] = [];
46
68
  private readonly flushingOwners = new Set<string>();
47
69
  private readonly synthesisGroups = new Map<string, SynthesisGroupState>();
48
- private readonly finalSynthesisGroupEventIds = new Set<string>();
49
70
  private highestAcceptedSequence = 0;
50
71
 
51
72
  bind(binding: ParentBinding): void {
@@ -115,7 +136,6 @@ export class DeliveryCoordinator {
115
136
  this.pendingSettlements.length = 0;
116
137
  this.flushingOwners.clear();
117
138
  this.synthesisGroups.clear();
118
- this.finalSynthesisGroupEventIds.clear();
119
139
  this.highestAcceptedSequence = 0;
120
140
  }
121
141
 
@@ -240,12 +260,15 @@ export class DeliveryCoordinator {
240
260
  }
241
261
  if (state.acceptedEventIds.length !== state.expected) return;
242
262
  const finalEventId = state.acceptedEventIds.at(-1);
243
- if (finalEventId) this.finalSynthesisGroupEventIds.add(finalEventId);
263
+ if (finalEventId) state.finalEventId = finalEventId;
244
264
  }
245
265
 
246
266
  private isFinalBoundary(settlement: WorkerSettlement): boolean {
247
267
  if (!settlement.synthesisGroupId) return true;
248
- return this.finalSynthesisGroupEventIds.has(settlement.eventId);
268
+ const state = this.synthesisGroups.get(
269
+ synthesisGroupKey(settlement.ownerSessionId, settlement.synthesisGroupId),
270
+ );
271
+ return state?.finalEventId === settlement.eventId;
249
272
  }
250
273
 
251
274
  private finishSynthesisGroup(settlement: WorkerSettlement): void {
@@ -253,10 +276,29 @@ export class DeliveryCoordinator {
253
276
  this.synthesisGroups.delete(
254
277
  synthesisGroupKey(settlement.ownerSessionId, settlement.synthesisGroupId),
255
278
  );
256
- this.finalSynthesisGroupEventIds.delete(settlement.eventId);
257
279
  }
258
280
  }
259
281
 
282
+ /** Process-scoped direct delivery state and its Orchestration settlement subscription. */
283
+ export const deliveryLayer: Layer.Layer<Delivery, never, Orchestration> = Layer.effect(
284
+ Delivery,
285
+ Effect.gen(function* () {
286
+ const orchestration = yield* Orchestration;
287
+ const coordinator = new DeliveryCoordinator();
288
+
289
+ // Registered first so subscription release runs before delivery state is cleared.
290
+ yield* Effect.addFinalizer(() => Effect.sync(() => coordinator.clear()));
291
+ yield* Effect.acquireRelease(
292
+ Effect.sync(() => orchestration.subscribeSettlement((settlement) => {
293
+ coordinator.accept(settlement);
294
+ })),
295
+ (unsubscribe) => Effect.sync(unsubscribe),
296
+ );
297
+
298
+ return Delivery.of(coordinator);
299
+ }),
300
+ );
301
+
260
302
  function synthesisGroupKey(ownerSessionId: string, synthesisGroupId: string): string {
261
303
  return `${ownerSessionId}\u0000${synthesisGroupId}`;
262
304
  }
@@ -1,4 +1,5 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import { Schema } from "effect";
2
3
 
3
4
  export const SUPPORTED_TOOL_NAMES = [
4
5
  "read",
@@ -88,17 +89,36 @@ function compareWorkersByName(left: WorkerDefinition, right: WorkerDefinition):
88
89
  return 0;
89
90
  }
90
91
 
91
- export interface OrchestrateTaskInput {
92
- readonly worker: string;
93
- readonly title: string;
94
- readonly instructions: string;
95
- }
92
+ export const MAX_WORKER_TITLE_LENGTH = 200;
93
+ export const MAX_WORKER_INSTRUCTIONS_LENGTH = 100_000;
94
+ export const CANCELLATION_GRACE_MS = 5_000;
96
95
 
97
- declare const workerIdBrand: unique symbol;
98
- declare const runIdBrand: unique symbol;
96
+ const NonBlankString = Schema.String.check(Schema.isPattern(/\S/));
97
+ const WorkerLabel = NonBlankString.check(
98
+ Schema.isMaxLength(MAX_WORKER_TITLE_LENGTH),
99
+ );
100
+ const WorkerInstructions = NonBlankString.check(
101
+ Schema.isMaxLength(MAX_WORKER_INSTRUCTIONS_LENGTH),
102
+ );
103
+
104
+ export const OrchestrateTaskInput = Schema.Struct({
105
+ worker: WorkerLabel,
106
+ title: WorkerLabel,
107
+ instructions: WorkerInstructions,
108
+ });
109
+ export interface OrchestrateTaskInput extends Schema.Schema.Type<typeof OrchestrateTaskInput> {}
99
110
 
100
- export type WorkerId = string & { readonly [workerIdBrand]: "WorkerId" };
101
- export type RunId = string & { readonly [runIdBrand]: "RunId" };
111
+ /** A validated worker identity. Worker IDs are stable across interactive generations. */
112
+ export const WorkerId = Schema.String.check(
113
+ Schema.isPattern(/^worker-\S+$/),
114
+ ).pipe(Schema.brand("WorkerId"));
115
+ export type WorkerId = typeof WorkerId.Type;
116
+
117
+ /** A validated identity for exactly one worker generation. */
118
+ export const RunId = Schema.String.check(
119
+ Schema.isPattern(/^run-\S+$/),
120
+ ).pipe(Schema.brand("RunId"));
121
+ export type RunId = typeof RunId.Type;
102
122
 
103
123
  export type WorkerIdFactory = () => WorkerId;
104
124
  export type RunIdFactory = () => RunId;
@@ -111,13 +131,13 @@ export interface OrchestrateIdFactories {
111
131
  export function createRandomWorkerIdFactory(
112
132
  randomId: () => string = defaultRandomId,
113
133
  ): WorkerIdFactory {
114
- return () => `worker-${randomId()}` as WorkerId;
134
+ return () => WorkerId.make(`worker-${randomId()}`);
115
135
  }
116
136
 
117
137
  export function createRandomRunIdFactory(
118
138
  randomId: () => string = defaultRandomId,
119
139
  ): RunIdFactory {
120
- return () => `run-${randomId()}` as RunId;
140
+ return () => RunId.make(`run-${randomId()}`);
121
141
  }
122
142
 
123
143
  export function createRandomIdFactories(
@@ -131,12 +151,12 @@ export function createRandomIdFactories(
131
151
 
132
152
  export function createSequentialWorkerIdFactory(startAt = 1): WorkerIdFactory {
133
153
  let next = startAt;
134
- return () => `worker-${next++}` as WorkerId;
154
+ return () => WorkerId.make(`worker-${next++}`);
135
155
  }
136
156
 
137
157
  export function createSequentialRunIdFactory(startAt = 1): RunIdFactory {
138
158
  let next = startAt;
139
- return () => `run-${next++}` as RunId;
159
+ return () => RunId.make(`run-${next++}`);
140
160
  }
141
161
 
142
162
  export function createSequentialIdFactories(startAt = 1): OrchestrateIdFactories {
@@ -150,15 +170,23 @@ function defaultRandomId(): string {
150
170
  return globalThis.crypto.randomUUID();
151
171
  }
152
172
 
153
- export interface WorkerUsage {
154
- readonly input: number;
155
- readonly output: number;
156
- readonly cacheRead: number;
157
- readonly cacheWrite: number;
158
- readonly cost: number;
159
- readonly contextTokens: number;
160
- readonly turns: number;
161
- }
173
+ const NonnegativeFinite = Schema.Finite.check(
174
+ Schema.isGreaterThanOrEqualTo(0),
175
+ );
176
+ const NonnegativeInteger = Schema.Int.check(
177
+ Schema.isGreaterThanOrEqualTo(0),
178
+ );
179
+
180
+ export const WorkerUsage = Schema.Struct({
181
+ input: NonnegativeFinite,
182
+ output: NonnegativeFinite,
183
+ cacheRead: NonnegativeFinite,
184
+ cacheWrite: NonnegativeFinite,
185
+ cost: NonnegativeFinite,
186
+ contextTokens: NonnegativeFinite,
187
+ turns: NonnegativeInteger,
188
+ });
189
+ export interface WorkerUsage extends Schema.Schema.Type<typeof WorkerUsage> {}
162
190
 
163
191
  /** Direction of the most recent message across the worker/model boundary. */
164
192
  export type WorkerMessageDirection = "to-model" | "from-model";
@@ -173,38 +201,39 @@ export const EMPTY_WORKER_USAGE: WorkerUsage = Object.freeze({
173
201
  turns: 0,
174
202
  });
175
203
 
176
- export interface WorkerCompletedOutcome {
177
- readonly status: "completed";
178
- readonly assistantText: string;
179
- }
180
-
181
- export interface WorkerReadyOutcome {
182
- readonly status: "ready";
183
- readonly assistantText: string;
184
- }
185
-
186
- export interface WorkerFailedOutcome {
187
- readonly status: "failed";
188
- readonly message: string;
189
- readonly assistantText?: string;
190
- }
191
-
192
- export interface WorkerAbortedOutcome {
193
- readonly status: "aborted";
194
- readonly message?: string;
195
- readonly assistantText?: string;
196
- }
197
-
198
- export interface WorkerClosedOutcome {
199
- readonly status: "closed";
200
- }
201
-
202
- export type WorkerOutcome =
203
- | WorkerCompletedOutcome
204
- | WorkerReadyOutcome
205
- | WorkerFailedOutcome
206
- | WorkerAbortedOutcome
207
- | WorkerClosedOutcome;
204
+ export const WorkerCompletedOutcome = Schema.Struct({
205
+ status: Schema.Literal("completed"),
206
+ assistantText: Schema.String,
207
+ });
208
+ export const WorkerReadyOutcome = Schema.Struct({
209
+ status: Schema.Literal("ready"),
210
+ assistantText: Schema.String,
211
+ });
212
+ export const WorkerFailedOutcome = Schema.Struct({
213
+ status: Schema.Literal("failed"),
214
+ message: Schema.String,
215
+ assistantText: Schema.optionalKey(Schema.String),
216
+ });
217
+ export const WorkerAbortedOutcome = Schema.Struct({
218
+ status: Schema.Literal("aborted"),
219
+ message: Schema.optionalKey(Schema.String),
220
+ assistantText: Schema.optionalKey(Schema.String),
221
+ });
222
+ /** Outcomes emitted in response to a worker generation. */
223
+ export const WorkerResponseOutcome = Schema.Union([
224
+ WorkerCompletedOutcome,
225
+ WorkerReadyOutcome,
226
+ WorkerFailedOutcome,
227
+ WorkerAbortedOutcome,
228
+ ]);
229
+ export type WorkerResponseOutcome = typeof WorkerResponseOutcome.Type;
230
+
231
+ /** Domain outcomes include closure, which is not a generation response. */
232
+ export const WorkerOutcome = Schema.Union([
233
+ WorkerResponseOutcome,
234
+ Schema.Struct({ status: Schema.Literal("closed") }),
235
+ ]);
236
+ export type WorkerOutcome = typeof WorkerOutcome.Type;
208
237
  export type WorkerStatus =
209
238
  | "starting"
210
239
  | "running"
@@ -301,7 +330,3 @@ export function transitionWorkerStatus(
301
330
 
302
331
  return { ...worker, status, outcome: undefined };
303
332
  }
304
-
305
- export const MAX_WORKER_TITLE_LENGTH = 200;
306
- export const MAX_WORKER_INSTRUCTIONS_LENGTH = 100_000;
307
- export const CANCELLATION_GRACE_MS = 5_000;