@fungi.computer/shiitake 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +18 -8
  2. package/dist/agent-control.d.ts +19 -15
  3. package/dist/agent-control.js +66 -126
  4. package/dist/client-wire.d.ts +12 -3
  5. package/dist/client-wire.js +33 -8
  6. package/dist/client.d.ts +34 -3
  7. package/dist/client.js +1 -1
  8. package/dist/cloudflare.d.ts +3 -1
  9. package/dist/cloudflare.js +2 -2
  10. package/dist/delegation.d.ts +145 -13
  11. package/dist/delegation.js +109 -37
  12. package/dist/effect.d.ts +1 -1
  13. package/dist/fleet.d.ts +30 -8
  14. package/dist/fleet.js +142 -38
  15. package/dist/http-routes.d.ts +84 -12
  16. package/dist/http-routes.js +15 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/internal/admission-door.d.ts +2 -0
  19. package/dist/internal/admission-door.js +2 -0
  20. package/dist/internal/client-boundary.d.ts +10 -0
  21. package/dist/internal/client-boundary.js +10 -0
  22. package/dist/internal/compaction/compaction.js +1 -1
  23. package/dist/internal/contracts.d.ts +2 -0
  24. package/dist/internal/coordinator.d.ts +3 -1
  25. package/dist/internal/coordinator.js +98 -13
  26. package/dist/internal/effect-client.d.ts +3 -1
  27. package/dist/internal/effect-client.js +3 -2
  28. package/dist/internal/effect-contracts.d.ts +6 -2
  29. package/dist/internal/model-acquisition.js +2 -0
  30. package/dist/internal/observation-kernel.d.ts +8 -4
  31. package/dist/internal/observation-kernel.js +44 -9
  32. package/dist/internal/promise-facade.d.ts +1 -1
  33. package/dist/internal/promise-facade.js +1 -1
  34. package/dist/internal/run-scope.js +37 -8
  35. package/dist/internal/runtime-coordinator.d.ts +5 -0
  36. package/dist/internal/runtime-coordinator.js +8 -4
  37. package/dist/internal/runtime-core.d.ts +2 -1
  38. package/dist/internal/runtime-core.js +6 -1
  39. package/dist/internal/session-context.d.ts +1 -1
  40. package/dist/internal/session-context.js +84 -13
  41. package/dist/internal/woodstock/agent-reply.d.ts +7 -2
  42. package/dist/internal/woodstock/agent-reply.js +22 -13
  43. package/dist/internal/woodstock/fleet-store.d.ts +3 -2
  44. package/dist/internal/woodstock/fleet-store.js +43 -6
  45. package/dist/internal/woodstock/index.d.ts +2 -0
  46. package/dist/internal/woodstock/index.js +11 -5
  47. package/dist/internal/woodstock/run-recovery.js +4 -1
  48. package/dist/internal/woodstock/session-entry.d.ts +13 -3
  49. package/dist/internal/woodstock/session-entry.js +1 -0
  50. package/dist/internal/woodstock/session-store.d.ts +4 -0
  51. package/dist/internal/woodstock/session-store.js +26 -19
  52. package/dist/remote-client.js +5 -1
  53. package/dist/testing.d.ts +5 -2
  54. package/dist/testing.js +4 -2
  55. package/docs/api/agent-control.md +18 -12
  56. package/docs/api/client.md +78 -0
  57. package/docs/api/delegation.md +72 -55
  58. package/docs/api/effect.md +5 -0
  59. package/docs/api/fleet.md +23 -23
  60. package/docs/api/http.md +2 -1
  61. package/docs/api/index.md +13 -0
  62. package/docs/api/testing.md +3 -3
  63. package/package.json +9 -8
@@ -1,15 +1,16 @@
1
1
  import { nanoid } from "@fungi.computer/cairn";
2
2
  import { parseModelPreference } from "./model-preference.js";
3
+ import { acceptedNotice } from "@fungi.computer/bird-dog/effect";
3
4
  import * as Effect from "effect/Effect";
4
5
  import * as Option from "effect/Option";
5
6
  import * as Stream from "effect/Stream";
6
7
  import { ShiitakeError } from "../errors.js";
7
- import { decodeClientReadOptions, decodeClientSessionCommand, decodeClientSessionId, decodeClientSubscribeOptions, } from "./client-boundary.js";
8
+ import { decodeClientReadOptions, decodeClientSettlementRequest, requireDecoded, decodeClientSessionCommand, decodeClientSessionId, decodeClientSubscribeOptions, } from "./client-boundary.js";
8
9
  import { mergeObservationSignals, openObservationKernel, publishOwnerObservation, } from "./observation-kernel.js";
9
10
  import { checkHostMeta } from "./host-meta.js";
10
11
  import { readSessionActivity } from "./session-status.js";
11
12
  import { readSessionContext } from "./session-context.js";
12
- import { abortSession, cancelSession, branchSession, recordOperatorLine, forkSession, readSession, readSessionModelPreference, readSessionReplayPage, readSessionInbox, readLatestPendingPrompt, createSession, deleteSession, deliverSessionGrants, readSessionGrants, setSessionModelPreference, setSessionTitle, readAdmissionIdentity, assertAdmissionIdentity, } from "./woodstock/index.js";
13
+ import { abortSession, cancelSession, branchSession, recordOperatorLine, forkSession, readSession, readCommandSettlement, readSessionModelPreference, readSessionReplayPage, readSessionInbox, readLatestPendingPrompt, createSession, deleteSession, deliverSessionGrants, readSessionGrants, setSessionModelPreference, setSessionTitle, readAdmissionIdentity, assertAdmissionIdentity, } from "./woodstock/index.js";
13
14
  /** Generic Agent-scope extension controls over the runtime's one call door. */
14
15
  function agentExtensions(runtime) {
15
16
  return Object.freeze({
@@ -33,7 +34,7 @@ function withContext(snapshot, context) {
33
34
  }
34
35
  /** Read the baseline and its context use for one Session. */
35
36
  function readSessionWithContext(options, runtime, sessionId, input) {
36
- return currentSessionActivity(options, runtime, sessionId).pipe(Effect.flatMap((activity) => readSession(options.store, sessionId, activity, input, () => runtime.extensions.project(sessionId))), Effect.flatMap((snapshot) => readSessionContext(options, sessionId).pipe(Effect.map((context) => withContext(snapshot, context)))));
37
+ return currentSessionActivity(options, runtime, sessionId).pipe(Effect.flatMap((activity) => readSession(options.store, sessionId, activity, input, () => runtime.extensions.project(sessionId))), Effect.flatMap((snapshot) => readSessionContext(options, sessionId, snapshot.cursor, input?.before === undefined).pipe(Effect.map((context) => withContext(snapshot, context)))));
37
38
  }
38
39
  function sessionOwnerOperation(runtime, sessionId, operation) {
39
40
  return runtime.assertOpen.pipe(Effect.andThen(Effect.try({
@@ -106,6 +107,12 @@ function subscriptionFrames(options, runtime, registration, sessionId, after, de
106
107
  if (signal === "reset")
107
108
  return resetFrame(options, runtime, sessionId, includeSandboxFailures).pipe(Effect.map(continueFromReset));
108
109
  return runtime.assertOpen.pipe(Effect.andThen(currentSessionActivity(options, runtime, sessionId)), Effect.flatMap((activity) => {
110
+ registration.setQueued(activity.facts.queue.state === "queued");
111
+ if (signal === "activity" && state.delivery === "live")
112
+ return Effect.succeed([
113
+ [{ kind: "activity", activity: activity.facts }],
114
+ Option.some({ ...state, awaitSignal: true }),
115
+ ]);
109
116
  if (signal === "progress" && state.delivery === "live") {
110
117
  return Effect.succeed([
111
118
  [
@@ -132,6 +139,7 @@ function subscriptionFrames(options, runtime, registration, sessionId, after, de
132
139
  return resetFrame(options, runtime, sessionId, includeSandboxFailures).pipe(Effect.map(continueFromReset));
133
140
  const latestCursor = page.frames
134
141
  .flatMap((frame) => frame.kind === "progress" ||
142
+ frame.kind === "activity" ||
135
143
  frame.kind === "tool_output"
136
144
  ? []
137
145
  : [frame.cursor])
@@ -141,13 +149,18 @@ function subscriptionFrames(options, runtime, registration, sessionId, after, de
141
149
  delivery: page.hasMore ? state.delivery : "live",
142
150
  awaitSignal: !page.hasMore,
143
151
  };
144
- // Every update carries the context use it settles on.
145
- return (page.frames.some((frame) => frame.kind === "update")
146
- ? readSessionContext(options, sessionId)
152
+ // Only a final update that moves the cursor gets a
153
+ // figure. A wake that moved nothing never re-reads the
154
+ // whole kept window.
155
+ const movedHistory = page.frames.some((frame) => frame.kind === "update" &&
156
+ frame.cursor !== state.after);
157
+ return (movedHistory && !page.hasMore
158
+ ? readSessionContext(options, sessionId, latestCursor ?? null)
147
159
  : Effect.succeed(undefined)).pipe(Effect.map((context) => [
148
160
  context === undefined
149
161
  ? page.frames
150
- : page.frames.map((frame) => frame.kind === "update"
162
+ : page.frames.map((frame) => frame.kind === "update" &&
163
+ frame.cursor === latestCursor
151
164
  ? Object.freeze({ ...frame, context })
152
165
  : frame),
153
166
  Option.some(next),
@@ -173,11 +186,14 @@ function decodeWatchInput(sessionId, input) {
173
186
  function watchSession(options, runtime, observation, sessionId, input) {
174
187
  return runtime.assertOpen.pipe(Effect.andThen(decodeWatchInput(sessionId, input)), Effect.flatMap((decoded) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
175
188
  const registration = yield* observation.register(decoded.sessionId);
176
- return yield* restore(runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, decoded.sessionId, decoded.options)), Effect.map((snapshot) => Object.freeze({
177
- snapshot,
178
- changes: subscriptionFrames(options, runtime, registration, decoded.sessionId, snapshot.cursor ?? undefined, "live", decoded.options.includeSandboxFailures === true).pipe(Stream.ensuring(Effect.sync(registration.close))),
179
- close: Effect.sync(registration.close),
180
- })))).pipe(Effect.onError(() => Effect.sync(registration.close)));
189
+ return yield* restore(runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, decoded.sessionId, decoded.options)), Effect.map((snapshot) => {
190
+ registration.setQueued(snapshot.activity.queue.state === "queued");
191
+ return Object.freeze({
192
+ snapshot,
193
+ changes: subscriptionFrames(options, runtime, registration, decoded.sessionId, snapshot.cursor ?? undefined, "live", decoded.options.includeSandboxFailures === true).pipe(Stream.ensuring(Effect.sync(registration.close))),
194
+ close: Effect.sync(registration.close),
195
+ });
196
+ }))).pipe(Effect.onError(() => Effect.sync(registration.close)));
181
197
  }))));
182
198
  }
183
199
  /**
@@ -335,6 +351,35 @@ export function openCoordinator(options, runtime) {
335
351
  call: (command, input) => runtime.assertOpen.pipe(Effect.andThen(runtime.extensions.call({ scope: "session", sessionId }, extensionId, command, input))),
336
352
  }),
337
353
  read: (input) => runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, sessionId, input))),
354
+ readSettlement: (commandId, timeoutMs = 0) => Effect.scoped(Effect.gen(function* () {
355
+ yield* runtime.assertOpen;
356
+ const decoded = yield* Effect.try({
357
+ try: () => ({
358
+ sessionId: decodeClientSessionId(sessionId),
359
+ ...requireDecoded(decodeClientSettlementRequest({ commandId, timeoutMs }), "Settlement request is invalid"),
360
+ }),
361
+ catch: (cause) => cause instanceof ShiitakeError
362
+ ? cause
363
+ : new ShiitakeError("contract", "invalid", "Settlement request is invalid"),
364
+ });
365
+ // Subscribe before reading: a commit racing the first read cannot
366
+ // be missed. Only settlement hints reach this registration.
367
+ const registration = yield* Effect.acquireRelease(observation.register(decoded.sessionId, "settlement"), (value) => Effect.sync(value.close));
368
+ const read = readCommandSettlement(options.store, decoded.sessionId, decoded.commandId);
369
+ const initial = yield* read;
370
+ if (initial !== undefined || decoded.timeoutMs === 0)
371
+ return initial;
372
+ return yield* Effect.gen(function* () {
373
+ for (;;) {
374
+ const hint = yield* registration.awaitSignal;
375
+ if (hint === "closed")
376
+ return yield* Effect.fail(new ShiitakeError("runtime", "closed", "Shiitake is closed"));
377
+ const settled = yield* read;
378
+ if (settled !== undefined)
379
+ return settled;
380
+ }
381
+ }).pipe(Effect.timeoutOption(decoded.timeoutMs), Effect.map(Option.getOrUndefined));
382
+ })),
338
383
  subscribe: (input) => subscribeSession(options, runtime, observation, sessionId, input),
339
384
  watch: (input) => watchSession(options, runtime, observation, sessionId, input),
340
385
  });
@@ -370,7 +415,47 @@ export function openCoordinator(options, runtime) {
370
415
  * is the host's authentication of a delegated Agent's reply; only then does
371
416
  * the model see the line inside the verified-reply tag.
372
417
  */
373
- export function acceptAddressedMail(runtime, mail, meta, reply) {
418
+ export function acceptAddressedMail(runtime, mail, meta, reply, stageMail, sessionGrantDelivery) {
419
+ if (mail.to.sessionId === undefined) {
420
+ if (reply !== undefined || mail.replyTo.length === 0)
421
+ throw new Error("Only delegated work can create a recipient Session");
422
+ return runtime.door.transact((admit) => {
423
+ const sessionId = runtime.createSessionWithinTransaction(mail.key);
424
+ const grants = sessionGrantDelivery?.(sessionId);
425
+ if (grants !== undefined)
426
+ runtime.stageSessionGrantsWithinTransaction(sessionId, grants);
427
+ const addressed = {
428
+ ...mail,
429
+ to: { ...mail.to, sessionId },
430
+ };
431
+ const request = {
432
+ sessionId,
433
+ requireExistingSession: true,
434
+ command: {
435
+ requestId: mail.key,
436
+ delivery: "followUp",
437
+ type: "prompt",
438
+ text: mail.content,
439
+ },
440
+ when: "now",
441
+ origin: { kind: "user" },
442
+ mail: addressed,
443
+ onAccepted: (_acceptedSessionId, commandId) => {
444
+ if (stageMail === undefined)
445
+ throw new Error("Recipient Agent mail outbox is unavailable");
446
+ stageMail(acceptedNotice(mail, sessionId, commandId, Date.now()));
447
+ },
448
+ };
449
+ const result = admit(meta === undefined ? request : { ...request, meta });
450
+ if (result.status !== "accepted")
451
+ throw new Error("Sessionless addressed mail was not admitted");
452
+ return {
453
+ status: "accepted",
454
+ commandId: result.commandId,
455
+ sessionId,
456
+ };
457
+ });
458
+ }
374
459
  return admitUserWork(runtime, mail.to.sessionId, {
375
460
  requestId: mail.key,
376
461
  delivery: reply === undefined ? "followUp" : "steer",
@@ -5,7 +5,9 @@ import type { ShiitakeOwnerAgent, ShiitakeOptions } from "./contracts.js";
5
5
  /** Adapt a Promise tool configuration for both initial open and reload. */
6
6
  export declare function liftTools(toolSource: NonNullable<ShiitakeOptions["tools"]>): NonNullable<ShiitakeEffectOptions["tools"]>;
7
7
  /** Run one Shiitake Effect through its sole Promise error adaptation. */
8
- export declare function runShiitakePromise<A, E>(effect: Effect.Effect<A, E>): Promise<A>;
8
+ export declare function runShiitakePromise<A, E>(effect: Effect.Effect<A, E>, options?: Readonly<{
9
+ signal?: AbortSignal;
10
+ }>): Promise<A>;
9
11
  /** Adapt one canonical Effect agent to Shiitake's ordinary web interface. */
10
12
  export declare function toPromiseAgent(agent: ShiitakeEffectAgent, suppliedAddress?: string): ShiitakeAgent;
11
13
  /**
@@ -19,8 +19,8 @@ export function liftTools(toolSource) {
19
19
  : { ...tools, execution: toolSource.execution };
20
20
  }
21
21
  /** Run one Shiitake Effect through its sole Promise error adaptation. */
22
- export function runShiitakePromise(effect) {
23
- return Effect.runPromiseExit(effect).then((exit) => {
22
+ export function runShiitakePromise(effect, options) {
23
+ return Effect.runPromiseExit(effect, options).then((exit) => {
24
24
  if (Exit.isSuccess(exit))
25
25
  return exit.value;
26
26
  const failure = Option.getOrUndefined(Cause.findErrorOption(exit.cause));
@@ -76,6 +76,7 @@ function sessionFacade(address, session) {
76
76
  });
77
77
  },
78
78
  read: (input) => runShiitakePromise(session.read(input)),
79
+ readSettlement: (commandId, options) => runShiitakePromise(session.readSettlement(commandId, options?.timeoutMs), options),
79
80
  subscribe: (input) => observationAsyncIterable(session.subscribe(input)),
80
81
  watch: (input) => runShiitakePromise(session.watch(input)).then((watch) => Object.freeze({
81
82
  snapshot: watch.snapshot,
@@ -2,7 +2,7 @@ import type { HostMetaOptions } from "./host-meta.js";
2
2
  import type { ExtensionRosterV0 } from "../extensions.js";
3
3
  import type { Api, AssistantMessage, Model, MuleStreamFunction, RetryPolicy } from "@fungi.computer/mule/effect";
4
4
  import type { CapabilitySource, ExecutionOptions, ModuleRegistration } from "@fungi.computer/mycelium";
5
- import type { GrantsDelivery, GrantsDeliveryResult, ToolAccess } from "./contracts.js";
5
+ import type { CommandSettlement, GrantsDelivery, GrantsDeliveryResult, ToolAccess } from "./contracts.js";
6
6
  import type { Sandbox } from "@fungi.computer/mycelium/effect";
7
7
  import * as Effect from "effect/Effect";
8
8
  import * as Stream from "effect/Stream";
@@ -26,6 +26,8 @@ export type ModelAcquisitionEffect = Readonly<{
26
26
  retryable: (message: AssistantMessage) => boolean;
27
27
  /** Owner-authored reason for this door's failure message, never SDK prose. */
28
28
  failureReason?: (message: AssistantMessage) => string | undefined;
29
+ /** Provider prose for this door's exact failed turn; never a diagnostic. */
30
+ providerError?: (message: AssistantMessage) => string | undefined;
29
31
  /** Effect finalizer for acquired model resources. */
30
32
  release: Effect.Effect<void, ShiitakeError>;
31
33
  }>;
@@ -128,7 +130,7 @@ export type ShiitakeEffectOptions = Readonly<{
128
130
  retry?: RetryPolicy;
129
131
  /**
130
132
  * Maximum silence between live provider stream events, in milliseconds.
131
- * Omitted means `piHttpIdleTimeoutMs` (Pi coding-agent's own default).
133
+ * Omitted uses Mule's model stream limits.
132
134
  */
133
135
  modelStreamIdleTimeoutMs?: number;
134
136
  }>;
@@ -189,6 +191,8 @@ export type ShiitakeEffectSession = Readonly<{
189
191
  }>;
190
192
  /** Read a bounded durable baseline. */
191
193
  read: (input?: ReadOptions) => Effect.Effect<SessionSnapshot, ShiitakeError>;
194
+ /** Exact command facts; timeout releases observation without cancelling work. */
195
+ readSettlement: (commandId: string, timeoutMs?: number) => Effect.Effect<CommandSettlement | undefined, ShiitakeError>;
192
196
  /** Subscribe to strict-after replay and live frames. */
193
197
  subscribe: (input: SubscribeOptions) => Stream.Stream<SessionFrame, ShiitakeError>;
194
198
  /** Open a race-safe baseline plus live changes resource. */
@@ -21,6 +21,8 @@ function asEffectAcquisition(acquisition) {
21
21
  };
22
22
  if (acquisition.failureReason !== undefined)
23
23
  shared = { ...shared, failureReason: acquisition.failureReason };
24
+ if (acquisition.providerError !== undefined)
25
+ shared = { ...shared, providerError: acquisition.providerError };
24
26
  if (acquisition.stream === undefined)
25
27
  return acquisition.retryAfterMs === undefined
26
28
  ? Object.freeze(shared)
@@ -2,8 +2,8 @@ import * as Effect from "effect/Effect";
2
2
  import { ShiitakeError } from "../errors.js";
3
3
  import type { SqliteOwner } from "./contracts.js";
4
4
  /** Finite process-local signal; durable owner state remains authoritative. */
5
- export type ObservationSignal = "closed" | "reset" | "durable" | "progress";
6
- export type ObservationReason = "durable" | "progress";
5
+ export type ObservationSignal = "closed" | "reset" | "durable" | "activity" | "progress";
6
+ export type ObservationReason = "durable" | "activity" | "progress" | "settlement";
7
7
  /** One scoped process-local observation registration. */
8
8
  export type ObservationRegistration = Readonly<{
9
9
  /** Inspect and consume one pending finite signal without waiting. */
@@ -12,17 +12,21 @@ export type ObservationRegistration = Readonly<{
12
12
  awaitSignal: Effect.Effect<ObservationSignal>;
13
13
  /** Release this registration without affecting durable work. */
14
14
  close: () => void;
15
+ /** Track whether this Session's current queue head is waiting on the Agent. */
16
+ setQueued: (queued: boolean) => void;
15
17
  }>;
16
18
  /** Process-local wake coordination; durable facts remain authoritative. */
17
19
  export type ObservationKernel = Readonly<{
18
20
  /** Register before the caller's first durable read. */
19
- register: (sessionId: string) => Effect.Effect<ObservationRegistration, ShiitakeError>;
21
+ register: (sessionId: string, interest?: "settlement") => Effect.Effect<ObservationRegistration, ShiitakeError>;
20
22
  /** Close every registration and release this agent's owner publisher. */
21
23
  close: Effect.Effect<void>;
22
24
  }>;
23
25
  /** Combine coalesced reasons without allowing a progress hint to hide a commit. */
24
26
  export declare function mergeObservationSignals(first: ObservationSignal, second: ObservationSignal): ObservationSignal;
25
- /** Notify owner observers; null identifies an Agent-wide execution change. */
27
+ /** Notify observers of one Session's durable or process-local change. */
26
28
  export declare function publishOwnerObservation(store: SqliteOwner, sessionId: string | null, reason?: ObservationReason): void;
29
+ /** Wake queued Sessions when this Agent's execution slot changes. */
30
+ export declare function publishAgentExecutionChange(store: SqliteOwner): void;
27
31
  /** Open one coalescing process-local observation kernel per agent. */
28
32
  export declare function openObservationKernel(store: SqliteOwner): ObservationKernel;
@@ -12,6 +12,8 @@ export function mergeObservationSignals(first, second) {
12
12
  return "reset";
13
13
  if (first === "durable" || second === "durable")
14
14
  return "durable";
15
+ if (first === "activity" || second === "activity")
16
+ return "activity";
15
17
  return "progress";
16
18
  }
17
19
  function queueObservation(listener, reason) {
@@ -25,7 +27,9 @@ function queueObservation(listener, reason) {
25
27
  listener.pendingReason =
26
28
  listener.pendingReason === "durable" || reason === "durable"
27
29
  ? "durable"
28
- : "progress";
30
+ : listener.pendingReason === "activity" || reason === "activity"
31
+ ? "activity"
32
+ : "progress";
29
33
  }
30
34
  function deliverObservation(listener, reason) {
31
35
  if (listener.closed || listener.reset)
@@ -46,14 +50,14 @@ function deliverObservation(listener, reason) {
46
50
  listener.reset = true;
47
51
  }
48
52
  }
49
- /** Notify owner observers; null identifies an Agent-wide execution change. */
53
+ /** Notify observers of one Session's durable or process-local change. */
50
54
  export function publishOwnerObservation(store, sessionId, reason = "durable") {
51
55
  const publishers = ownerPublishers.get(store);
52
56
  if (publishers === undefined)
53
57
  return;
54
58
  for (const publish of publishers) {
55
59
  try {
56
- publish(sessionId, reason);
60
+ publish({ kind: "session", sessionId, reason });
57
61
  }
58
62
  catch (cause) {
59
63
  reportCatchFailure("owner-observation-publish", cause);
@@ -61,6 +65,20 @@ export function publishOwnerObservation(store, sessionId, reason = "durable") {
61
65
  }
62
66
  }
63
67
  }
68
+ /** Wake queued Sessions when this Agent's execution slot changes. */
69
+ export function publishAgentExecutionChange(store) {
70
+ const publishers = ownerPublishers.get(store);
71
+ if (publishers === undefined)
72
+ return;
73
+ for (const publish of publishers) {
74
+ try {
75
+ publish({ kind: "agent-execution" });
76
+ }
77
+ catch (cause) {
78
+ reportCatchFailure("owner-execution-change-publish", cause);
79
+ }
80
+ }
81
+ }
64
82
  /** Open one coalescing process-local observation kernel per agent. */
65
83
  export function openObservationKernel(store) {
66
84
  const sessions = new Map();
@@ -79,7 +97,7 @@ export function openObservationKernel(store) {
79
97
  listener.pendingCount = 0;
80
98
  const reason = listener.pendingReason;
81
99
  listener.pendingReason = undefined;
82
- return reason === "durable" ? "durable" : "progress";
100
+ return reason ?? "progress";
83
101
  };
84
102
  const closeListener = (listener) => {
85
103
  if (listener.closed)
@@ -104,26 +122,39 @@ export function openObservationKernel(store) {
104
122
  }
105
123
  }
106
124
  };
107
- const publish = (sessionId, reason) => {
108
- const listeners = sessionId === null ? active : sessions.get(sessionId);
125
+ const publish = (publication) => {
126
+ if (publication.kind === "agent-execution") {
127
+ for (const listener of active)
128
+ if (listener.queued && !listener.settlementOnly)
129
+ deliverObservation(listener, "activity");
130
+ return;
131
+ }
132
+ const listeners = publication.sessionId === null
133
+ ? active
134
+ : sessions.get(publication.sessionId);
109
135
  if (listeners === undefined)
110
136
  return;
111
- for (const listener of listeners)
112
- deliverObservation(listener, reason);
137
+ for (const listener of listeners) {
138
+ if (listener.settlementOnly && publication.reason !== "settlement")
139
+ continue;
140
+ deliverObservation(listener, publication.reason === "settlement" ? "durable" : publication.reason);
141
+ }
113
142
  };
114
143
  publishers.add(publish);
115
144
  ownerPublishers.set(store, publishers);
116
145
  return Object.freeze({
117
- register: (sessionId) => Effect.suspend(() => {
146
+ register: (sessionId, interest) => Effect.suspend(() => {
118
147
  if (released)
119
148
  return Effect.fail(new ShiitakeError("runtime", "closed", "Shiitake is closed"));
120
149
  if (active.size >= MAX_OBSERVATIONS)
121
150
  return Effect.fail(new ShiitakeError("runtime", "rejected", "Observation capacity is exhausted"));
122
151
  const listener = {
123
152
  sessionId,
153
+ settlementOnly: interest === "settlement",
124
154
  closed: false,
125
155
  pendingCount: 0,
126
156
  pendingReason: undefined,
157
+ queued: true,
127
158
  reset: false,
128
159
  wake: undefined,
129
160
  };
@@ -153,6 +184,10 @@ export function openObservationKernel(store) {
153
184
  close: () => {
154
185
  closeListener(listener);
155
186
  },
187
+ setQueued: (queued) => {
188
+ if (!listener.closed)
189
+ listener.queued = queued;
190
+ },
156
191
  }));
157
192
  }),
158
193
  close: Effect.sync(() => {
@@ -14,5 +14,5 @@ export declare function openPromiseAgent(options: ShiitakeOptions, make: (option
14
14
  export declare function openPromiseHost(options: ShiitakeOptions, mailRoom: BirdDogEffect, openHost: (options: ShiitakeEffectOptions, mailRoom: BirdDogEffect) => Effect.Effect<ShiitakeEffectHost, ShiitakeError>): Promise<Readonly<{
15
15
  agent: ShiitakeOwnerAgent;
16
16
  readSettlement: (sessionId: string, commandId: string) => Promise<CommandSettlement | undefined>;
17
- acceptMail: (mail: BirdDogMail, meta?: Json, reply?: VerifiedAgentReply) => Promise<Accepted>;
17
+ acceptMail: (mail: BirdDogMail, meta?: Json, reply?: VerifiedAgentReply, stageAcceptedNotice?: (mail: BirdDogMail) => void) => Promise<Accepted>;
18
18
  }>>;
@@ -155,6 +155,6 @@ export function openPromiseHost(options, mailRoom, openHost) {
155
155
  return runShiitakePromise(liftRuntime(options).pipe(Effect.flatMap((runtime) => openHost(liftOptions(options, runtime), mailRoom)))).then(({ agent, acceptMail, readSettlement }) => Object.freeze({
156
156
  agent: toPromiseOwner(agent),
157
157
  readSettlement: (sessionId, commandId) => runShiitakePromise(readSettlement(sessionId, commandId)),
158
- acceptMail: (mail, meta, reply) => runShiitakePromise(acceptMail(mail, meta, reply)),
158
+ acceptMail: (mail, meta, reply, stageAcceptedNotice) => runShiitakePromise(acceptMail(mail, meta, reply, stageAcceptedNotice)),
159
159
  }));
160
160
  }
@@ -1,4 +1,4 @@
1
- import { muleEffect, piCodingAgentRetryDefaults, piHttpIdleTimeoutMs, } from "@fungi.computer/mule/effect";
1
+ import { muleEffect, piCodingAgentRetryDefaults, } from "@fungi.computer/mule/effect";
2
2
  import { make as makeMycelium } from "@fungi.computer/mycelium/effect";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Cause from "effect/Cause";
@@ -33,13 +33,21 @@ export class ToolSetupError extends ShiitakeError {
33
33
  * Map Mule's retry transitions onto Woodstock's retained `live_retry` facts.
34
34
  * A started attempt ends the backoff countdown before streaming resumes.
35
35
  */
36
- function toLiveRetryEvent(event) {
36
+ function toLiveRetryEvent(event, model) {
37
37
  if (event.type === "retry_attempt_started")
38
38
  return {
39
39
  type: "auto_retry_attempt",
40
40
  run: event.run,
41
41
  attempt: event.attempt,
42
42
  };
43
+ // Mule's retry text is Pi's private error body. Woodstock's retry journal
44
+ // feeds status and recovery, not the owner's failed-turn conversation.
45
+ const safeReason = ("Model request failed. Retry the request. " +
46
+ unavailableRunReason({
47
+ runId: event.run.value,
48
+ provider: model.provider,
49
+ model: model.id,
50
+ })).slice(0, 200);
43
51
  return event.type === "retry_scheduled"
44
52
  ? {
45
53
  type: "auto_retry_start",
@@ -47,7 +55,7 @@ function toLiveRetryEvent(event) {
47
55
  attempt: event.attempt,
48
56
  maxAttempts: event.maxAttempts,
49
57
  delayMs: event.delayMs,
50
- errorMessage: event.errorMessage,
58
+ errorMessage: safeReason,
51
59
  }
52
60
  : event.finalError === undefined
53
61
  ? {
@@ -61,7 +69,7 @@ function toLiveRetryEvent(event) {
61
69
  run: event.run,
62
70
  attempt: event.attempt,
63
71
  success: event.success,
64
- finalError: event.finalError,
72
+ finalError: safeReason,
65
73
  };
66
74
  }
67
75
  /** Acquire the run's tools for exactly one Session, so hosts can project per Session. */
@@ -139,6 +147,7 @@ export function runAcceptedCommand(options, prepared, executionContract, extensi
139
147
  let resume = prepared.resume;
140
148
  const settings = openCompactionSettings(options.compaction)(acquired.model);
141
149
  let failureReason;
150
+ let failedModelAttempts = 0;
142
151
  const run = yield* runWithCompaction({
143
152
  store: options.store,
144
153
  sessionId: prepared.command.sessionId,
@@ -151,6 +160,7 @@ export function runAcceptedCommand(options, prepared, executionContract, extensi
151
160
  messages: prepared.messages,
152
161
  resuming: resume !== undefined,
153
162
  run: (workingMessages, betweenTurns) => {
163
+ failedModelAttempts = 0;
154
164
  const continuation = resume;
155
165
  resume = undefined;
156
166
  // A failed lifecycle notification precedes Mule's returned outcome
@@ -162,11 +172,21 @@ export function runAcceptedCommand(options, prepared, executionContract, extensi
162
172
  failedEnd = event;
163
173
  return Effect.void;
164
174
  }
175
+ if (event.type === "message_end" &&
176
+ event.value.role === "assistant" &&
177
+ event.value.stopReason === "error") {
178
+ const providerError = acquired.providerError?.(event.value);
179
+ if (providerError !== undefined)
180
+ return recorder({
181
+ ...event,
182
+ value: { ...event.value, providerError },
183
+ });
184
+ }
165
185
  return recorder(event);
166
186
  };
167
187
  const input = {
168
188
  model: acquired.model,
169
- modelStreamIdleTimeoutMs: options.modelStreamIdleTimeoutMs ?? piHttpIdleTimeoutMs,
189
+ modelStreamIdleTimeoutMs: options.modelStreamIdleTimeoutMs,
170
190
  retry: options.retry ?? piCodingAgentRetryDefaults,
171
191
  retryAfterMs: acquired.retryAfterMs,
172
192
  retryable: acquired.retryable,
@@ -191,7 +211,11 @@ export function runAcceptedCommand(options, prepared, executionContract, extensi
191
211
  phase: "control",
192
212
  message: error.message,
193
213
  }))),
194
- onRetry: (event) => recordLiveRetryEvent(options.store, accepted, toLiveRetryEvent(event), event.turn),
214
+ onRetry: (event) => {
215
+ if (event.type === "retry_finished" && !event.success)
216
+ failedModelAttempts = event.attempt + 1;
217
+ return recordLiveRetryEvent(options.store, accepted, toLiveRetryEvent(event, acquired.model), event.turn);
218
+ },
195
219
  };
196
220
  return muleEffect
197
221
  .run(acquired.stream === undefined
@@ -201,8 +225,13 @@ export function runAcceptedCommand(options, prepared, executionContract, extensi
201
225
  if (failedEnd === undefined)
202
226
  return Effect.void;
203
227
  if (Exit.isSuccess(exit)) {
204
- failureReason = (acquired.failureReason?.(exit.value.message) ??
205
- returnedFailureReason(exit.value)).slice(0, 200);
228
+ const cause = exit.value.modelLimitReason ??
229
+ acquired.failureReason?.(exit.value.message) ??
230
+ returnedFailureReason(exit.value);
231
+ failureReason = (failedModelAttempts > 1 &&
232
+ exit.value.modelLimitReason === undefined
233
+ ? `The model failed ${String(failedModelAttempts)} times on this conversation: ${cause}`
234
+ : cause).slice(0, 200);
206
235
  return recorder(failedEnd, failureReason);
207
236
  }
208
237
  const error = Cause.findErrorOption(exit.cause);
@@ -2,6 +2,7 @@ import { type BirdDogEffect } from "@fungi.computer/bird-dog/effect";
2
2
  import { type WatchdogQueueHead } from "@fungi.computer/watchdog/effect";
3
3
  import * as Effect from "effect/Effect";
4
4
  import type { AgentRunningWork } from "../client.js";
5
+ import type { GrantsDelivery } from "./contracts.js";
5
6
  import { ShiitakeError } from "../errors.js";
6
7
  import { type AdmissionDoor } from "./admission-door.js";
7
8
  import type { ShiitakeEffectOptions } from "./effect-contracts.js";
@@ -19,6 +20,10 @@ export type RuntimeCoordinator = Readonly<{
19
20
  beginClose: Effect.Effect<void>;
20
21
  /** The one accepted-command door every prompt producer admits through. */
21
22
  door: AdmissionDoor;
23
+ /** Reserve a Session within the admission door's enclosing transaction. */
24
+ createSessionWithinTransaction: (requestId: string) => string;
25
+ /** Write new-Session grants through the enclosing admission transaction. */
26
+ stageSessionGrantsWithinTransaction: (sessionId: string, delivery: GrantsDelivery) => void;
22
27
  /** Storage-pressure check for new Sessions and new prompts. */
23
28
  storage: StorageAdmission;
24
29
  recordAcceptedCancellation: RecordAcceptedCancellation;
@@ -3,7 +3,7 @@ import { WatchdogError } from "@fungi.computer/watchdog";
3
3
  import { makeRuntime as makeWatchdogRuntime, } from "@fungi.computer/watchdog/effect";
4
4
  import * as Effect from "effect/Effect";
5
5
  import * as Latch from "effect/Latch";
6
- import { publishOwnerObservation } from "./observation-kernel.js";
6
+ import { publishAgentExecutionChange } from "./observation-kernel.js";
7
7
  import { ShiitakeError } from "../errors.js";
8
8
  import { agentTurnRecovery, executeAcceptedRun, } from "./accepted-run-executor.js";
9
9
  import { openAdmissionDoor } from "./admission-door.js";
@@ -11,7 +11,7 @@ import { openCompactionSettings } from "./compaction/settings.js";
11
11
  import { admitExtensionRosterV0, } from "./extension-roster.js";
12
12
  import { coreConsumer, openExtensionRuntime, } from "./extension-runtime.js";
13
13
  import { reconcileRuntime } from "./runtime-reconciliation.js";
14
- import { advanceCursor, prepareExtensionStore, projectSessionRun, openStorageAdmission, readCursor, } from "./woodstock/index.js";
14
+ import { advanceCursor, createSessionWithinTransaction, deliverSessionGrants, prepareExtensionStore, projectSessionRun, openStorageAdmission, readCursor, } from "./woodstock/index.js";
15
15
  function runtimeFailure(message) {
16
16
  return new ShiitakeError("runtime", "rejected", message);
17
17
  }
@@ -64,7 +64,7 @@ export function openRuntimeCoordinator(options, coreDigest, admitted, mailRoom)
64
64
  if (runningWork.kind === "session")
65
65
  activeSessions.add(job.queue);
66
66
  // A different Session's wait changes when this executor changes.
67
- publishOwnerObservation(options.store, null);
67
+ publishAgentExecutionChange(options.store);
68
68
  }), () => job.lane === options.hostJobs?.lane
69
69
  ? (hostExecutor.current?.execute(job) ??
70
70
  Effect.fail(new WatchdogError("execution_failed")))
@@ -82,7 +82,7 @@ export function openRuntimeCoordinator(options, coreDigest, admitted, mailRoom)
82
82
  if (job.lane !== options.hostJobs?.lane)
83
83
  activeSessions.delete(job.queue);
84
84
  runningSlots.delete(job.lane === options.hostJobs?.lane ? "host" : "serial");
85
- publishOwnerObservation(options.store, null);
85
+ publishAgentExecutionChange(options.store);
86
86
  })),
87
87
  },
88
88
  wake: {
@@ -253,6 +253,10 @@ export function openRuntimeCoordinator(options, coreDigest, admitted, mailRoom)
253
253
  : Effect.fail(new ShiitakeError("runtime", "closed", "Shiitake is closed"))),
254
254
  beginClose,
255
255
  door,
256
+ createSessionWithinTransaction: (requestId) => createSessionWithinTransaction(options.store, requestId, storage.admitNew),
257
+ stageSessionGrantsWithinTransaction: (sessionId, delivery) => {
258
+ deliverSessionGrants(options.store, sessionId, delivery);
259
+ },
256
260
  storage,
257
261
  recordAcceptedCancellation: ({ jobId, cancellationId, onlyIfPending }) => watchdog.transactional.recordCancellationIntent({
258
262
  jobId,
@@ -5,6 +5,7 @@ import type { VerifiedAgentReply } from "./woodstock/index.js";
5
5
  import type { BirdDogEffect } from "@fungi.computer/bird-dog/effect";
6
6
  import * as Effect from "effect/Effect";
7
7
  import type { Accepted } from "../client.js";
8
+ import type { GrantsDelivery } from "./contracts.js";
8
9
  import { ShiitakeError } from "../errors.js";
9
10
  import type { ShiitakeEffectOptions, ShiitakeEffectOwner } from "./effect-contracts.js";
10
11
  /** Private host composition with the addressed-mail ingress kept off Session. */
@@ -13,7 +14,7 @@ export type RuntimeCore = Readonly<{
13
14
  readSettlement: (sessionId: string, commandId: string) => Effect.Effect<CommandSettlement | undefined, ShiitakeError>;
14
15
  /** Owner-local execution projection; never served by the remote client. */
15
16
  activeSessions: Effect.Effect<readonly string[]>;
16
- acceptMail: (mail: BirdDogMail, meta?: Json, reply?: VerifiedAgentReply) => Effect.Effect<Accepted, ShiitakeError>;
17
+ acceptMail: (mail: BirdDogMail, meta?: Json, reply?: VerifiedAgentReply, stageAcceptedNotice?: (mail: BirdDogMail) => void, sessionGrantDelivery?: (sessionId: string) => GrantsDelivery) => Effect.Effect<Accepted, ShiitakeError>;
17
18
  }>;
18
19
  /** Open Runtime Core, optionally joining its owner-local BirdDog mail room. */
19
20
  export declare function openRuntimeCore(options: ShiitakeEffectOptions, mailRoom?: BirdDogEffect): Effect.Effect<RuntimeCore, ShiitakeError>;
@@ -34,7 +34,12 @@ export function openRuntimeCore(options, mailRoom) {
34
34
  agent: owner,
35
35
  readSettlement: (sessionId, commandId) => runtime.assertOpen.pipe(Effect.andThen(readCommandSettlement(options.store, sessionId, commandId))),
36
36
  activeSessions: runtime.activeSessions,
37
- acceptMail: (mail, meta, reply) => acceptAddressedMail(runtime, mail, meta, reply),
37
+ acceptMail: (mail, meta, reply, stageAcceptedNotice, sessionGrantDelivery) => acceptAddressedMail(runtime, mail, meta, reply, stageAcceptedNotice ??
38
+ ((notice) => {
39
+ if (mailRoom?.postMail === undefined)
40
+ throw new Error("Recipient Agent mail outbox is unavailable");
41
+ Effect.runSync(mailRoom.postMail(notice));
42
+ }), sessionGrantDelivery),
38
43
  });
39
44
  });
40
45
  }