@tryinget/pi-agent-registry 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ // ---
2
+ // summary: Fleet Phase-2 exact-task read-only standing-agent dispatch through the ASC-owned execution runtime.
3
+ // read_when:
4
+ // - changing the dispatch_agent pipeline, eligibility gates, or effect classification.
5
+ // ---
6
+
7
+ import type {
8
+ AscExecutionRuntime,
9
+ DispatchSubagentExecutionUpdate,
10
+ SubagentModelContext,
11
+ } from "@tryinget/pi-autonomous-session-control/execution";
12
+ import { loadAscExecutionSurface } from "./asc-execution-surface.ts";
13
+ import {
14
+ AkAuthorizationError,
15
+ authorizeExactTask,
16
+ buildDispatchEvidenceDetails,
17
+ readAkTask,
18
+ recordDispatchEvidence,
19
+ } from "./dispatch-authorization.ts";
20
+ import {
21
+ DISPATCH_CHILD_PROVENANCE_ENV,
22
+ DISPATCH_EXECUTION_TIMEOUT_SECONDS,
23
+ DISPATCH_PHASE,
24
+ DISPATCH_RECEIPT_SCHEMA,
25
+ DISPATCH_STARTUP_TIMEOUT_SECONDS,
26
+ type DispatchAgentRequest,
27
+ type DispatchFailureReason,
28
+ MAX_DISPATCH_ATTEMPTS_PER_PAIR,
29
+ READ_ONLY_DISPATCH_TOOLS,
30
+ } from "./dispatch-contract.ts";
31
+ import {
32
+ buildDispatchReceiptInput,
33
+ type DispatchReceipt,
34
+ readDispatchAttemptLedger,
35
+ sha256Hex,
36
+ writeImmutableDispatchReceipt,
37
+ } from "./dispatch-receipt.ts";
38
+ import {
39
+ composeDispatchSubagentRequest,
40
+ createPhase2AscRuntime,
41
+ type DispatchRequestInputs,
42
+ type DispatchRuntimeFactory,
43
+ dispatchEffectCorrelationId,
44
+ } from "./dispatch-request.ts";
45
+ import { captureFleetGitSnapshot, resolveGitRepoRoot } from "./fleet-git-snapshot.ts";
46
+ import type { AgentRegistry } from "./registry.ts";
47
+
48
+ export class AgentDispatchError extends Error {
49
+ readonly reason: DispatchFailureReason;
50
+ readonly effectDisposition: "confirmed_no_effects" | "settled" | "effect_indeterminate";
51
+ readonly spawnAttempted: boolean;
52
+
53
+ constructor(
54
+ reason: DispatchFailureReason,
55
+ message: string,
56
+ options?: {
57
+ effectDisposition?: "confirmed_no_effects" | "settled" | "effect_indeterminate";
58
+ spawnAttempted?: boolean;
59
+ },
60
+ ) {
61
+ super(message);
62
+ this.name = "AgentDispatchError";
63
+ this.reason = reason;
64
+ this.effectDisposition = options?.effectDisposition ?? "confirmed_no_effects";
65
+ this.spawnAttempted = options?.spawnAttempted ?? false;
66
+ }
67
+ }
68
+
69
+ export interface DispatchAgentDependencies {
70
+ registry: AgentRegistry;
71
+ /** AK CLI binary (default: ak). */
72
+ akBinary?: string;
73
+ /** Explicit receipts directory override (tests / isolated runs). */
74
+ receiptsDir?: string;
75
+ /** Execution-runtime factory; defaults to the ASC-owned runtime. */
76
+ createRuntime?: DispatchRuntimeFactory;
77
+ }
78
+
79
+ export interface DispatchAgentSuccess {
80
+ ok: true;
81
+ receipt: DispatchReceipt;
82
+ receiptPath: string;
83
+ evidenceId?: number;
84
+ output: string;
85
+ }
86
+
87
+ export interface DispatchAgentFailure {
88
+ ok: false;
89
+ reason: DispatchFailureReason;
90
+ message: string;
91
+ effectDisposition: "confirmed_no_effects" | "settled" | "effect_indeterminate";
92
+ spawnAttempted: boolean;
93
+ receipt?: DispatchReceipt;
94
+ receiptPath?: string;
95
+ output?: string;
96
+ }
97
+
98
+ export type DispatchAgentOutcome = DispatchAgentSuccess | DispatchAgentFailure;
99
+
100
+ /**
101
+ * Fleet Phase-2 dispatch: exactly one read-only standing-agent execution bound
102
+ * to one exact claimed AK task, one immutable receipt, and at most one typed
103
+ * AK evidence row. Every gate fails closed before any ASC identity, capacity,
104
+ * session, or spawn effect exists; ASC owns all execution machinery.
105
+ */
106
+ export async function dispatchAgent(
107
+ request: DispatchAgentRequest,
108
+ deps: DispatchAgentDependencies,
109
+ ctx: SubagentModelContext & { cwd: string },
110
+ onUpdate?: (update: DispatchSubagentExecutionUpdate) => void,
111
+ signal?: AbortSignal,
112
+ ): Promise<DispatchAgentOutcome> {
113
+ const fail = (
114
+ reason: DispatchFailureReason,
115
+ message: string,
116
+ extra?: Partial<
117
+ Pick<
118
+ DispatchAgentFailure,
119
+ "effectDisposition" | "spawnAttempted" | "receipt" | "receiptPath" | "output"
120
+ >
121
+ >,
122
+ ): DispatchAgentFailure => ({
123
+ ok: false,
124
+ reason,
125
+ message,
126
+ effectDisposition: extra?.effectDisposition ?? "confirmed_no_effects",
127
+ spawnAttempted: extra?.spawnAttempted ?? false,
128
+ ...(extra?.receipt ? { receipt: extra.receipt } : {}),
129
+ ...(extra?.receiptPath ? { receiptPath: extra.receiptPath } : {}),
130
+ ...(extra?.output ? { output: extra.output } : {}),
131
+ });
132
+
133
+ if (
134
+ typeof request.agent !== "string" ||
135
+ !request.agent.trim() ||
136
+ !Number.isInteger(request.task) ||
137
+ request.task <= 0 ||
138
+ typeof request.objective !== "string" ||
139
+ !request.objective.trim()
140
+ ) {
141
+ return fail(
142
+ "invalid_request",
143
+ "dispatch_agent requires agent, task (exact AK id), and objective",
144
+ );
145
+ }
146
+ if (process.env[DISPATCH_CHILD_PROVENANCE_ENV]) {
147
+ return fail(
148
+ "recursive_dispatch",
149
+ "standing-agent dispatch is one level deep; this session is already a dispatched standing-agent child",
150
+ );
151
+ }
152
+ const surface = await loadAscExecutionSurface();
153
+ if (!surface) {
154
+ return fail(
155
+ "asc_execution_unavailable",
156
+ "the installed ASC package does not export the execution surface (createAscExecutionRuntime, resolveSubagentSessionsDir, resolveSubagentModelSelection); dispatch fails closed",
157
+ );
158
+ }
159
+
160
+ const manifest = deps.registry.get(request.agent);
161
+ if (!manifest) {
162
+ return fail(
163
+ "unknown_agent",
164
+ `unknown agent: ${request.agent} (registered: ${[...deps.registry.agents.keys()].sort().join(", ") || "none"})`,
165
+ );
166
+ }
167
+
168
+ const ledger = await readDispatchAttemptLedger(request.agent, request.task, {
169
+ dir: deps.receiptsDir,
170
+ });
171
+ if (ledger.settled) {
172
+ return fail(
173
+ "dispatch_already_recorded",
174
+ `one settled dispatch per (agent, exact task) pair: ak-${request.task}/${request.agent} settled with receipt sha256 ${ledger.settled.receipt.receiptSha256}`,
175
+ );
176
+ }
177
+ if (ledger.nextAttemptIndex > MAX_DISPATCH_ATTEMPTS_PER_PAIR) {
178
+ return fail(
179
+ "dispatch_attempts_exhausted",
180
+ `ak-${request.task}/${request.agent} already consumed ${MAX_DISPATCH_ATTEMPTS_PER_PAIR} bounded attempts without a settled read-only dispatch; further attempts require explicit owner disposition`,
181
+ );
182
+ }
183
+
184
+ const parentRoot = await resolveGitRepoRoot(ctx.cwd).catch(() => undefined);
185
+ if (!parentRoot) {
186
+ return fail(
187
+ "parent_repo_unobservable",
188
+ `dispatch origin ${ctx.cwd} is not one observable Git repository; exact-task binding is impossible`,
189
+ );
190
+ }
191
+ const parentPre = await captureFleetGitSnapshot(parentRoot).catch(() => undefined);
192
+ if (!parentPre) {
193
+ return fail(
194
+ "parent_repo_unobservable",
195
+ `dispatch-origin repository ${parentRoot} cannot be captured`,
196
+ );
197
+ }
198
+
199
+ let task: Awaited<ReturnType<typeof readAkTask>>;
200
+ try {
201
+ task = await readAkTask(request.task, { akBinary: deps.akBinary });
202
+ } catch (error) {
203
+ if (error instanceof AkAuthorizationError) {
204
+ return fail(error.code, error.message);
205
+ }
206
+ return fail(
207
+ "ak_unavailable",
208
+ `AK task read failed: ${error instanceof Error ? error.message : String(error)}`,
209
+ );
210
+ }
211
+ const authorization = authorizeExactTask(task, parentRoot);
212
+ if (!authorization.ok) {
213
+ return fail(authorization.code, authorization.message);
214
+ }
215
+
216
+ const declaredTools = [...manifest.tools];
217
+ if (
218
+ declaredTools.length === 0 ||
219
+ declaredTools.some((tool) => !READ_ONLY_DISPATCH_TOOLS.includes(tool))
220
+ ) {
221
+ return fail(
222
+ "agent_not_read_only",
223
+ `agent ${manifest.name} declares tools [${declaredTools.join(", ")}]; Fleet Phase-2 read-only dispatch requires a non-empty subset of [${READ_ONLY_DISPATCH_TOOLS.join(", ")}]`,
224
+ );
225
+ }
226
+
227
+ const agentSnapshot = await captureFleetGitSnapshot(manifest.root).catch(() => undefined);
228
+ if (!agentSnapshot) {
229
+ return fail(
230
+ "agent_repo_drift",
231
+ `agent repository ${manifest.root} cannot be captured immutably`,
232
+ );
233
+ }
234
+ if (agentSnapshot.status !== "clean_observed") {
235
+ return fail(
236
+ "agent_repo_dirty",
237
+ `agent repository ${manifest.name} worktree is dirty; dispatched bytes could not bind to an immutable revision`,
238
+ );
239
+ }
240
+ const committedManifest = await agentSnapshot.readFile("agent.json").catch(() => undefined);
241
+ const committedPrompt = await agentSnapshot
242
+ .readFile(manifest.system_prompt_file)
243
+ .catch(() => undefined);
244
+ if (!committedManifest || !committedPrompt) {
245
+ return fail(
246
+ "agent_repo_drift",
247
+ `agent repository ${manifest.name} is missing committed agent.json or ${manifest.system_prompt_file}`,
248
+ );
249
+ }
250
+
251
+ let launch: Awaited<ReturnType<AgentRegistry["resolve"]>>;
252
+ try {
253
+ launch = await deps.registry.resolve(request.agent);
254
+ } catch (error) {
255
+ return fail(
256
+ "agent_resolution_failed",
257
+ `agent ${request.agent} failed read-only launch resolution: ${error instanceof Error ? error.message : String(error)}`,
258
+ );
259
+ }
260
+
261
+ const requestInputs: DispatchRequestInputs = {
262
+ manifest,
263
+ launch,
264
+ task,
265
+ objective: request.objective,
266
+ parentRoot,
267
+ manifestSha256: committedManifest.sha256,
268
+ };
269
+ const effectCorrelationId = dispatchEffectCorrelationId(requestInputs);
270
+ const allowedPaths =
271
+ manifest.scope?.repos && manifest.scope.repos.length > 0
272
+ ? [...manifest.scope.repos]
273
+ : [parentRoot];
274
+ const forbiddenPaths = [...(manifest.scope?.forbidden ?? []), ".git", "node_modules"];
275
+ const runtime = createPhase2AscRuntime(
276
+ surface,
277
+ deps.registry,
278
+ launch,
279
+ { cwd: ctx.cwd },
280
+ deps.createRuntime,
281
+ );
282
+
283
+ let result: Awaited<ReturnType<AscExecutionRuntime["execute"]>>;
284
+ try {
285
+ result = await runtime.execute(
286
+ composeDispatchSubagentRequest(requestInputs),
287
+ ctx,
288
+ onUpdate,
289
+ signal,
290
+ );
291
+ } catch (error) {
292
+ await launch.cleanup().catch(() => undefined);
293
+ return fail(
294
+ "dispatch_failed",
295
+ `ASC runtime rejected the dispatch (effect classification is indeterminate; ASC's own effect-receipt path remains authoritative): ${error instanceof Error ? error.message : String(error)}`,
296
+ { effectDisposition: "effect_indeterminate" },
297
+ );
298
+ }
299
+ await launch.cleanup().catch(() => undefined);
300
+
301
+ const ascDetails = result.details;
302
+ // ASC's owner-issued effect receipt is the effect-truth surface; the raw
303
+ // details field is absent on terminal results (ASC's own observation layer
304
+ // derives disposition from the receipt the same way).
305
+ const effectDisposition =
306
+ ascDetails.effectReceipt?.disposition ?? ascDetails.effectDisposition ?? "effect_indeterminate";
307
+ const finish = await agentSnapshot.finish().catch(() => undefined);
308
+ const revisionStable = finish?.stable === true;
309
+ const parentPost = await captureFleetGitSnapshot(parentRoot).catch(() => undefined);
310
+ if (!parentPost) {
311
+ return fail(
312
+ "parent_repo_unobservable",
313
+ `dispatch-origin repository ${parentRoot} could not be re-observed after execution; the read-only claim is unproven`,
314
+ {
315
+ effectDisposition,
316
+ spawnAttempted: true,
317
+ output: result.text,
318
+ },
319
+ );
320
+ }
321
+ const headStable = parentPost.commit === parentPre.commit;
322
+ const noMutationObserved =
323
+ headStable && parentPost.statusSha256 === parentPre.statusSha256 && revisionStable;
324
+
325
+ const receiptInput = buildDispatchReceiptInput({
326
+ agent: {
327
+ name: manifest.name,
328
+ ...(manifest.role ? { role: manifest.role } : {}),
329
+ ...(manifest.creation_task ? { creation_task: manifest.creation_task } : {}),
330
+ tools: declaredTools,
331
+ thinking: launch.thinking,
332
+ model: launch.model,
333
+ ...(manifest.skills?.profile ? { skillProfile: manifest.skills.profile } : {}),
334
+ loadedSkills: launch.loadedSkills,
335
+ manifestSha256: committedManifest.sha256,
336
+ manifestBlobOid: committedManifest.blobOid,
337
+ systemPromptSha256: committedPrompt.sha256,
338
+ agentRepo: {
339
+ commit: agentSnapshot.commit,
340
+ treeOid: agentSnapshot.treeOid,
341
+ status: "clean_observed",
342
+ statusSha256: agentSnapshot.statusSha256,
343
+ revisionStable,
344
+ },
345
+ },
346
+ task: {
347
+ id: task.id,
348
+ repo: task.repo,
349
+ title: task.title,
350
+ status: task.status,
351
+ claimedBy: task.claimed_by ?? "",
352
+ leaseExpiresAt: task.lease_expires_at,
353
+ },
354
+ dispatch: {
355
+ attemptIndex: ledger.nextAttemptIndex,
356
+ settlement: "not_settled",
357
+ objective: request.objective,
358
+ objectiveSha256: sha256Hex(request.objective),
359
+ mutationPolicy: "read_only",
360
+ allowedPaths,
361
+ forbiddenPaths,
362
+ effectCorrelationId,
363
+ executionTimeoutSeconds: DISPATCH_EXECUTION_TIMEOUT_SECONDS,
364
+ startupTimeoutSeconds: DISPATCH_STARTUP_TIMEOUT_SECONDS,
365
+ asc: {
366
+ dispatchId: ascDetails.dispatchId ?? "",
367
+ attemptId: ascDetails.attemptId ?? "",
368
+ sessionName: ascDetails.sessionName ?? "",
369
+ sessionFile: ascDetails.sessionFile ?? "",
370
+ status: ascDetails.status ?? "error",
371
+ ...(typeof ascDetails.exitCode === "number" ? { exitCode: ascDetails.exitCode } : {}),
372
+ effectDisposition,
373
+ ...(ascDetails.effectReceipt?.receiptPath
374
+ ? { effectReceiptPath: ascDetails.effectReceipt.receiptPath }
375
+ : {}),
376
+ ...(ascDetails.requestedModel ? { requestedModel: ascDetails.requestedModel } : {}),
377
+ ...(ascDetails.effectiveModel ? { effectiveModel: ascDetails.effectiveModel } : {}),
378
+ ...(ascDetails.usage
379
+ ? { usage: ascDetails.usage as unknown as Record<string, unknown> }
380
+ : {}),
381
+ },
382
+ outputSha256: sha256Hex(result.text),
383
+ outputChars: result.text.length,
384
+ },
385
+ observation: {
386
+ parentRepoRoot: parentRoot,
387
+ parentHead: parentPre.commit,
388
+ preStatusSha256: parentPre.statusSha256,
389
+ postStatusSha256: parentPost.statusSha256,
390
+ headStable,
391
+ noMutationObserved,
392
+ boundary:
393
+ "Bounded observation of the dispatch-origin repository and agent repository across the dispatch window (HEAD plus porcelain-tracked worktree state); git-ignored files, .git internals, and surfaces outside those two repositories are not observed, and an undetectable modify-and-restore interval is not claimed absent.",
394
+ },
395
+ recordedAt: new Date().toISOString(),
396
+ });
397
+
398
+ const settledIdentityComplete = Boolean(
399
+ ascDetails.dispatchId &&
400
+ ascDetails.attemptId &&
401
+ ascDetails.sessionName &&
402
+ ascDetails.sessionFile &&
403
+ ascDetails.effectReceipt?.receiptPath &&
404
+ ascDetails.effectReceipt.consumerCorrelationId,
405
+ );
406
+ const settledCorrelationEcho =
407
+ ascDetails.effectReceipt?.consumerCorrelationId === effectCorrelationId;
408
+ const settled =
409
+ result.ok &&
410
+ ascDetails.status === "done" &&
411
+ noMutationObserved &&
412
+ revisionStable &&
413
+ effectDisposition === "settled" &&
414
+ settledIdentityComplete &&
415
+ settledCorrelationEcho;
416
+ receiptInput.dispatch.settlement = settled ? "settled" : "not_settled";
417
+
418
+ let written: Awaited<ReturnType<typeof writeImmutableDispatchReceipt>>;
419
+ try {
420
+ written = await writeImmutableDispatchReceipt(receiptInput, { dir: deps.receiptsDir });
421
+ } catch (error) {
422
+ return fail(
423
+ "receipt_write_failed",
424
+ `immutable dispatch receipt could not be published: ${error instanceof Error ? error.message : String(error)}`,
425
+ {
426
+ effectDisposition,
427
+ spawnAttempted: true,
428
+ output: result.text,
429
+ },
430
+ );
431
+ }
432
+
433
+ if (!settled) {
434
+ const reason: DispatchFailureReason = !result.ok
435
+ ? "dispatch_failed"
436
+ : !noMutationObserved
437
+ ? "read_only_violation_observed"
438
+ : !revisionStable
439
+ ? "agent_repo_drift"
440
+ : !settledIdentityComplete || !settledCorrelationEcho
441
+ ? "dispatch_failed"
442
+ : "dispatch_failed";
443
+ return {
444
+ ok: false,
445
+ reason,
446
+ message: `dispatch executed but did not settle as a proven read-only observation (status=${ascDetails.status ?? "error"}, revisionStable=${revisionStable}, noMutationObserved=${noMutationObserved}, effectDisposition=${effectDisposition}); the immutable receipt remains the record of truth`,
447
+ effectDisposition,
448
+ spawnAttempted: true,
449
+ receipt: written.receipt,
450
+ receiptPath: written.receiptPath,
451
+ output: result.text,
452
+ };
453
+ }
454
+
455
+ try {
456
+ const evidence = await recordDispatchEvidence(
457
+ {
458
+ taskId: task.id,
459
+ details: buildDispatchEvidenceDetails({
460
+ agent: manifest.name,
461
+ agentRepoCommit: agentSnapshot.commit,
462
+ manifestSha256: committedManifest.sha256,
463
+ task: task.id,
464
+ attemptIndex: ledger.nextAttemptIndex,
465
+ dispatchId: ascDetails.dispatchId ?? "",
466
+ attemptId: ascDetails.attemptId ?? "",
467
+ sessionName: ascDetails.sessionName ?? "",
468
+ effectDisposition,
469
+ effectCorrelationId,
470
+ effectCorrelationEchoVerified: settledCorrelationEcho,
471
+ noMutationObserved,
472
+ outputSha256: sha256Hex(result.text),
473
+ receiptSha256: written.receiptSha256,
474
+ receiptName: written.receiptPath.split("/").pop(),
475
+ }),
476
+ },
477
+ { akBinary: deps.akBinary },
478
+ );
479
+ return {
480
+ ok: true,
481
+ receipt: written.receipt,
482
+ receiptPath: written.receiptPath,
483
+ evidenceId: evidence.evidenceId,
484
+ output: result.text,
485
+ };
486
+ } catch (error) {
487
+ return {
488
+ ok: false,
489
+ reason: "evidence_record_failed",
490
+ message: `dispatch settled and the immutable receipt was published, but AK evidence recording failed: ${error instanceof Error ? error.message : String(error)} (record it from the receipt digest manually)`,
491
+ effectDisposition: "settled",
492
+ spawnAttempted: true,
493
+ receipt: written.receipt,
494
+ receiptPath: written.receiptPath,
495
+ output: result.text,
496
+ };
497
+ }
498
+ }