@zachwill/pi-orchestrate 0.9.0 → 0.9.2
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/extension/contract.ts +73 -7
- package/extension/delivery.ts +16 -10
- package/extension/host.ts +3 -7
- package/extension/index.ts +8 -18
- package/extension/presentation.ts +12 -15
- package/extension/runtime.ts +52 -64
- package/extension/tools.ts +94 -28
- package/package.json +1 -1
package/extension/contract.ts
CHANGED
|
@@ -11,6 +11,12 @@ function sortedWorkers(catalog: WorkerCatalog) {
|
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function escapeContractMarkers(value: string): string {
|
|
15
|
+
return value
|
|
16
|
+
.replaceAll(CONTRACT_START, "<!-- pi-orchestrate:contract:start -->")
|
|
17
|
+
.replaceAll(CONTRACT_END, "<!-- pi-orchestrate:contract:end -->");
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
function formatCatalog(catalog: WorkerCatalog): string {
|
|
15
21
|
const workers = sortedWorkers(catalog);
|
|
16
22
|
if (workers.length === 0) return "- No trusted workers are available for this session.";
|
|
@@ -18,11 +24,74 @@ function formatCatalog(catalog: WorkerCatalog): string {
|
|
|
18
24
|
return workers
|
|
19
25
|
.map(
|
|
20
26
|
(worker) =>
|
|
21
|
-
`- \`${worker.name}\` [${worker.source.kind}] (${worker.lifecycle}): ${worker.description}`,
|
|
27
|
+
`- \`${escapeContractMarkers(worker.name)}\` [${worker.source.kind}] (${worker.lifecycle}): ${escapeContractMarkers(worker.description)}`,
|
|
22
28
|
)
|
|
23
29
|
.join("\n");
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
interface ContractMarker {
|
|
33
|
+
readonly start: number;
|
|
34
|
+
readonly end: number;
|
|
35
|
+
readonly kind: "start" | "end";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function contractMarkers(prompt: string): ContractMarker[] {
|
|
39
|
+
const markers: ContractMarker[] = [];
|
|
40
|
+
for (const [value, kind] of [
|
|
41
|
+
[CONTRACT_START, "start"],
|
|
42
|
+
[CONTRACT_END, "end"],
|
|
43
|
+
] as const) {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
while (offset < prompt.length) {
|
|
46
|
+
const start = prompt.indexOf(value, offset);
|
|
47
|
+
if (start < 0) break;
|
|
48
|
+
markers.push({ start, end: start + value.length, kind });
|
|
49
|
+
offset = start + value.length;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return markers.sort((left, right) => left.start - right.start);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function removeContractMarkers(prompt: string): {
|
|
56
|
+
readonly prompt: string;
|
|
57
|
+
readonly insertionOffset?: number;
|
|
58
|
+
} {
|
|
59
|
+
const markers = contractMarkers(prompt);
|
|
60
|
+
if (markers.length === 0) return { prompt };
|
|
61
|
+
|
|
62
|
+
const removed: Array<{ start: number; end: number }> = [];
|
|
63
|
+
const stack: ContractMarker[] = [];
|
|
64
|
+
for (const marker of markers) {
|
|
65
|
+
if (marker.kind === "start") {
|
|
66
|
+
stack.push(marker);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const start = stack.pop();
|
|
70
|
+
if (start && stack.length === 0) removed.push({ start: start.start, end: marker.end });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const marker of markers) {
|
|
74
|
+
if (!removed.some((range) => marker.start >= range.start && marker.end <= range.end)) {
|
|
75
|
+
removed.push({ start: marker.start, end: marker.end });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
removed.sort((left, right) => left.start - right.start);
|
|
79
|
+
|
|
80
|
+
const insertionPoint = markers[0]!.start;
|
|
81
|
+
let insertionOffset = 0;
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
let cleaned = "";
|
|
84
|
+
for (const range of removed) {
|
|
85
|
+
if (range.start < cursor) continue;
|
|
86
|
+
const retained = prompt.slice(cursor, range.start);
|
|
87
|
+
cleaned += retained;
|
|
88
|
+
if (range.start <= insertionPoint) insertionOffset = cleaned.length;
|
|
89
|
+
cursor = range.end;
|
|
90
|
+
}
|
|
91
|
+
cleaned += prompt.slice(cursor);
|
|
92
|
+
return { prompt: cleaned, insertionOffset };
|
|
93
|
+
}
|
|
94
|
+
|
|
26
95
|
function buildContract(catalog: WorkerCatalog): string {
|
|
27
96
|
return `${CONTRACT_START}
|
|
28
97
|
## Pi Orchestrate Contract
|
|
@@ -60,12 +129,9 @@ export function appendOrchestratorContract(
|
|
|
60
129
|
catalog: WorkerCatalog,
|
|
61
130
|
): string {
|
|
62
131
|
const section = buildContract(catalog);
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
if (end >= 0) {
|
|
67
|
-
return `${systemPrompt.slice(0, start)}${section}${systemPrompt.slice(end + CONTRACT_END.length)}`;
|
|
68
|
-
}
|
|
132
|
+
const cleaned = removeContractMarkers(systemPrompt);
|
|
133
|
+
if (cleaned.insertionOffset !== undefined) {
|
|
134
|
+
return `${cleaned.prompt.slice(0, cleaned.insertionOffset)}${section}${cleaned.prompt.slice(cleaned.insertionOffset)}`;
|
|
69
135
|
}
|
|
70
136
|
|
|
71
137
|
const separator =
|
package/extension/delivery.ts
CHANGED
|
@@ -67,6 +67,8 @@ export class DeliveryCoordinator implements DeliveryService {
|
|
|
67
67
|
private readonly pendingSettlements: WorkerSettlement[] = [];
|
|
68
68
|
private readonly flushingOwners = new Set<string>();
|
|
69
69
|
private readonly synthesisGroups = new Map<string, SynthesisGroupState>();
|
|
70
|
+
// Runtime settlement sequences are process-scoped and monotonic across owners,
|
|
71
|
+
// so one watermark is valid.
|
|
70
72
|
private highestAcceptedSequence = 0;
|
|
71
73
|
|
|
72
74
|
bind(binding: ParentBinding): void {
|
|
@@ -83,15 +85,14 @@ export class DeliveryCoordinator implements DeliveryService {
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
markAgentStarted(ownerSessionId: string, generation: ParentBindingGeneration): void {
|
|
86
|
-
if (!this.matchesBinding(ownerSessionId, generation)) return;
|
|
87
88
|
const parent = this.boundParents.get(ownerSessionId);
|
|
88
|
-
if (parent
|
|
89
|
+
if (parent?.binding.generation !== generation) return;
|
|
90
|
+
parent.agentRunning = true;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
markAgentSettled(ownerSessionId: string, generation: ParentBindingGeneration): void {
|
|
92
|
-
if (!this.matchesBinding(ownerSessionId, generation)) return;
|
|
93
94
|
const parent = this.boundParents.get(ownerSessionId);
|
|
94
|
-
if (
|
|
95
|
+
if (parent?.binding.generation !== generation) return;
|
|
95
96
|
parent.agentRunning = false;
|
|
96
97
|
this.flush(ownerSessionId, generation);
|
|
97
98
|
}
|
|
@@ -163,21 +164,23 @@ export class DeliveryCoordinator implements DeliveryService {
|
|
|
163
164
|
|
|
164
165
|
this.flushingOwners.add(ownerSessionId);
|
|
165
166
|
try {
|
|
167
|
+
// Deliver a stable owner-ordered prefix, stopping at the latest complete synthesis boundary.
|
|
166
168
|
const queued = this.pendingSettlements.filter(
|
|
167
169
|
(settlement) => settlement.ownerSessionId === ownerSessionId,
|
|
168
170
|
);
|
|
169
171
|
let latestFinalIndex = -1;
|
|
170
|
-
for (
|
|
171
|
-
|
|
172
|
-
if (settlement && this.isFinalBoundary(settlement)) latestFinalIndex = index;
|
|
172
|
+
for (const [index, settlement] of queued.entries()) {
|
|
173
|
+
if (this.isFinalBoundary(settlement)) latestFinalIndex = index;
|
|
173
174
|
}
|
|
174
175
|
const flushThrough = latestFinalIndex >= 0 ? latestFinalIndex : queued.length - 1;
|
|
175
176
|
let flushBytesRemaining = MAX_DELIVERY_MARKDOWN_BYTES;
|
|
176
177
|
|
|
177
|
-
for (
|
|
178
|
+
for (const [index, settlement] of queued.entries()) {
|
|
179
|
+
if (index > flushThrough) break;
|
|
180
|
+
// Synchronous delivery callbacks can change owner, generation, or idle
|
|
181
|
+
// state before the next send.
|
|
178
182
|
if (!this.canDeliver(ownerSessionId, generation)) return;
|
|
179
|
-
|
|
180
|
-
if (!settlement || !this.pendingSettlements.includes(settlement)) continue;
|
|
183
|
+
if (!this.pendingSettlements.includes(settlement)) continue;
|
|
181
184
|
|
|
182
185
|
const messagesRemaining = flushThrough - index + 1;
|
|
183
186
|
const fairFlushBytes = Math.floor(flushBytesRemaining / messagesRemaining);
|
|
@@ -186,6 +189,8 @@ export class DeliveryCoordinator implements DeliveryService {
|
|
|
186
189
|
fairFlushBytes,
|
|
187
190
|
flushBytesRemaining,
|
|
188
191
|
));
|
|
192
|
+
// Intermediate results add context; only the completed boundary
|
|
193
|
+
// transfers work to a parent turn.
|
|
189
194
|
const triggerTurn = latestFinalIndex >= 0 && index === flushThrough;
|
|
190
195
|
const message = this.renderWorkerMessage(settlement, byteLimit);
|
|
191
196
|
const parent = this.boundParents.get(ownerSessionId);
|
|
@@ -194,6 +199,7 @@ export class DeliveryCoordinator implements DeliveryService {
|
|
|
194
199
|
try {
|
|
195
200
|
parent.binding.sendMessage(message, { triggerTurn });
|
|
196
201
|
} catch {
|
|
202
|
+
// Keep this settlement and the remaining prefix queued for a later retry.
|
|
197
203
|
return;
|
|
198
204
|
}
|
|
199
205
|
|
package/extension/host.ts
CHANGED
|
@@ -79,7 +79,7 @@ interface AttachmentAwareProcessHost extends ProcessHost {
|
|
|
79
79
|
attachments?: Set<ProcessHostAttachment>;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
type ProcessHostLifecycle = "
|
|
82
|
+
type ProcessHostLifecycle = "destroying" | "destroyed";
|
|
83
83
|
|
|
84
84
|
interface OwnedProcessHost extends AttachmentAwareProcessHost {
|
|
85
85
|
readonly effectRuntime?: ManagedRuntime.ManagedRuntime<Orchestration | Delivery, never>;
|
|
@@ -254,11 +254,9 @@ export function createProcessHost(): ProcessHost {
|
|
|
254
254
|
throw new Error("Cannot create a process host while the current host is being destroyed");
|
|
255
255
|
}
|
|
256
256
|
if (existing?.lifecycle === "destroyed") {
|
|
257
|
-
|
|
258
|
-
} else if (existing) {
|
|
259
|
-
existing.lifecycle = "active";
|
|
260
|
-
return existing;
|
|
257
|
+
throw new Error("Cannot create a process host after the current host was destroyed");
|
|
261
258
|
}
|
|
259
|
+
if (existing) return existing;
|
|
262
260
|
|
|
263
261
|
const effectRuntime = ManagedRuntime.make(createProcessApplicationLayer());
|
|
264
262
|
const runtime = createProcessHostRuntimeAdapter(effectRuntime);
|
|
@@ -268,7 +266,6 @@ export function createProcessHost(): ProcessHost {
|
|
|
268
266
|
delivery,
|
|
269
267
|
effectRuntime,
|
|
270
268
|
attachments: new Set(),
|
|
271
|
-
lifecycle: "active",
|
|
272
269
|
};
|
|
273
270
|
global[PROCESS_HOST_KEY] = host;
|
|
274
271
|
return host;
|
|
@@ -304,7 +301,6 @@ export function destroyProcessHost(
|
|
|
304
301
|
const ownedHost = host as OwnedProcessHost;
|
|
305
302
|
if (ownedHost.destroyPromise) return ownedHost.destroyPromise;
|
|
306
303
|
if ((ownedHost.attachments?.size ?? 0) > 0) return Promise.resolve();
|
|
307
|
-
if (ownedHost.lifecycle === "destroyed") return Promise.resolve();
|
|
308
304
|
|
|
309
305
|
ownedHost.lifecycle = "destroying";
|
|
310
306
|
let resolveDestruction!: () => void;
|
package/extension/index.ts
CHANGED
|
@@ -57,7 +57,6 @@ export function createOrchestrationExtension(
|
|
|
57
57
|
let host: ProcessHost | undefined;
|
|
58
58
|
let hostAttachment: ProcessHostAttachment | undefined;
|
|
59
59
|
let statusController: StatusController | undefined;
|
|
60
|
-
let toolsRegistered = false;
|
|
61
60
|
let activeBinding: OwnerBinding | undefined;
|
|
62
61
|
let cachedCatalog: WorkerCatalog | undefined;
|
|
63
62
|
|
|
@@ -76,27 +75,16 @@ export function createOrchestrationExtension(
|
|
|
76
75
|
registerOrchestrationPresentation(pi);
|
|
77
76
|
|
|
78
77
|
pi.on("session_start", (_event, ctx) => {
|
|
79
|
-
if (activeBinding && host && statusController) {
|
|
80
|
-
host.delivery.unbind(
|
|
81
|
-
activeBinding.ownerSessionId,
|
|
82
|
-
activeBinding.generation,
|
|
83
|
-
);
|
|
84
|
-
statusController.unbind(activeBinding.ownerSessionId);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
78
|
host ??= dependencies.getHost?.() ?? createProcessHost();
|
|
88
79
|
statusController ??=
|
|
89
80
|
dependencies.createStatusController?.(host.runtime) ??
|
|
90
81
|
createStatusController(host.runtime);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
toolsRegistered = true;
|
|
99
|
-
}
|
|
82
|
+
registerOrchestrationTools(pi, {
|
|
83
|
+
runtime: host.runtime,
|
|
84
|
+
getCatalog: catalogFor,
|
|
85
|
+
getDispatchDecision: (toolCallId) =>
|
|
86
|
+
dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
|
|
87
|
+
});
|
|
100
88
|
hostAttachment ??= attachProcessHost(host);
|
|
101
89
|
|
|
102
90
|
dispatchDecisions.clear();
|
|
@@ -132,6 +120,8 @@ export function createOrchestrationExtension(
|
|
|
132
120
|
);
|
|
133
121
|
const ownerSessionId = activeBinding?.ownerSessionId;
|
|
134
122
|
if (!ownerSessionId) return;
|
|
123
|
+
// Sole dispatches and homogeneous orchestrate waves detach; mixed tools stay with the
|
|
124
|
+
// current parent turn, while a wave shares one boundary for one later synthesis turn.
|
|
135
125
|
const isOrchestrateGroup =
|
|
136
126
|
toolCalls.length > 1 &&
|
|
137
127
|
toolCalls.every((toolCall) => toolCall.name === "orchestrate");
|
|
@@ -83,8 +83,6 @@ export class StatusController {
|
|
|
83
83
|
private disposed = false;
|
|
84
84
|
private unsubscribeState: (() => void) | undefined;
|
|
85
85
|
private widget: WorkerStatusComponent | undefined;
|
|
86
|
-
private widgetInstalled = false;
|
|
87
|
-
private pendingSnapshot: RuntimeSnapshot | undefined;
|
|
88
86
|
|
|
89
87
|
constructor(private readonly runtime: PresentationRuntime) {}
|
|
90
88
|
|
|
@@ -114,36 +112,35 @@ export class StatusController {
|
|
|
114
112
|
ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, formatFooterStatus(snapshot));
|
|
115
113
|
if (ctx.mode !== "tui") return;
|
|
116
114
|
const active = activeWorkers(snapshot);
|
|
117
|
-
this.pendingSnapshot = snapshot;
|
|
118
115
|
if (active.length === 0) {
|
|
119
|
-
if (this.
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
if (this.widget !== undefined) {
|
|
117
|
+
ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
|
|
118
|
+
this.widget = undefined;
|
|
119
|
+
}
|
|
122
120
|
return;
|
|
123
121
|
}
|
|
124
|
-
if (this.widget) {
|
|
122
|
+
if (this.widget !== undefined) {
|
|
125
123
|
this.widget.update(snapshot);
|
|
126
124
|
return;
|
|
127
125
|
}
|
|
128
|
-
if (this.widgetInstalled) return;
|
|
129
126
|
ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, (tui, theme) => {
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
const widget = new WorkerStatusComponent(snapshot, theme, tui);
|
|
128
|
+
this.widget = widget;
|
|
129
|
+
return widget;
|
|
132
130
|
}, { placement: "aboveEditor" });
|
|
133
|
-
this.widgetInstalled = true;
|
|
134
131
|
}
|
|
135
132
|
|
|
136
133
|
private clearBinding(): void {
|
|
137
134
|
const unsubscribeState = this.unsubscribeState;
|
|
138
135
|
this.unsubscribeState = undefined;
|
|
139
136
|
unsubscribeState?.();
|
|
140
|
-
this.widget = undefined;
|
|
141
|
-
this.pendingSnapshot = undefined;
|
|
142
137
|
const current = this.binding;
|
|
143
138
|
if (!current) return;
|
|
144
139
|
current.ctx.ui.setStatus(ORCHESTRATION_PRESENTATION_KEY, undefined);
|
|
145
|
-
if (current.ctx.mode === "tui" && this.
|
|
146
|
-
|
|
140
|
+
if (current.ctx.mode === "tui" && this.widget !== undefined) {
|
|
141
|
+
current.ctx.ui.setWidget(ORCHESTRATION_PRESENTATION_KEY, undefined);
|
|
142
|
+
this.widget = undefined;
|
|
143
|
+
}
|
|
147
144
|
this.binding = undefined;
|
|
148
145
|
}
|
|
149
146
|
}
|
package/extension/runtime.ts
CHANGED
|
@@ -205,6 +205,7 @@ interface RuntimeWorker {
|
|
|
205
205
|
readonly record: WorkerRecord;
|
|
206
206
|
readonly context: OrchestrationContext;
|
|
207
207
|
readonly definition: WorkerDefinition;
|
|
208
|
+
// Worker-local authority fence; stale asynchronous callbacks must revalidate it.
|
|
208
209
|
readonly generation: number;
|
|
209
210
|
readonly session?: WorkerSessionHandle;
|
|
210
211
|
readonly observationRelease?: () => void;
|
|
@@ -271,9 +272,6 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
271
272
|
effect: Effect.Effect<void, never>,
|
|
272
273
|
) => Fiber.Fiber<void, never>,
|
|
273
274
|
private readonly cleanups: FiberSet.FiberSet<void, never>,
|
|
274
|
-
private readonly runCleanup: (
|
|
275
|
-
effect: Effect.Effect<void, never>,
|
|
276
|
-
) => Fiber.Fiber<void, never>,
|
|
277
275
|
private readonly clock: Clock.Clock,
|
|
278
276
|
private readonly idFactories: OrchestrateIdFactories,
|
|
279
277
|
private readonly shutdownCompletion: Deferred.Deferred<void>,
|
|
@@ -429,34 +427,16 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
429
427
|
};
|
|
430
428
|
|
|
431
429
|
const admission = this.transact((draft) => {
|
|
432
|
-
const
|
|
433
|
-
if (open._tag === "rejected") return { value: open };
|
|
434
|
-
const ownership = ownedWorkerDecision(
|
|
430
|
+
const ready = readyInteractiveDecision(
|
|
435
431
|
draft,
|
|
436
|
-
"sendInteractive",
|
|
437
432
|
context.ownerSessionId,
|
|
438
433
|
validatedWorkerId,
|
|
439
434
|
);
|
|
440
|
-
if (
|
|
441
|
-
|
|
442
|
-
const worker = ownership.value;
|
|
443
|
-
if (
|
|
444
|
-
worker.record.lifecycle !== "interactive" ||
|
|
445
|
-
worker.record.status !== "ready" ||
|
|
446
|
-
!worker.session
|
|
447
|
-
) {
|
|
448
|
-
return {
|
|
449
|
-
value: rejected(
|
|
450
|
-
"sendInteractive",
|
|
451
|
-
"worker-state",
|
|
452
|
-
"interactive_send requires an owned ready interactive worker",
|
|
453
|
-
),
|
|
454
|
-
};
|
|
455
|
-
}
|
|
435
|
+
if (ready._tag === "rejected") return { value: ready };
|
|
456
436
|
if (draft.runs.has(runId)) throw new Error(`Duplicate run ID: ${runId}`);
|
|
457
437
|
|
|
438
|
+
const { worker, session } = ready.value;
|
|
458
439
|
const generation = worker.generation + 1;
|
|
459
|
-
const session = worker.session;
|
|
460
440
|
const runningRecord: WorkerRecord = {
|
|
461
441
|
...transitionWorkerStatus(worker.record, "running"),
|
|
462
442
|
runId,
|
|
@@ -822,6 +802,7 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
822
802
|
),
|
|
823
803
|
);
|
|
824
804
|
const exit = fiber.pollUnsafe();
|
|
805
|
+
// A closed FiberMap rejects admission with an interrupted sentinel.
|
|
825
806
|
if (exit && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
|
|
826
807
|
this.settleGenerationLaunchFailure(workerId, generation);
|
|
827
808
|
}
|
|
@@ -1261,6 +1242,8 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1261
1242
|
});
|
|
1262
1243
|
}
|
|
1263
1244
|
|
|
1245
|
+
// Commit stopping and one shared completion before post-commit physical abort
|
|
1246
|
+
// and generation interruption; every cancellation caller joins it.
|
|
1264
1247
|
private markWorkersStopping(
|
|
1265
1248
|
draft: RuntimeState,
|
|
1266
1249
|
workers: readonly RuntimeWorker[],
|
|
@@ -1305,6 +1288,7 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1305
1288
|
): void {
|
|
1306
1289
|
const fiber = this.runCancellation(this.cancelWorker(workerId, completion));
|
|
1307
1290
|
const exit = fiber.pollUnsafe();
|
|
1291
|
+
// A closed FiberSet rejects admission with an interrupted sentinel.
|
|
1308
1292
|
if (!exit || Exit.isSuccess(exit) || !Cause.hasInterruptsOnly(exit.cause)) return;
|
|
1309
1293
|
|
|
1310
1294
|
const worker = this.current().workers.get(workerId);
|
|
@@ -1461,7 +1445,7 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1461
1445
|
const session = worker.session;
|
|
1462
1446
|
if (session) {
|
|
1463
1447
|
actions.push(runAction(() => {
|
|
1464
|
-
this.
|
|
1448
|
+
this.launchCleanup(
|
|
1465
1449
|
Effect.suspend(() => session.dispose()).pipe(
|
|
1466
1450
|
Effect.catchCause(() => Effect.void),
|
|
1467
1451
|
Effect.uninterruptible,
|
|
@@ -1472,19 +1456,9 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1472
1456
|
return actions;
|
|
1473
1457
|
}
|
|
1474
1458
|
|
|
1475
|
-
private
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
return;
|
|
1479
|
-
}
|
|
1480
|
-
const fiber = this.runCleanup(cleanup);
|
|
1481
|
-
const exit = fiber.pollUnsafe();
|
|
1482
|
-
if (exit && Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) {
|
|
1483
|
-
// FiberSet.runtime returns an already-interrupted sentinel when closure
|
|
1484
|
-
// wins admission. If admission won and closure interrupted the real fiber,
|
|
1485
|
-
// repeating the cached uninterruptible disposal only joins that cleanup.
|
|
1486
|
-
Effect.runFork(cleanup);
|
|
1487
|
-
}
|
|
1459
|
+
private launchCleanup(cleanup: Effect.Effect<void>): void {
|
|
1460
|
+
const fiber = Effect.runFork(cleanup);
|
|
1461
|
+
FiberSet.addUnsafe(this.cleanups, fiber);
|
|
1488
1462
|
}
|
|
1489
1463
|
|
|
1490
1464
|
private preflightOpen(
|
|
@@ -1500,30 +1474,12 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1500
1474
|
workerId: WorkerId,
|
|
1501
1475
|
): Decision<void> {
|
|
1502
1476
|
return this.transact((draft) => {
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
workerId,
|
|
1510
|
-
);
|
|
1511
|
-
if (ownership._tag === "rejected") return { value: ownership };
|
|
1512
|
-
const worker = ownership.value;
|
|
1513
|
-
if (
|
|
1514
|
-
worker.record.lifecycle !== "interactive" ||
|
|
1515
|
-
worker.record.status !== "ready" ||
|
|
1516
|
-
!worker.session
|
|
1517
|
-
) {
|
|
1518
|
-
return {
|
|
1519
|
-
value: rejected(
|
|
1520
|
-
"sendInteractive",
|
|
1521
|
-
"worker-state",
|
|
1522
|
-
"interactive_send requires an owned ready interactive worker",
|
|
1523
|
-
),
|
|
1524
|
-
};
|
|
1525
|
-
}
|
|
1526
|
-
return { value: accepted(undefined) };
|
|
1477
|
+
const ready = readyInteractiveDecision(draft, ownerSessionId, workerId);
|
|
1478
|
+
return {
|
|
1479
|
+
value: ready._tag === "accepted"
|
|
1480
|
+
? accepted(undefined)
|
|
1481
|
+
: ready,
|
|
1482
|
+
};
|
|
1527
1483
|
});
|
|
1528
1484
|
}
|
|
1529
1485
|
|
|
@@ -1536,6 +1492,7 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1536
1492
|
): A {
|
|
1537
1493
|
const draft = makeDraft(this.state);
|
|
1538
1494
|
const mutation = reducer(draft);
|
|
1495
|
+
// Commit before action factories capture the snapshot and callbacks can run.
|
|
1539
1496
|
this.state = Object.freeze(draft);
|
|
1540
1497
|
this.enqueueActions((mutation.actions ?? []).map((action) => action(
|
|
1541
1498
|
this.state,
|
|
@@ -1546,6 +1503,7 @@ class StatefulOrchestration implements OrchestrationService {
|
|
|
1546
1503
|
}
|
|
1547
1504
|
|
|
1548
1505
|
private enqueueActions(actions: readonly CommittedAction[]): void {
|
|
1506
|
+
// Reentrant actions queue behind this drain; drain all before rethrowing the first failure.
|
|
1549
1507
|
this.actionQueue.push(...actions);
|
|
1550
1508
|
if (this.drainingActions) return;
|
|
1551
1509
|
|
|
@@ -1580,7 +1538,6 @@ export function orchestrationLayer(
|
|
|
1580
1538
|
// Scope finalizers run in reverse acquisition order. Keep cleanup open while
|
|
1581
1539
|
// generation and cancellation interruption settle workers and enqueue disposal.
|
|
1582
1540
|
const cleanups = yield* FiberSet.make<void, never>();
|
|
1583
|
-
const runCleanup = yield* FiberSet.runtime(cleanups)<never>();
|
|
1584
1541
|
const cancellations = yield* FiberSet.make<void, never>();
|
|
1585
1542
|
const runCancellation = yield* FiberSet.runtime(cancellations)<never>();
|
|
1586
1543
|
const generations = yield* FiberMap.make<WorkerId, void, never>();
|
|
@@ -1594,7 +1551,6 @@ export function orchestrationLayer(
|
|
|
1594
1551
|
cancellations,
|
|
1595
1552
|
runCancellation,
|
|
1596
1553
|
cleanups,
|
|
1597
|
-
runCleanup,
|
|
1598
1554
|
clock,
|
|
1599
1555
|
options.idFactories ?? createRandomIdFactories(),
|
|
1600
1556
|
shutdownCompletion,
|
|
@@ -1875,6 +1831,38 @@ function ownedWorkerDecision(
|
|
|
1875
1831
|
: accepted(worker);
|
|
1876
1832
|
}
|
|
1877
1833
|
|
|
1834
|
+
function readyInteractiveDecision(
|
|
1835
|
+
draft: RuntimeState,
|
|
1836
|
+
ownerSessionId: string,
|
|
1837
|
+
workerId: WorkerId,
|
|
1838
|
+
): Decision<{
|
|
1839
|
+
readonly worker: RuntimeWorker;
|
|
1840
|
+
readonly session: WorkerSessionHandle;
|
|
1841
|
+
}> {
|
|
1842
|
+
const open = openDecision(draft, "sendInteractive");
|
|
1843
|
+
if (open._tag === "rejected") return open;
|
|
1844
|
+
const ownership = ownedWorkerDecision(
|
|
1845
|
+
draft,
|
|
1846
|
+
"sendInteractive",
|
|
1847
|
+
ownerSessionId,
|
|
1848
|
+
workerId,
|
|
1849
|
+
);
|
|
1850
|
+
if (ownership._tag === "rejected") return ownership;
|
|
1851
|
+
const worker = ownership.value;
|
|
1852
|
+
if (
|
|
1853
|
+
worker.record.lifecycle !== "interactive" ||
|
|
1854
|
+
worker.record.status !== "ready" ||
|
|
1855
|
+
!worker.session
|
|
1856
|
+
) {
|
|
1857
|
+
return rejected(
|
|
1858
|
+
"sendInteractive",
|
|
1859
|
+
"worker-state",
|
|
1860
|
+
"interactive_send requires an owned ready interactive worker",
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
return accepted({ worker, session: worker.session });
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1878
1866
|
function accepted<A>(value: A): Decision<A> {
|
|
1879
1867
|
return { _tag: "accepted", value };
|
|
1880
1868
|
}
|
package/extension/tools.ts
CHANGED
|
@@ -162,7 +162,14 @@ export function registerOrchestrationTools(
|
|
|
162
162
|
return renderDispatchCall(theme, args, expanded);
|
|
163
163
|
},
|
|
164
164
|
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
165
|
-
return renderOrchestrationResult(
|
|
165
|
+
return renderOrchestrationResult(
|
|
166
|
+
result,
|
|
167
|
+
isPartial,
|
|
168
|
+
expanded,
|
|
169
|
+
theme,
|
|
170
|
+
context.isError,
|
|
171
|
+
context.lastComponent,
|
|
172
|
+
);
|
|
166
173
|
},
|
|
167
174
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
168
175
|
const decision = deps.getDispatchDecision(toolCallId);
|
|
@@ -210,8 +217,8 @@ export function registerOrchestrationTools(
|
|
|
210
217
|
renderCall(_args, theme) {
|
|
211
218
|
return new Text(theme.fg("toolTitle", theme.bold("worker_status")), 0, 0);
|
|
212
219
|
},
|
|
213
|
-
renderResult(result, { isPartial }, theme) {
|
|
214
|
-
return renderDiagnosticsResult(result, isPartial, theme);
|
|
220
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
221
|
+
return renderDiagnosticsResult(result, isPartial, context.isError, theme);
|
|
215
222
|
},
|
|
216
223
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
217
224
|
const ownerSessionId = ctx.sessionManager.getSessionId();
|
|
@@ -236,10 +243,24 @@ export function registerOrchestrationTools(
|
|
|
236
243
|
],
|
|
237
244
|
parameters: interactiveSendSchema,
|
|
238
245
|
renderCall(args, theme, { expanded }) {
|
|
239
|
-
|
|
246
|
+
const fields = recordFields(args);
|
|
247
|
+
return renderInteractiveMessageCall(
|
|
248
|
+
theme,
|
|
249
|
+
"interactive_send",
|
|
250
|
+
fields.worker_id,
|
|
251
|
+
fields.instructions,
|
|
252
|
+
expanded,
|
|
253
|
+
);
|
|
240
254
|
},
|
|
241
255
|
renderResult(result, { isPartial, expanded }, theme, context) {
|
|
242
|
-
return renderOrchestrationResult(
|
|
256
|
+
return renderOrchestrationResult(
|
|
257
|
+
result,
|
|
258
|
+
isPartial,
|
|
259
|
+
expanded,
|
|
260
|
+
theme,
|
|
261
|
+
context.isError,
|
|
262
|
+
context.lastComponent,
|
|
263
|
+
);
|
|
243
264
|
},
|
|
244
265
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
245
266
|
const workerId = params.worker_id;
|
|
@@ -287,13 +308,23 @@ export function registerOrchestrationTools(
|
|
|
287
308
|
],
|
|
288
309
|
parameters: workerAbortSchema,
|
|
289
310
|
renderCall(args, theme) {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
311
|
+
const fields = recordFields(args);
|
|
312
|
+
const workerIds = Array.isArray(fields.worker_ids)
|
|
313
|
+
? fields.worker_ids
|
|
314
|
+
: undefined;
|
|
315
|
+
const target = workerIds
|
|
316
|
+
? `${workerIds.length} worker${workerIds.length === 1 ? "" : "s"}`
|
|
317
|
+
: fields.all === true ? "all workers" : "";
|
|
293
318
|
return renderCompactCall(theme, "worker_abort", target);
|
|
294
319
|
},
|
|
295
|
-
renderResult(result, { isPartial }, theme) {
|
|
296
|
-
return renderSimpleResult(
|
|
320
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
321
|
+
return renderSimpleResult(
|
|
322
|
+
result,
|
|
323
|
+
context.isError,
|
|
324
|
+
isPartial ? "Requesting worker stop…" : "Worker stop requested",
|
|
325
|
+
theme,
|
|
326
|
+
"warning",
|
|
327
|
+
);
|
|
297
328
|
},
|
|
298
329
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
299
330
|
const ownerSessionId = ctx.sessionManager.getSessionId();
|
|
@@ -318,10 +349,16 @@ export function registerOrchestrationTools(
|
|
|
318
349
|
],
|
|
319
350
|
parameters: interactiveCloseSchema,
|
|
320
351
|
renderCall(args, theme) {
|
|
321
|
-
|
|
352
|
+
const fields = recordFields(args);
|
|
353
|
+
return renderCompactCall(theme, "interactive_close", fields.worker_id);
|
|
322
354
|
},
|
|
323
|
-
renderResult(result, { isPartial }, theme) {
|
|
324
|
-
return renderSimpleResult(
|
|
355
|
+
renderResult(result, { isPartial }, theme, context) {
|
|
356
|
+
return renderSimpleResult(
|
|
357
|
+
result,
|
|
358
|
+
context.isError,
|
|
359
|
+
isPartial ? "Closing worker…" : "✓ Worker closed",
|
|
360
|
+
theme,
|
|
361
|
+
);
|
|
325
362
|
},
|
|
326
363
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
327
364
|
const ownerSessionId = ctx.sessionManager.getSessionId();
|
|
@@ -531,31 +568,26 @@ function readableDetails(title: string, details: unknown): string {
|
|
|
531
568
|
return `${truncation.content}\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}. Full structured details remain available.]`;
|
|
532
569
|
}
|
|
533
570
|
|
|
534
|
-
interface RenderableTask {
|
|
535
|
-
readonly worker?: unknown;
|
|
536
|
-
readonly title?: unknown;
|
|
537
|
-
readonly instructions?: unknown;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
571
|
function renderDispatchCall(
|
|
541
572
|
theme: Theme,
|
|
542
|
-
task:
|
|
573
|
+
task: unknown,
|
|
543
574
|
expanded: boolean,
|
|
544
575
|
): Component {
|
|
576
|
+
const fields = isRecord(task) ? task : {};
|
|
545
577
|
const container = new Container();
|
|
546
578
|
container.addChild(new Text(
|
|
547
|
-
theme.fg("toolTitle", theme.bold("orchestrate ")) + theme.fg("muted", safeTerminalText(
|
|
579
|
+
theme.fg("toolTitle", theme.bold("orchestrate ")) + theme.fg("muted", safeTerminalText(fields.worker)),
|
|
548
580
|
0, 0,
|
|
549
581
|
));
|
|
550
582
|
container.addChild(new Text(
|
|
551
|
-
`${theme.fg("accent", "→")} ${theme.fg("text", theme.bold(safeTerminalText(
|
|
583
|
+
`${theme.fg("accent", "→")} ${theme.fg("text", theme.bold(safeTerminalText(fields.title)))}`,
|
|
552
584
|
0, 0,
|
|
553
585
|
));
|
|
554
586
|
if (expanded) {
|
|
555
|
-
container.addChild(new Text(safeTerminalText(
|
|
587
|
+
container.addChild(new Text(safeTerminalText(fields.instructions), 2, 0));
|
|
556
588
|
return new WidthBoundComponent(container);
|
|
557
589
|
}
|
|
558
|
-
container.addChild(new InstructionPreview(
|
|
590
|
+
container.addChild(new InstructionPreview(fields.instructions, theme));
|
|
559
591
|
container.addChild(new Text(theme.fg("dim", keyHint("app.tools.expand", "to inspect full instructions")), 0, 0));
|
|
560
592
|
return new WidthBoundComponent(container);
|
|
561
593
|
}
|
|
@@ -642,8 +674,18 @@ function renderOrchestrationResult(
|
|
|
642
674
|
isPartial: boolean,
|
|
643
675
|
expanded: boolean,
|
|
644
676
|
theme: Theme,
|
|
677
|
+
isError: boolean,
|
|
645
678
|
lastComponent: unknown,
|
|
646
679
|
): Component {
|
|
680
|
+
if (isError) {
|
|
681
|
+
return new WidthBoundComponent(renderSimpleResult(
|
|
682
|
+
result,
|
|
683
|
+
true,
|
|
684
|
+
firstResultLine(result) || "Worker operation failed",
|
|
685
|
+
theme,
|
|
686
|
+
"warning",
|
|
687
|
+
));
|
|
688
|
+
}
|
|
647
689
|
const details = result.details;
|
|
648
690
|
if (Result.isSuccess(decodeAcceptedRunRenderDetails(details))) {
|
|
649
691
|
return new WidthBoundComponent(new Text(theme.fg("success", "Sent to worker") + theme.fg("dim", " · response arrives when complete"), 0, 0));
|
|
@@ -660,7 +702,13 @@ function renderOrchestrationResult(
|
|
|
660
702
|
return new WidthBoundComponent(new Text(theme.fg("warning", "Worker result details unavailable"), 0, 0));
|
|
661
703
|
}
|
|
662
704
|
if (isPartial) return new WidthBoundComponent(new Text(theme.fg("warning", "Sending work…"), 0, 0));
|
|
663
|
-
return new WidthBoundComponent(renderSimpleResult(
|
|
705
|
+
return new WidthBoundComponent(renderSimpleResult(
|
|
706
|
+
result,
|
|
707
|
+
false,
|
|
708
|
+
firstResultLine(result) || "Work sent",
|
|
709
|
+
theme,
|
|
710
|
+
"warning",
|
|
711
|
+
));
|
|
664
712
|
}
|
|
665
713
|
|
|
666
714
|
interface RenderedInlineSettlement {
|
|
@@ -736,7 +784,20 @@ function readInlineResult(details: unknown): RenderedInlineSettlement | undefine
|
|
|
736
784
|
};
|
|
737
785
|
}
|
|
738
786
|
|
|
739
|
-
function renderDiagnosticsResult(
|
|
787
|
+
function renderDiagnosticsResult(
|
|
788
|
+
result: AgentToolResult<unknown>,
|
|
789
|
+
isPartial: boolean,
|
|
790
|
+
isError: boolean,
|
|
791
|
+
theme: Theme,
|
|
792
|
+
): Text {
|
|
793
|
+
if (isError) {
|
|
794
|
+
return renderSimpleResult(
|
|
795
|
+
result,
|
|
796
|
+
true,
|
|
797
|
+
firstResultLine(result) || "Worker diagnostics failed",
|
|
798
|
+
theme,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
740
801
|
if (isPartial) return new Text(theme.fg("muted", "Reading worker diagnostics…"), 0, 0);
|
|
741
802
|
const details = result.details;
|
|
742
803
|
if (isRecord(details) && isRecord(details.state) && Array.isArray(details.state.workers)) {
|
|
@@ -752,12 +813,13 @@ function renderDiagnosticsResult(result: AgentToolResult<unknown>, isPartial: bo
|
|
|
752
813
|
|
|
753
814
|
function renderSimpleResult(
|
|
754
815
|
result: AgentToolResult<unknown>,
|
|
816
|
+
isError: boolean,
|
|
755
817
|
message: string,
|
|
756
818
|
theme: Theme,
|
|
757
819
|
normalColor: "success" | "warning" = "success",
|
|
758
820
|
): Text {
|
|
759
|
-
const
|
|
760
|
-
return new Text(theme.fg(
|
|
821
|
+
const text = isError ? firstResultLine(result) || message : message;
|
|
822
|
+
return new Text(theme.fg(isError ? "error" : normalColor, text), 0, 0);
|
|
761
823
|
}
|
|
762
824
|
|
|
763
825
|
function firstResultLine(result: AgentToolResult<unknown>): string | undefined {
|
|
@@ -766,6 +828,10 @@ function firstResultLine(result: AgentToolResult<unknown>): string | undefined {
|
|
|
766
828
|
return first.text.split("\n").find((line) => line.trim())?.trim();
|
|
767
829
|
}
|
|
768
830
|
|
|
831
|
+
function recordFields(value: unknown): Record<string, unknown> {
|
|
832
|
+
return isRecord(value) ? value : {};
|
|
833
|
+
}
|
|
834
|
+
|
|
769
835
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
770
836
|
return typeof value === "object" && value !== null;
|
|
771
837
|
}
|