@fungi.computer/shiitake 0.3.1 → 0.5.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 +19 -18
  2. package/dist/agent-control.d.ts +24 -19
  3. package/dist/agent-control.js +69 -128
  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 +151 -17
  11. package/dist/delegation.js +174 -41
  12. package/dist/effect.d.ts +1 -1
  13. package/dist/fleet.d.ts +37 -10
  14. package/dist/fleet.js +217 -48
  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 +9 -2
  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 +4 -2
  44. package/dist/internal/woodstock/fleet-store.js +70 -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,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
  }
@@ -7,4 +7,4 @@ import type { ShiitakeEffectOptions } from "./effect-contracts.js";
7
7
  * context or its context cannot be read; a window the host cannot describe is
8
8
  * null, never guessed.
9
9
  */
10
- export declare function readSessionContext(options: ShiitakeEffectOptions, sessionId: string): Effect.Effect<SessionContext | undefined>;
10
+ export declare function readSessionContext(options: ShiitakeEffectOptions, sessionId: string, cursor: string | null, cache?: boolean): Effect.Effect<SessionContext | undefined>;
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect";
2
2
  import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
3
3
  import { reportCatchFailure } from "./failure.js";
4
4
  import { buildSessionContext, readSessionContextPath, readSessionModelPreference, } from "./woodstock/index.js";
5
+ const contextByOwner = new WeakMap();
5
6
  /**
6
7
  * The context size compaction measures against the model's window, for the
7
8
  * Session's settled context: the next run starts from it. Null before the
@@ -18,28 +19,98 @@ function settledContextTokens(store, sessionId) {
18
19
  * context or its context cannot be read; a window the host cannot describe is
19
20
  * null, never guessed.
20
21
  */
21
- export function readSessionContext(options, sessionId) {
22
+ export function readSessionContext(options, sessionId, cursor, cache = true) {
22
23
  return Effect.gen(function* () {
23
- // A context too large to read safely has no footer figure.
24
- const read = yield* Effect.result(Effect.try({
25
- try: () => ({
26
- tokens: settledContextTokens(options.store, sessionId),
27
- preference: readSessionModelPreference(options.store, sessionId),
28
- }),
24
+ const savedPreference = yield* Effect.result(Effect.try({
25
+ try: () => readSessionModelPreference(options.store, sessionId),
29
26
  catch: (error) => error,
30
27
  }));
31
- if (read._tag === "Failure" || read.success.tokens === null)
28
+ if (savedPreference._tag === "Failure") {
29
+ reportCatchFailure("session-context.preference", savedPreference.failure);
32
30
  return undefined;
33
- const { tokens, preference } = read.success;
31
+ }
32
+ const preference = savedPreference.success;
33
+ const preferenceKey = JSON.stringify(preference);
34
+ const ownerCache = contextByOwner.get(options) ?? new Map();
35
+ contextByOwner.set(options, ownerCache);
36
+ const previous = cache ? ownerCache.get(sessionId) : undefined;
34
37
  const describe = options.model.describe;
35
- if (describe === undefined)
36
- return Object.freeze({ tokens, window: null });
38
+ if (previous?.cursor === cursor && previous.preference === preferenceKey) {
39
+ if (previous.value === undefined || describe === undefined)
40
+ return previous.value;
41
+ const previousValue = previous.value;
42
+ // A null Session override follows a mutable Agent default. Check the
43
+ // selected window without decoding history again; a changed window is
44
+ // a distinct effective preference for this cursor.
45
+ return yield* describe({ sessionId, preference }).pipe(Effect.map(({ contextWindow }) => {
46
+ if (previousValue.window === contextWindow)
47
+ return previousValue;
48
+ const value = Object.freeze({
49
+ tokens: previousValue.tokens,
50
+ window: contextWindow,
51
+ });
52
+ if (cache && ownerCache.get(sessionId)?.cursor === cursor)
53
+ ownerCache.set(sessionId, {
54
+ cursor,
55
+ preference: preferenceKey,
56
+ value,
57
+ });
58
+ return value;
59
+ }), Effect.catch((error) => Effect.sync(() => {
60
+ reportCatchFailure("session-context.describe", error);
61
+ return previousValue;
62
+ })));
63
+ }
64
+ // At most one cached whole-window read per Session, cursor, and
65
+ // preference: a late describe may publish only while this cursor still
66
+ // owns the cache entry. Reads before the tip bypass the cache and each read
67
+ // the whole window. A cold-cache connection also reads the whole kept
68
+ // window.
69
+ const contextRead = yield* Effect.result(Effect.try({
70
+ try: () => settledContextTokens(options.store, sessionId),
71
+ catch: (error) => error,
72
+ }));
73
+ if (contextRead._tag === "Failure") {
74
+ reportCatchFailure("session-context.figure", contextRead.failure);
75
+ return undefined;
76
+ }
77
+ const tokens = contextRead.success;
78
+ if (tokens === null) {
79
+ if (cache)
80
+ ownerCache.set(sessionId, {
81
+ cursor,
82
+ preference: preferenceKey,
83
+ value: undefined,
84
+ });
85
+ return undefined;
86
+ }
87
+ if (describe === undefined) {
88
+ const value = Object.freeze({ tokens, window: null });
89
+ if (cache)
90
+ ownerCache.set(sessionId, { cursor, preference: preferenceKey, value });
91
+ return value;
92
+ }
93
+ // Publish the decoded figure before yielding to the host's model
94
+ // description. Concurrent watchers then reuse these tokens rather than
95
+ // decoding the same Session window while `describe` is in flight.
96
+ if (cache)
97
+ ownerCache.set(sessionId, {
98
+ cursor,
99
+ preference: preferenceKey,
100
+ value: Object.freeze({ tokens, window: null }),
101
+ });
37
102
  // A null Session override follows a mutable Agent default. Resolve through
38
103
  // the host's turn-model selector on every read, without acquiring a turn.
39
- const window = yield* describe({ sessionId, preference }).pipe(Effect.map(({ contextWindow }) => contextWindow), Effect.catch((error) => Effect.sync(() => {
104
+ const window = yield* describe({
105
+ sessionId,
106
+ preference,
107
+ }).pipe(Effect.map(({ contextWindow }) => contextWindow), Effect.catch((error) => Effect.sync(() => {
40
108
  reportCatchFailure("session-context.describe", error);
41
109
  return null;
42
110
  })));
43
- return Object.freeze({ tokens, window });
111
+ const value = Object.freeze({ tokens, window });
112
+ if (cache && ownerCache.get(sessionId)?.cursor === cursor)
113
+ ownerCache.set(sessionId, { cursor, preference: preferenceKey, value });
114
+ return value;
44
115
  });
45
116
  }
@@ -5,13 +5,18 @@ import * as Schema from "effect/Schema";
5
5
  * the mail it sent: the replying Agent and Session plus BirdDog's settlement
6
6
  * facts. Only `acceptMail` stores it, and only the host passes it.
7
7
  */
8
- export declare const VerifiedAgentReplySchema: Schema.Struct<{
8
+ export declare const VerifiedAgentReplySchema: Schema.Union<readonly [Schema.Struct<{
9
9
  readonly commandId: Schema.String;
10
10
  readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
11
11
  readonly diagnostic: Schema.optionalKey<Schema.String>;
12
12
  readonly agentId: Schema.String;
13
13
  readonly sessionId: Schema.String;
14
- }>;
14
+ }>, Schema.Struct<{
15
+ readonly outcome: Schema.Literal<"undeliverable">;
16
+ readonly reason: Schema.Literals<readonly ["recipient_offline", "recipient_left"]>;
17
+ readonly agentId: Schema.String;
18
+ readonly sessionId: Schema.optionalKey<Schema.String>;
19
+ }>]>;
15
20
  /** A delegated Agent's reply after the admitting host authenticated it. */
16
21
  export type VerifiedAgentReply = typeof VerifiedAgentReplySchema.Type;
17
22
  /** The tag that marks a host-verified Agent reply in model context. */
@@ -1,4 +1,4 @@
1
- import { BirdDogReplySchema } from "@fungi.computer/bird-dog/effect";
1
+ import { SettledBirdDogReplySchema, UndeliverableBirdDogReplySchema, } from "@fungi.computer/bird-dog/effect";
2
2
  import * as Schema from "effect/Schema";
3
3
  const Identifier = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
4
4
  /**
@@ -6,11 +6,18 @@ const Identifier = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength
6
6
  * the mail it sent: the replying Agent and Session plus BirdDog's settlement
7
7
  * facts. Only `acceptMail` stores it, and only the host passes it.
8
8
  */
9
- export const VerifiedAgentReplySchema = Schema.Struct({
10
- agentId: Identifier,
11
- sessionId: Identifier,
12
- ...BirdDogReplySchema.fields,
13
- });
9
+ export const VerifiedAgentReplySchema = Schema.Union([
10
+ Schema.Struct({
11
+ agentId: Identifier,
12
+ sessionId: Identifier,
13
+ ...SettledBirdDogReplySchema.fields,
14
+ }),
15
+ Schema.Struct({
16
+ agentId: Identifier,
17
+ sessionId: Schema.optionalKey(Identifier),
18
+ ...UndeliverableBirdDogReplySchema.fields,
19
+ }),
20
+ ]);
14
21
  /** The tag that marks a host-verified Agent reply in model context. */
15
22
  export const AGENT_REPLY_TAG = "fungi_agent_reply";
16
23
  // `<` before the tag name, allowing the spacing and case a model would still read as the tag.
@@ -27,14 +34,16 @@ function attribute(value) {
27
34
  .replaceAll(">", "&gt;");
28
35
  }
29
36
  function opening(reply) {
30
- const attributes = [
31
- ["agent_id", reply.agentId],
32
- ["session_id", reply.sessionId],
33
- ["command_id", reply.commandId],
34
- ["outcome", reply.outcome],
35
- ];
36
- if (reply.diagnostic !== undefined)
37
+ const attributes = [["agent_id", reply.agentId]];
38
+ if (reply.sessionId !== undefined)
39
+ attributes.push(["session_id", reply.sessionId]);
40
+ if (reply.outcome !== "undeliverable")
41
+ attributes.push(["command_id", reply.commandId]);
42
+ attributes.push(["outcome", reply.outcome]);
43
+ if (reply.outcome !== "undeliverable" && reply.diagnostic !== undefined)
37
44
  attributes.push(["diagnostic", reply.diagnostic]);
45
+ if (reply.outcome === "undeliverable")
46
+ attributes.push(["reason", reply.reason]);
38
47
  const rendered = attributes
39
48
  .map(([name, value]) => `${name}="${attribute(value)}"`)
40
49
  .join(" ");
@@ -27,10 +27,12 @@ export declare function createFleetDelegationStore(storage: WatchdogSqliteOwner)
27
27
  read: (requestId: string) => {
28
28
  readonly [x: string]: string | number | ArrayBuffer | null;
29
29
  } | undefined;
30
- /** Every delegation this owner has sent and had accepted, newest first. */
30
+ /** Every recipient-accepted delegation, newest first. */
31
31
  sent: () => readonly {
32
32
  readonly [x: string]: string | number | ArrayBuffer | null;
33
33
  }[];
34
34
  reserve(requestId: string, input: string, mail: string): void;
35
- recordReceipt(requestId: string, receipt: string): void;
35
+ recordReceipt(requestId: string, receipt: string, expectedMail: string, acceptedMail: string): void;
36
+ recordHeld(requestId: string): void;
37
+ recordUndeliverable(requestId: string): boolean;
36
38
  };
@@ -81,24 +81,88 @@ export function createFleetDirectoryStore(sql) {
81
81
  /** Durable correspondence is frozen before delivery; retries reuse the same row. */
82
82
  export function createFleetDelegationStore(storage) {
83
83
  storage.sql.exec(`CREATE TABLE IF NOT EXISTS shiitake_fleet_delegations (
84
- request_id TEXT PRIMARY KEY, input TEXT NOT NULL, mail TEXT NOT NULL, receipt TEXT
84
+ request_id TEXT PRIMARY KEY, input TEXT NOT NULL, mail TEXT NOT NULL,
85
+ receipt TEXT
86
+ ) STRICT`);
87
+ storage.sql.exec(`CREATE TABLE IF NOT EXISTS shiitake_fleet_held_delegations (
88
+ request_id TEXT PRIMARY KEY
89
+ ) STRICT`);
90
+ storage.sql
91
+ .exec(`CREATE TABLE IF NOT EXISTS shiitake_fleet_undeliverable_delegations (
92
+ request_id TEXT PRIMARY KEY
85
93
  ) STRICT`);
86
94
  return {
87
95
  read: (requestId) => storage.sql
88
- .exec("SELECT input, mail, receipt FROM shiitake_fleet_delegations WHERE request_id = ?", requestId)
96
+ .exec(`SELECT delegation.input, delegation.mail,
97
+ CASE
98
+ WHEN terminal.request_id IS NOT NULL THEN 'undeliverable'
99
+ WHEN held.request_id IS NOT NULL THEN 'held'
100
+ WHEN delegation.receipt IS NOT NULL THEN 'accepted'
101
+ ELSE 'pending'
102
+ END AS state,
103
+ delegation.receipt
104
+ FROM shiitake_fleet_delegations AS delegation
105
+ LEFT JOIN shiitake_fleet_held_delegations AS held
106
+ ON held.request_id = delegation.request_id
107
+ LEFT JOIN shiitake_fleet_undeliverable_delegations AS terminal
108
+ ON terminal.request_id = delegation.request_id
109
+ WHERE delegation.request_id = ?`, requestId)
89
110
  .toArray()[0],
90
- /** Every delegation this owner has sent and had accepted, newest first. */
111
+ /** Every recipient-accepted delegation, newest first. */
91
112
  sent: () => storage.sql
92
- .exec("SELECT input, mail, receipt FROM shiitake_fleet_delegations WHERE receipt IS NOT NULL ORDER BY rowid DESC")
113
+ .exec(`SELECT input, mail, 'accepted' AS state, receipt
114
+ FROM shiitake_fleet_delegations
115
+ WHERE receipt IS NOT NULL
116
+ ORDER BY rowid DESC`)
93
117
  .toArray(),
94
118
  reserve(requestId, input, mail) {
95
119
  storage.transactionSync(() => {
96
120
  storage.sql.exec("INSERT OR IGNORE INTO shiitake_fleet_delegations(request_id, input, mail) VALUES (?, ?, ?)", requestId, input, mail);
97
121
  });
98
122
  },
99
- recordReceipt(requestId, receipt) {
123
+ recordReceipt(requestId, receipt, expectedMail, acceptedMail) {
100
124
  storage.transactionSync(() => {
101
- storage.sql.exec("UPDATE shiitake_fleet_delegations SET receipt = ? WHERE request_id = ?", receipt, requestId);
125
+ const row = storage.sql
126
+ .exec("SELECT mail, receipt FROM shiitake_fleet_delegations WHERE request_id = ?", requestId)
127
+ .toArray()[0];
128
+ if (row === undefined)
129
+ throw new Error("Cannot record acceptance without frozen delegation mail");
130
+ if (row.receipt !== null) {
131
+ if (row.receipt === receipt && row.mail === acceptedMail)
132
+ return;
133
+ throw new Error("Delegation acceptance conflicts with its recorded receipt");
134
+ }
135
+ if (row.mail !== expectedMail)
136
+ throw new Error("Delegation mail changed before acceptance was recorded");
137
+ storage.sql.exec("UPDATE shiitake_fleet_delegations SET mail = ?, receipt = ? WHERE request_id = ? AND receipt IS NULL", acceptedMail, receipt, requestId);
138
+ storage.sql.exec("DELETE FROM shiitake_fleet_held_delegations WHERE request_id = ?", requestId);
139
+ });
140
+ },
141
+ recordHeld(requestId) {
142
+ storage.transactionSync(() => {
143
+ storage.sql.exec(`INSERT OR IGNORE INTO shiitake_fleet_held_delegations (request_id)
144
+ SELECT request_id FROM shiitake_fleet_delegations
145
+ WHERE request_id = ? AND receipt IS NULL
146
+ AND NOT EXISTS (
147
+ SELECT 1 FROM shiitake_fleet_undeliverable_delegations
148
+ WHERE request_id = ?
149
+ )`, requestId, requestId);
150
+ });
151
+ },
152
+ recordUndeliverable(requestId) {
153
+ return storage.transactionSync(() => {
154
+ const held = storage.sql
155
+ .exec(`SELECT delegation.request_id
156
+ FROM shiitake_fleet_delegations AS delegation
157
+ JOIN shiitake_fleet_held_delegations AS held
158
+ ON held.request_id = delegation.request_id
159
+ WHERE delegation.request_id = ? AND delegation.receipt IS NULL`, requestId)
160
+ .toArray()[0];
161
+ if (held === undefined)
162
+ return false;
163
+ storage.sql.exec("INSERT OR IGNORE INTO shiitake_fleet_undeliverable_delegations(request_id) VALUES (?)", requestId);
164
+ storage.sql.exec("DELETE FROM shiitake_fleet_held_delegations WHERE request_id = ?", requestId);
165
+ return true;
102
166
  });
103
167
  },
104
168
  };
@@ -125,6 +125,8 @@ export declare function readSessionSummary(owner: SqliteOwner, sessionId: string
125
125
  * `admitNew` may refuse a new Session; a replayed request is never refused.
126
126
  */
127
127
  export declare function createSession(owner: SqliteOwner, requestId: string, admitNew: () => void): Effect.Effect<string, ShiitakeError>;
128
+ /** Reserve or replay a Session inside an enclosing owner transaction. */
129
+ export declare function createSessionWithinTransaction(owner: SqliteOwner, requestId: string, admitNew: () => void): string;
128
130
  /** Permanently delete one idle Session; false when it does not exist. */
129
131
  export declare function deleteSession(owner: SqliteOwner, input: Readonly<{
130
132
  sessionId: string;
@@ -7,7 +7,7 @@ import { diagnosticsByWorkId, projectCommandSettlement, readClientReplayPage, re
7
7
  import { sessionIds } from "./identities.js";
8
8
  import { openProgress, openToolProgress } from "./progress.js";
9
9
  import { encodeStoredSessionEntry, } from "./session-entry.js";
10
- import { appendSessionEntry, appendCompactionTransitionOwned, appendCompactionNoticeOwned, placeCompactionTransitionOwned, acceptSessionAbort, acceptSessionCancellation, completeSessionLeaf, prepareSessionRun, prepareSessionStore, readSessionEntriesForWork, readSessionCommandAdmission, settleSessionControls, takePendingSessionControls, readSessionEntryById, selectSessionBranch, forkSessionAt, readStoredSessionSummary as readSessionSummaryOwned, readStoredSessionFactPage, createStoredSession, deleteStoredSession, appendOperatorEntry, } from "./session-store.js";
10
+ import { appendSessionEntry, appendCompactionTransitionOwned, appendCompactionNoticeOwned, placeCompactionTransitionOwned, acceptSessionAbort, acceptSessionCancellation, completeSessionLeaf, prepareSessionRun, prepareSessionStore, readSessionEntriesForWork, readSessionCommandAdmission, settleSessionControls, takePendingSessionControls, readSessionEntryById, selectSessionBranch, forkSessionAt, readStoredSessionSummary as readSessionSummaryOwned, readStoredSessionFactPage, createStoredSession, createStoredSessionWithinTransaction, deleteStoredSession, appendOperatorEntry, } from "./session-store.js";
11
11
  import { readSessionInboxPage, recordSessionInboxDeletion, } from "./session-inbox.js";
12
12
  export * from "./extension-store.js";
13
13
  export { readLatestPendingPrompt } from "./client-projection.js";
@@ -66,7 +66,7 @@ export function cancelSession(owner, sessionId, commandId, onlyIfPending, readQu
66
66
  return storeEffect(() => {
67
67
  const cancellation = acceptSessionCancellation(owner, sessionId, commandId, readQueueHead, record, onlyIfPending, meta);
68
68
  if (cancellation?.delivery === "new")
69
- publishOwnerObservation(owner, sessionId);
69
+ publishOwnerObservation(owner, sessionId, "settlement");
70
70
  return cancellation;
71
71
  });
72
72
  }
@@ -83,14 +83,14 @@ export function takeSessionControls(owner, input) {
83
83
  export function settleControls(owner, input) {
84
84
  return storeEffect(() => {
85
85
  settleSessionControls(owner, input);
86
- publishOwnerObservation(owner, input.sessionId);
86
+ publishOwnerObservation(owner, input.sessionId, "settlement");
87
87
  });
88
88
  }
89
89
  /** Resolve Session-scoped abort to private work and record it atomically. */
90
90
  export function abortSession(owner, sessionId, readQueueHead, record, meta) {
91
91
  return storeEffect(() => {
92
92
  const cancellation = acceptSessionAbort(owner, sessionId, readQueueHead, record, meta);
93
- publishOwnerObservation(owner, sessionId);
93
+ publishOwnerObservation(owner, sessionId, "settlement");
94
94
  return cancellation?.accepted;
95
95
  });
96
96
  }
@@ -202,6 +202,10 @@ export function readSessionSummary(owner, sessionId) {
202
202
  export function createSession(owner, requestId, admitNew) {
203
203
  return storeEffect(() => createStoredSession(owner, decodeClientSessionCreationRequestId({ requestId }), admitNew));
204
204
  }
205
+ /** Reserve or replay a Session inside an enclosing owner transaction. */
206
+ export function createSessionWithinTransaction(owner, requestId, admitNew) {
207
+ return createStoredSessionWithinTransaction(owner, decodeClientSessionCreationRequestId({ requestId }), admitNew);
208
+ }
205
209
  /** Permanently delete one idle Session; false when it does not exist. */
206
210
  export function deleteSession(owner, input) {
207
211
  return storeEffect(() => deleteStoredSession(owner, {
@@ -273,7 +277,9 @@ function appendRecordedEntry(owner, accepted, entry, findExisting, beforeAppend)
273
277
  throw rejected("Session fact identity allocation failed");
274
278
  });
275
279
  if (delivery === "new")
276
- publishOwnerObservation(owner, accepted.sessionId);
280
+ publishOwnerObservation(owner, accepted.sessionId, entry.kind === "run_terminal" || entry.kind === "work_terminal"
281
+ ? "settlement"
282
+ : "durable");
277
283
  }
278
284
  /** Persist an operator-only sandbox diagnostic beside its accepted Session work. */
279
285
  export function recordSandboxFailure(owner, accepted, event) {
@@ -56,7 +56,10 @@ export function contextMessageIds(path, activeWorkId) {
56
56
  if (entry.value.role === "assistant") {
57
57
  lastWorkAssistant.set(entry.workId, row.id);
58
58
  }
59
- if (terminalWork.has(entry.workId) || entry.workId === activeWorkId)
59
+ if ((terminalWork.has(entry.workId) || entry.workId === activeWorkId) &&
60
+ (entry.value.role !== "assistant" ||
61
+ (entry.value.stopReason !== "error" &&
62
+ entry.value.stopReason !== "aborted")))
60
63
  messages.add(row.id);
61
64
  }
62
65
  if ((entry.kind === "compaction" && entry.data.resumesRun) ||