@fungi.computer/shiitake 0.1.0 → 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.
Files changed (112) hide show
  1. package/README.md +155 -34
  2. package/dist/agent-control.d.ts +78 -11
  3. package/dist/agent-control.js +273 -70
  4. package/dist/client-entry.d.ts +15 -8
  5. package/dist/client-entry.js +7 -5
  6. package/dist/client-wire.d.ts +24 -8
  7. package/dist/client-wire.js +53 -11
  8. package/dist/client.d.ts +56 -11
  9. package/dist/client.js +1 -1
  10. package/dist/cloudflare.d.ts +42 -23
  11. package/dist/cloudflare.js +76 -54
  12. package/dist/cold-sessions.d.ts +28 -0
  13. package/dist/cold-sessions.js +1 -0
  14. package/dist/delegation.d.ts +214 -0
  15. package/dist/delegation.js +319 -0
  16. package/dist/effect.d.ts +1 -1
  17. package/dist/errors.d.ts +2 -2
  18. package/dist/errors.js +2 -2
  19. package/dist/extension-contract.d.ts +10 -2
  20. package/dist/extensions.d.ts +6 -2
  21. package/dist/fleet.d.ts +52 -34
  22. package/dist/fleet.js +101 -63
  23. package/dist/hosted.js +1 -0
  24. package/dist/http-routes.d.ts +106 -22
  25. package/dist/http-routes.js +18 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/internal/accepted-run-executor.d.ts +5 -0
  28. package/dist/internal/accepted-run-executor.js +10 -4
  29. package/dist/internal/admission-door.d.ts +3 -0
  30. package/dist/internal/admission-door.js +12 -2
  31. package/dist/internal/client-boundary.js +7 -1
  32. package/dist/internal/compaction/compaction-slot.d.ts +36 -0
  33. package/dist/internal/compaction/compaction-slot.js +41 -0
  34. package/dist/internal/compaction/compaction.d.ts +7 -4
  35. package/dist/internal/compaction/compaction.js +59 -30
  36. package/dist/internal/compaction/session-compaction.d.ts +8 -16
  37. package/dist/internal/compaction/session-compaction.js +358 -119
  38. package/dist/internal/compaction/settings.js +5 -0
  39. package/dist/internal/compaction/utils.d.ts +19 -16
  40. package/dist/internal/compaction/utils.js +30 -84
  41. package/dist/internal/contracts.d.ts +40 -13
  42. package/dist/internal/coordinator.js +28 -10
  43. package/dist/internal/effect-client.d.ts +4 -2
  44. package/dist/internal/effect-client.js +29 -0
  45. package/dist/internal/effect-contracts.d.ts +17 -4
  46. package/dist/internal/extension-runtime.d.ts +5 -0
  47. package/dist/internal/extension-runtime.js +184 -89
  48. package/dist/internal/host-meta.d.ts +7 -0
  49. package/dist/internal/model-acquisition.js +7 -1
  50. package/dist/internal/model-preference.d.ts +2 -0
  51. package/dist/internal/model-preference.js +2 -1
  52. package/dist/internal/promise-facade.js +4 -16
  53. package/dist/internal/run-failure-reason.d.ts +10 -0
  54. package/dist/internal/run-failure-reason.js +19 -0
  55. package/dist/internal/run-scope.d.ts +4 -1
  56. package/dist/internal/run-scope.js +38 -5
  57. package/dist/internal/runtime-coordinator.d.ts +2 -1
  58. package/dist/internal/runtime-coordinator.js +65 -4
  59. package/dist/internal/runtime-core.js +1 -0
  60. package/dist/internal/runtime-reconciliation.js +42 -6
  61. package/dist/internal/session-context.d.ts +1 -2
  62. package/dist/internal/session-context.js +8 -30
  63. package/dist/internal/session-status.d.ts +2 -1
  64. package/dist/internal/session-status.js +4 -1
  65. package/dist/internal/woodstock/client-projection.d.ts +6 -5
  66. package/dist/internal/woodstock/client-projection.js +79 -53
  67. package/dist/internal/woodstock/cold-sessions.d.ts +4 -0
  68. package/dist/internal/woodstock/cold-sessions.js +90 -0
  69. package/dist/internal/woodstock/extension-store.d.ts +2 -0
  70. package/dist/internal/woodstock/extension-store.js +4 -0
  71. package/dist/internal/woodstock/fleet-store.d.ts +3 -3
  72. package/dist/internal/woodstock/fleet-store.js +16 -11
  73. package/dist/internal/woodstock/index.d.ts +11 -3
  74. package/dist/internal/woodstock/index.js +33 -14
  75. package/dist/internal/woodstock/progress.d.ts +14 -1
  76. package/dist/internal/woodstock/progress.js +97 -19
  77. package/dist/internal/woodstock/run-recovery.js +6 -4
  78. package/dist/internal/woodstock/session-attention.d.ts +1 -1
  79. package/dist/internal/woodstock/session-attention.js +2 -2
  80. package/dist/internal/woodstock/session-entry.d.ts +109 -42
  81. package/dist/internal/woodstock/session-entry.js +63 -20
  82. package/dist/internal/woodstock/session-inbox.js +2 -0
  83. package/dist/internal/woodstock/session-store.d.ts +104 -24
  84. package/dist/internal/woodstock/session-store.js +937 -246
  85. package/dist/internal/woodstock/storage-admission.d.ts +8 -8
  86. package/dist/internal/woodstock/storage-admission.js +8 -34
  87. package/dist/internal/woodstock/storage-conformance.d.ts +15 -0
  88. package/dist/internal/woodstock/storage-conformance.js +489 -0
  89. package/dist/model-preference.d.ts +2 -2
  90. package/dist/model-preference.js +2 -2
  91. package/dist/node.js +1 -1
  92. package/dist/remote-client.js +8 -1
  93. package/dist/session-attention.d.ts +8 -1
  94. package/dist/session-attention.js +6 -1
  95. package/dist/testing.js +1 -0
  96. package/docs/api/README.md +18 -16
  97. package/docs/api/agent-control.md +172 -2
  98. package/docs/api/client-entry.md +26 -10
  99. package/docs/api/client.md +153 -37
  100. package/docs/api/cloudflare.md +76 -23
  101. package/docs/api/delegation.md +485 -0
  102. package/docs/api/effect.md +20 -2
  103. package/docs/api/extensions.md +21 -9
  104. package/docs/api/fleet.md +149 -122
  105. package/docs/api/http.md +4 -1
  106. package/docs/api/index.md +116 -20
  107. package/docs/api/internal/woodstock/storage-conformance.md +81 -0
  108. package/docs/api/model-preference.md +17 -0
  109. package/docs/api/testing.md +16 -4
  110. package/package.json +26 -14
  111. package/dist/internal/browser-sessions.d.ts +0 -31
  112. package/dist/internal/browser-sessions.js +0 -173
@@ -1,15 +1,20 @@
1
1
  import { isContextOverflow, } from "@fungi.computer/mule/effect";
2
2
  import { isRecoverableLength } from "@earendil-works/pi-ai/utils/overflow";
3
3
  import * as Effect from "effect/Effect";
4
+ import * as Fiber from "effect/Fiber";
5
+ import * as Schema from "effect/Schema";
4
6
  import { ShiitakeError } from "../../errors.js";
5
7
  import { borrowedView, compact, prepareCompaction, } from "./compaction.js";
6
- import { estimateContextTokens, estimateTokens, shouldCompact, } from "./utils.js";
7
- import { appendCompactionNotice, appendCompactionTransition, beginCompactionActivity, buildSessionContext, endCompactionActivity, withdrawnWorkIds, contextMessageIds, commandContextMessage, operatorContextMessage, promptCommand, readSessionContextPath, } from "../woodstock/index.js";
8
+ import { claimSlot, releaseSlot, slotTaken, takeSummary, } from "./compaction-slot.js";
9
+ import { estimateContextTokens, estimateMessageTokens, } from "@earendil-works/pi-ai/utils/estimate";
10
+ import { compactionSummaryMessage, shouldCompact, shouldCompactInBackground, } from "./utils.js";
11
+ import { appendCompactionNotice, appendCompactionTransition, beginCompactionActivity, buildSessionContext, CompactionReceiptSchema, compactionHeadState, endCompactionActivity, withdrawnWorkIds, contextMessageIds, commandContextMessage, operatorContextMessage, placeCompactionTransition, promptCommand, readSessionContextPath, } from "../woodstock/index.js";
8
12
  function runtimeFailure(message) {
9
13
  return new ShiitakeError("runtime", "rejected", message);
10
14
  }
11
15
  const commitFailures = {
12
16
  handoff: "The compaction handoff failed; the conversation context is unchanged",
17
+ handoffResult: "The compaction handoff failed: it returned an invalid result; the conversation context is unchanged",
13
18
  conflict: "The Session changed during compaction; the conversation context is unchanged",
14
19
  };
15
20
  /**
@@ -18,9 +23,11 @@ const commitFailures = {
18
23
  */
19
24
  export class CompactionCommitError extends ShiitakeError {
20
25
  phase;
21
- constructor(phase) {
26
+ usage;
27
+ constructor(phase, usage) {
22
28
  super(phase === "conflict" ? "store" : "runtime", phase === "conflict" ? "conflict" : "rejected", commitFailures[phase]);
23
29
  this.phase = phase;
30
+ this.usage = usage;
24
31
  }
25
32
  get diagnostic() {
26
33
  return commitFailures[this.phase];
@@ -57,14 +64,13 @@ function sourceEntries(path, activeWorkId) {
57
64
  const compaction = {
58
65
  id: row.id,
59
66
  kind: "compaction",
60
- timestamp: row.timestamp,
61
- firstKeptEntryId: row.entry.firstKeptEntryId,
62
- summary: row.entry.summary,
63
- usage: row.entry.usage,
67
+ head: row.entry.head,
68
+ model: row.entry.model[0],
69
+ usage: row.entry.data.usage,
64
70
  };
65
- entries.push(row.entry.details === undefined
71
+ entries.push(row.entry.data.details === undefined
66
72
  ? compaction
67
- : { ...compaction, details: row.entry.details });
73
+ : { ...compaction, details: row.entry.data.details });
68
74
  }
69
75
  }
70
76
  return entries;
@@ -73,36 +79,9 @@ function sourceEntries(path, activeWorkId) {
73
79
  function readPath(store, sessionId, workId) {
74
80
  return Effect.try({
75
81
  try: () => readSessionContextPath(store, { sessionId, workId }),
76
- catch: (error) => error instanceof ShiitakeError && error.cause === "context_too_large"
77
- ? error
78
- : runtimeFailure("Woodstock selected lineage is unavailable"),
82
+ catch: () => runtimeFailure("Woodstock selected lineage is unavailable"),
79
83
  });
80
84
  }
81
- /**
82
- * The compaction owner's context estimate: Pi's usage-plus-trailing count,
83
- * excluding provider usage that belongs before Woodstock's latest boundary.
84
- *
85
- * A provider's reported usage measures the context it actually read, so it
86
- * is authoritative only while nothing has reshaped that context since. Once
87
- * a compaction lands, every kept message's own usage figure is stale: it
88
- * priced the pre-compaction history, not the summary that replaced it. The
89
- * one safe fallback is a fresh chars/4 estimate of every message still in
90
- * view, the same method `tokensAfterCompaction` uses when it records the
91
- * marker's own `tokensAfter` at commit time; trailing-after-usage alone
92
- * would undercount (even to zero, when the kept tail's own reply is the
93
- * newest message and nothing followed it).
94
- */
95
- export function estimateThresholdContextTokens(path, messages) {
96
- const boundary = path.reduce((latest, row) => (row.entry.kind === "compaction" ? row.timestamp : latest), undefined);
97
- const estimate = estimateContextTokens(messages);
98
- const usageIndex = estimate.lastUsageIndex;
99
- const usageMessage = usageIndex === null ? undefined : messages[usageIndex];
100
- if (boundary === undefined ||
101
- usageMessage?.role !== "assistant" ||
102
- usageMessage.timestamp > boundary)
103
- return estimate.tokens;
104
- return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
105
- }
106
85
  /** The context the model reads once a compaction row becomes the boundary. */
107
86
  function tokensAfterCompaction(path, expected, entry, workId) {
108
87
  const boundary = {
@@ -113,7 +92,7 @@ function tokensAfterCompaction(path, expected, entry, workId) {
113
92
  timestamp: Date.now(),
114
93
  entry,
115
94
  };
116
- return buildSessionContext([...path, boundary], workId).reduce((sum, message) => sum + estimateTokens(message), 0);
95
+ return buildSessionContext([...path, boundary], workId).reduce((sum, message) => sum + estimateMessageTokens(message), 0);
117
96
  }
118
97
  /** One exact preparation, handoff, CAS append, and authoritative rebuild. */
119
98
  function commitCompaction(input, path, request) {
@@ -134,48 +113,104 @@ function commitCompactionEffect(input, path, request) {
134
113
  if (expected === undefined)
135
114
  return undefined;
136
115
  const result = yield* compact(preparation, input.model, input.stream, request.instructions);
137
- if (input.handoff !== undefined)
138
- yield* input.handoff
139
- .consume(borrowedView(preparation, result, expected.id))
140
- .pipe(Effect.mapError(() => new CompactionCommitError("handoff")));
141
- const tokensAfter = tokensAfterCompaction(path, expected, {
142
- kind: "compaction",
116
+ const receipt = yield* handOff(input, preparation, result, expected.id);
117
+ return yield* appendSummary(input, path, result, request, expected.id, receipt);
118
+ });
119
+ }
120
+ /**
121
+ * What a handoff may answer. A JS extension's result arrives untyped, so it is
122
+ * decoded here: an `accepted` receipt must satisfy the entry's own receipt
123
+ * rule, so an invalid one stops the compaction before anything is written.
124
+ */
125
+ const HandoffResultSchema = Schema.Union([
126
+ Schema.Struct({
127
+ type: Schema.Literal("accepted"),
128
+ receipt: CompactionReceiptSchema,
129
+ }),
130
+ Schema.Struct({ type: Schema.Literal("discarded_by_policy") }),
131
+ ]);
132
+ const decodeHandoffResult = Schema.decodeUnknownResult(HandoffResultSchema);
133
+ /**
134
+ * Zookeeper's handoff with the exact arrays, before the destructive append.
135
+ * Returns the archive receipt to store in the entry that commits, or
136
+ * `undefined` when no handoff is installed or it discarded by policy.
137
+ */
138
+ function handOff(input, preparation, result, contextRevision) {
139
+ if (input.handoff === undefined)
140
+ return Effect.succeed(undefined);
141
+ return input.handoff
142
+ .consume(borrowedView(preparation, result, input.sessionId, contextRevision))
143
+ .pipe(Effect.mapError(() => new CompactionCommitError("handoff", result.usage)), Effect.flatMap((handed) => {
144
+ const decoded = decodeHandoffResult(handed);
145
+ if (decoded._tag === "Failure")
146
+ return Effect.fail(new CompactionCommitError("handoffResult", result.usage));
147
+ return Effect.succeed(decoded.success.type === "accepted"
148
+ ? decoded.success.receipt
149
+ : undefined);
150
+ }));
151
+ }
152
+ /**
153
+ * The row a compaction appends on the leaf `path` ends at, with its measured
154
+ * `tokensAfter`. `contextRevision` names the last row the summary saw: that leaf
155
+ * for a blocking commit, the selection tail for a background one.
156
+ */
157
+ function compactionRow(input, path, result, request, contextRevision, receipt) {
158
+ const expected = path.at(-1);
159
+ if (expected === undefined)
160
+ return undefined;
161
+ const tokensAfter = tokensAfterCompaction(path, expected, {
162
+ kind: "compaction",
163
+ workId: input.workId,
164
+ head: result.firstKeptEntryId,
165
+ model: [compactionSummaryMessage(result.summary, Date.now())],
166
+ data: {
167
+ reason: request.trigger,
143
168
  resumesRun: request.resumesRun,
144
- trigger: request.trigger,
145
- workId: input.workId,
146
- firstKeptEntryId: result.firstKeptEntryId,
147
- summary: result.summary,
148
169
  tokensBefore: result.tokensBefore,
170
+ // This probe row only measures the context; the figure it computes
171
+ // is what the appended row records.
172
+ tokensAfter: 0,
149
173
  usage: result.usage,
150
- contextRevision: expected.id,
151
- }, input.workId);
152
- const committed = yield* Effect.try({
153
- try: () => appendCompactionTransition(input.store, {
154
- sessionId: input.sessionId,
155
- resumesRun: request.resumesRun,
156
- trigger: request.trigger,
157
- tokensAfter,
158
- expectedLeafId: expected.id,
159
- workId: input.workId,
160
- firstKeptEntryId: result.firstKeptEntryId,
161
- summary: result.summary,
162
- tokensBefore: result.tokensBefore,
163
- usage: result.usage,
164
- contextRevision: expected.id,
165
- details: result.details,
166
- }),
167
- catch: (error) => error instanceof ShiitakeError
168
- ? error.cause === "conflict"
169
- ? new CompactionCommitError("conflict")
170
- : error
171
- : runtimeFailure("Woodstock compaction append failed"),
172
- });
173
- const committedPath = [...path, committed];
174
- return {
175
- path: committedPath,
176
- messages: buildSessionContext(committedPath, input.workId),
177
- };
178
- });
174
+ contextRevision,
175
+ },
176
+ }, input.workId);
177
+ const row = {
178
+ sessionId: input.sessionId,
179
+ resumesRun: request.resumesRun,
180
+ trigger: request.trigger,
181
+ tokensAfter,
182
+ expectedLeafId: expected.id,
183
+ workId: input.workId,
184
+ head: result.firstKeptEntryId,
185
+ summary: result.summary,
186
+ tokensBefore: result.tokensBefore,
187
+ usage: result.usage,
188
+ contextRevision,
189
+ details: result.details,
190
+ };
191
+ return receipt === undefined ? row : { ...row, receipt };
192
+ }
193
+ /** The context the run continues from once `committed` ends `path`. */
194
+ function rebuilt(input, path, committed) {
195
+ const committedPath = [...path, committed];
196
+ return {
197
+ path: committedPath,
198
+ messages: buildSessionContext(committedPath, input.workId),
199
+ };
200
+ }
201
+ /** CAS-append a blocking compaction on the leaf `path` ends at and rebuild. */
202
+ function appendSummary(input, path, result, request, contextRevision, receipt) {
203
+ const row = compactionRow(input, path, result, request, contextRevision, receipt);
204
+ if (row === undefined)
205
+ return Effect.succeed(undefined);
206
+ return Effect.try({
207
+ try: () => appendCompactionTransition(input.store, row),
208
+ catch: (error) => error instanceof ShiitakeError
209
+ ? error.cause === "conflict"
210
+ ? new CompactionCommitError("conflict")
211
+ : error
212
+ : runtimeFailure("Woodstock compaction append failed"),
213
+ }).pipe(Effect.map((committed) => rebuilt(input, path, committed)));
179
214
  }
180
215
  /** A failure's display-safe reason: owner-closed text or the model's message. */
181
216
  function failureDiagnostic(error) {
@@ -185,23 +220,31 @@ function failureDiagnostic(error) {
185
220
  return error.message;
186
221
  return `Compaction failed (${error.boundary}: ${error.cause})`;
187
222
  }
188
- /** Record a compaction that committed nothing, so clients can show it. */
189
- function recordNotice(input, trigger, failure) {
223
+ /** Append one compaction notice, so clients can show what committed nothing. */
224
+ function writeNotice(input, notice) {
190
225
  return Effect.try({
191
226
  try: () => {
192
- appendCompactionNotice(input.store, {
193
- sessionId: input.sessionId,
194
- workId: input.workId,
195
- trigger,
196
- result: failure === undefined ? "nothing_to_compact" : "failed",
197
- diagnostic: failure === undefined ? undefined : failureDiagnostic(failure),
198
- });
227
+ appendCompactionNotice(input.store, notice);
199
228
  },
200
229
  catch: (error) => error instanceof ShiitakeError
201
230
  ? error
202
231
  : runtimeFailure("Compaction notice could not be recorded"),
203
232
  });
204
233
  }
234
+ /** Record a compaction that found nothing to compact, or failed. */
235
+ function recordNotice(input, trigger, failure) {
236
+ const common = { sessionId: input.sessionId, workId: input.workId, trigger };
237
+ return writeNotice(input, failure === undefined
238
+ ? { ...common, result: "nothing_to_compact" }
239
+ : {
240
+ ...common,
241
+ result: "failed",
242
+ diagnostic: failureDiagnostic(failure),
243
+ usage: failure instanceof CompactionCommitError
244
+ ? failure.usage
245
+ : undefined,
246
+ });
247
+ }
205
248
  /**
206
249
  * Run one automatic compaction. A failure is recorded as a notice and the
207
250
  * caller keeps its current context; automatic compaction never fails a run.
@@ -211,15 +254,177 @@ function autoCompaction(input, path, request) {
211
254
  ? Effect.succeed(result.success)
212
255
  : recordNotice(input, request.trigger, result.failure).pipe(Effect.as(undefined))));
213
256
  }
214
- /** Apply the pre-run threshold check, preserving unchanged input on failure. */
215
- function preRunMessages(input) {
216
- if (input.resuming === true ||
217
- !shouldCompact(estimateThresholdContextTokens(input.path, input.messages), input.model.contextWindow, input.settings))
218
- return Effect.succeed({ path: input.path, messages: input.messages });
219
- return autoCompaction(input, input.path, {
257
+ const thresholdRequest = {
258
+ trigger: "threshold",
259
+ resumesRun: false,
260
+ };
261
+ /**
262
+ * Start a background compaction when the context crossed the background
263
+ * threshold (tokens only), the owner's slot is free and this run has not
264
+ * stopped trying. `readContext` is the one context read, taken at fork time;
265
+ * the fiber keeps only the preparation and hands it back with its summary.
266
+ */
267
+ function startBackground(input, background, contextTokens, readContext) {
268
+ if (background.stopped ||
269
+ slotTaken(input.store) ||
270
+ !shouldCompactInBackground(contextTokens, input.model.contextWindow, input.settings))
271
+ return Effect.void;
272
+ return readContext.pipe(Effect.flatMap((path) => forkBackground(input, background.scope, path)), Effect.map((slot) => {
273
+ if (slot === undefined)
274
+ background.stopped = true;
275
+ else
276
+ background.slot = slot;
277
+ }));
278
+ }
279
+ /**
280
+ * Claim the owner's slot and fork the summary into the run's scope:
281
+ * interrupting the run interrupts it, aborts the provider call and frees the
282
+ * slot (a deliberate Shiitake difference: Pi's background compaction
283
+ * outlives an abort). Nothing to compact forks nothing and stays silent, as
284
+ * automatic compaction does.
285
+ */
286
+ function forkBackground(input, scope, path) {
287
+ const tail = path.at(-1);
288
+ const preparation = prepareCompaction(sourceEntries(path, input.workId), input.settings);
289
+ if (tail === undefined || preparation === undefined)
290
+ return Effect.succeed(undefined);
291
+ return claimSlot(input.store, scope, (handBack) => summarize(input, preparation, tail.id, handBack));
292
+ }
293
+ /** The fiber's work: the summary call, handed back through the slot. */
294
+ function summarize(input, preparation, selectionTail, handBack) {
295
+ return Effect.suspend(() => {
296
+ beginCompactionActivity(input.store, input.sessionId, "threshold");
297
+ return compact(preparation, input.model, input.stream).pipe(Effect.map((result) => {
298
+ handBack({ preparation, result, selectionTail });
299
+ }), Effect.ensuring(Effect.sync(() => {
300
+ endCompactionActivity(input.store, input.sessionId);
301
+ })));
302
+ });
303
+ }
304
+ /**
305
+ * Settle this run's background compaction and place it: at a between-turn
306
+ * point only once it has finished (`join: false`), or by waiting for it
307
+ * when the blocking threshold, an overflow or the end of the run arrives.
308
+ * Joining instead of starting a second compaction is Shiitake's
309
+ * single-flight policy; Pi does not join.
310
+ * Undefined while it still runs, or when it settled failed or stale; a
311
+ * summary or handoff failure becomes the ordinary `failed` notice. Background
312
+ * notices (`failed`, `stale`) always record trigger `threshold`, the start that
313
+ * made them; a placed entry takes `request`, standing in for the compaction
314
+ * the run would have made at that point.
315
+ */
316
+ function settleBackground(input, background, request, join) {
317
+ return Effect.suspend(() => {
318
+ const slot = background.slot;
319
+ const fiber = slot?.fiber;
320
+ if (slot === undefined ||
321
+ fiber === undefined ||
322
+ (!join && fiber.pollUnsafe() === undefined))
323
+ return Effect.succeed(undefined);
324
+ background.slot = undefined;
325
+ return Effect.result(Fiber.join(fiber).pipe(Effect.flatMap(() => handOffSummary(input, slot)), Effect.flatMap((handedOff) => handedOff === undefined
326
+ ? Effect.succeed(undefined)
327
+ : appendPlaced(input, handedOff, request)))).pipe(Effect.flatMap((placed) => {
328
+ if (placed._tag === "Success" && placed.success !== undefined)
329
+ return Effect.succeed(placed.success);
330
+ background.stopped = true;
331
+ return placed._tag === "Success"
332
+ ? Effect.succeed(undefined)
333
+ : recordNotice(input, "threshold", placed.failure).pipe(Effect.as(undefined));
334
+ }), Effect.ensuring(Effect.sync(() => {
335
+ releaseSlot(input.store, slot);
336
+ })));
337
+ });
338
+ }
339
+ /**
340
+ * Record a finished summary dropped unplaced: no entry, only its spend.
341
+ * `stale`: its head moved. `insufficient`: placed, the context would still be
342
+ * over the limit.
343
+ */
344
+ function recordDropped(input, result, reason) {
345
+ return writeNotice(input, {
346
+ sessionId: input.sessionId,
347
+ workId: input.workId,
220
348
  trigger: "threshold",
221
- resumesRun: false,
222
- }).pipe(Effect.map((compacted) => compacted ?? { path: input.path, messages: input.messages }));
349
+ result: reason,
350
+ usage: result.usage,
351
+ }).pipe(Effect.as(undefined));
352
+ }
353
+ /**
354
+ * Placement steps 1-3 (design §3): take the summary out of the slot, check
355
+ * its head, hand the fork-time arrays to Zookeeper, then release the slot.
356
+ * Only the result and the selection tail come back, so the summarized prefix
357
+ * is unreachable before placement reads the context again. A stale summary
358
+ * never reaches the handoff.
359
+ *
360
+ * A summary handed off here can still fail to commit (stale inside the
361
+ * append, insufficient, or a CAS conflict). Its accepted archive is then
362
+ * unreferenced and Z5 sweeps it, at most one per run: see the "view" steps
363
+ * of wiki/3-resources/shiitake-v0.0.1/zookeeper-compaction-handoff.md.
364
+ */
365
+ function handOffSummary(input, slot) {
366
+ const summary = takeSummary(slot);
367
+ if (summary === undefined)
368
+ return Effect.succeed(undefined);
369
+ const { result, selectionTail } = summary;
370
+ return Effect.try({
371
+ try: () => compactionHeadState(input.store, {
372
+ sessionId: input.sessionId,
373
+ head: result.firstKeptEntryId,
374
+ }),
375
+ catch: (error) => error instanceof ShiitakeError
376
+ ? error
377
+ : runtimeFailure("Woodstock compaction head is unavailable"),
378
+ }).pipe(Effect.flatMap((head) => head === "stale"
379
+ ? recordDropped(input, result, "stale")
380
+ : handOff(input, summary.preparation, result, selectionTail).pipe(Effect.map((receipt) => ({ result, selectionTail, receipt })))), Effect.ensuring(Effect.sync(() => {
381
+ releaseSlot(input.store, slot);
382
+ })));
383
+ }
384
+ /**
385
+ * Placement steps 4-5: one fresh read, then the append on the leaf as it is
386
+ * now, head-checked again inside the append transaction because the handoff
387
+ * ran in between. Rows added after the selection tail stay verbatim after
388
+ * the head; a head that moved meanwhile drops the summary as stale.
389
+ *
390
+ * Decided before appending (fix 3, option A): when the placed context would
391
+ * still be over the limit, the summary is not placed. It is recorded as
392
+ * `insufficient` and the caller's ordinary path (the blocking compaction, or the
393
+ * overflow recovery) runs on a fresh read. Placing it would leave a
394
+ * compaction at the tip, where Pi's preparation finds nothing to compact
395
+ * (compaction.ts:282), so that ordinary path could never help.
396
+ */
397
+ function appendPlaced(input, handedOff, request) {
398
+ return readPath(input.store, input.sessionId, input.workId).pipe(Effect.flatMap((path) => {
399
+ const row = compactionRow(input, path, handedOff.result, request, handedOff.selectionTail, handedOff.receipt);
400
+ if (row === undefined)
401
+ return Effect.succeed(undefined);
402
+ if (shouldCompact(row.tokensAfter, input.model.contextWindow, input.settings))
403
+ return recordDropped(input, handedOff.result, "insufficient");
404
+ return Effect.try({
405
+ try: () => placeCompactionTransition(input.store, row),
406
+ catch: (error) => error instanceof ShiitakeError
407
+ ? error.cause === "conflict"
408
+ ? new CompactionCommitError("conflict")
409
+ : error
410
+ : runtimeFailure("Woodstock compaction append failed"),
411
+ }).pipe(Effect.flatMap((placed) => placed === "stale"
412
+ ? recordDropped(input, handedOff.result, "stale")
413
+ : Effect.succeed(rebuilt(input, path, placed))));
414
+ }));
415
+ }
416
+ /**
417
+ * Apply the pre-run threshold check, preserving unchanged input on failure.
418
+ * Below it, the claimed context path may start a background compaction.
419
+ */
420
+ function preRunMessages(input, background) {
421
+ const unchanged = { path: input.path, messages: input.messages };
422
+ if (input.resuming === true)
423
+ return Effect.succeed(unchanged);
424
+ const tokens = estimateContextTokens(input.messages).tokens;
425
+ if (!shouldCompact(tokens, input.model.contextWindow, input.settings))
426
+ return startBackground(input, background, tokens, Effect.succeed(input.path)).pipe(Effect.as(unchanged));
427
+ return autoCompaction(input, input.path, thresholdRequest).pipe(Effect.map((compacted) => compacted ?? unchanged));
223
428
  }
224
429
  function classifyRecovery(outcome, model) {
225
430
  const overflow = isContextOverflow(outcome.message, model.contextWindow);
@@ -240,51 +445,74 @@ function removeFailedAssistant(messages) {
240
445
  function postRunPath(input, working, outcome, recovery) {
241
446
  if (!input.settings.enabled)
242
447
  return Effect.succeed(undefined);
243
- const threshold = shouldCompact(estimateThresholdContextTokens(working.path, [
448
+ const threshold = shouldCompact(estimateContextTokens([
244
449
  ...working.messages,
245
450
  ...outcome.messages.slice(working.newMessageCount),
246
- ]), input.model.contextWindow, input.settings);
451
+ ]).tokens, input.model.contextWindow, input.settings);
247
452
  return recovery.triggered || threshold
248
453
  ? readPath(input.store, input.sessionId, input.workId)
249
454
  : Effect.succeed(undefined);
250
455
  }
251
456
  /**
252
- * Pi's between-turn threshold check: when a running work's context has grown
253
- * past the threshold, compact the durable lineage (which already holds this
254
- * turn's tool results) and continue from the rebuilt context.
457
+ * Pi's between-turn point, where Mule is paused and the run is the only
458
+ * writer. A finished background summary is placed here; past the blocking
459
+ * threshold a running one is joined, and only if that placed nothing does
460
+ * the blocking compaction run. Below it, a background compaction may start.
255
461
  */
256
- function betweenTurns(input, working) {
462
+ function betweenTurns(input, working, background) {
257
463
  return (context, newMessageCount) => Effect.gen(function* () {
258
- if (!shouldCompact(estimateThresholdContextTokens(working.path, context), input.model.contextWindow, input.settings))
259
- return undefined;
260
- const path = yield* readPath(input.store, input.sessionId, input.workId);
261
- const compacted = yield* autoCompaction(input, path, {
262
- trigger: "threshold",
263
- resumesRun: false,
264
- });
265
- if (compacted === undefined)
464
+ const replace = (compacted) => {
465
+ working.path = compacted.path;
466
+ working.messages = compacted.messages;
467
+ working.newMessageCount = newMessageCount;
468
+ return compacted.messages;
469
+ };
470
+ const tokens = estimateContextTokens(context).tokens;
471
+ const blocking = shouldCompact(tokens, input.model.contextWindow, input.settings);
472
+ const placed = yield* settleBackground(input, background, thresholdRequest, blocking);
473
+ if (placed !== undefined)
474
+ return replace(placed);
475
+ const readContext = readPath(input.store, input.sessionId, input.workId);
476
+ if (!blocking) {
477
+ yield* startBackground(input, background, tokens, readContext);
266
478
  return undefined;
267
- working.path = compacted.path;
268
- working.messages = compacted.messages;
269
- working.newMessageCount = newMessageCount;
270
- return compacted.messages;
479
+ }
480
+ const compacted = yield* autoCompaction(input, yield* readContext, thresholdRequest);
481
+ return compacted === undefined ? undefined : replace(compacted);
271
482
  });
272
483
  }
273
484
  /** Run terminal outcomes through post-threshold and one-time recovery policy. */
274
- function postRunOutcome(input, initial) {
485
+ function postRunOutcome(input, initial, background) {
275
486
  return Effect.gen(function* () {
276
487
  let current = initial;
277
488
  let recoveryAttempted = initial.path.some(({ entry }) => entry.kind === "compaction" &&
278
489
  entry.workId === input.workId &&
279
- entry.resumesRun === true);
490
+ entry.data.resumesRun);
280
491
  for (;;) {
281
492
  const working = {
282
493
  path: current.path,
283
494
  messages: current.messages,
284
495
  newMessageCount: 0,
285
496
  };
286
- const outcome = yield* input.run(working.messages, betweenTurns(input, working));
497
+ const outcome = yield* input.run(working.messages, betweenTurns(input, working, background));
287
498
  const recovery = classifyRecovery(outcome, input.model);
499
+ const retry = recovery.retry && !recoveryAttempted;
500
+ // A summary still running when the run ends is waited for and placed
501
+ // first; it then stands in for the post-run compaction.
502
+ const placed = yield* settleBackground(input, background, {
503
+ trigger: recovery.overflow ? "overflow" : "threshold",
504
+ resumesRun: retry,
505
+ }, true);
506
+ if (placed !== undefined) {
507
+ if (!retry)
508
+ return outcome;
509
+ current = {
510
+ path: placed.path,
511
+ messages: removeFailedAssistant(placed.messages),
512
+ };
513
+ recoveryAttempted = true;
514
+ continue;
515
+ }
288
516
  const path = yield* postRunPath(input, working, outcome, recovery);
289
517
  if (path === undefined)
290
518
  return outcome;
@@ -304,11 +532,22 @@ function postRunOutcome(input, initial) {
304
532
  }
305
533
  });
306
534
  }
307
- /** Compose the retained pre-run, between-turn and post-run compaction phases. */
535
+ /**
536
+ * Compose the retained pre-run, between-turn and post-run compaction phases.
537
+ * A background compaction is a child fiber in the run's own scope.
538
+ */
308
539
  export function runWithCompaction(input) {
309
- if (!input.settings.enabled)
310
- return postRunOutcome(input, input);
311
- return preRunMessages(input).pipe(Effect.flatMap((initial) => postRunOutcome(input, initial)));
540
+ return Effect.gen(function* () {
541
+ const background = {
542
+ scope: yield* Effect.scope,
543
+ slot: undefined,
544
+ stopped: false,
545
+ };
546
+ const initial = input.settings.enabled
547
+ ? yield* preRunMessages(input, background)
548
+ : input;
549
+ return yield* postRunOutcome(input, initial, background);
550
+ });
312
551
  }
313
552
  /**
314
553
  * Compact one Session now, as accepted work: the same commit the automatic
@@ -5,11 +5,13 @@ const tokens = Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)), S
5
5
  const BudgetSchema = Schema.Struct({
6
6
  reserveTokens: Schema.optionalKey(tokens),
7
7
  keepRecentTokens: Schema.optionalKey(tokens),
8
+ backgroundTokens: Schema.optionalKey(tokens),
8
9
  });
9
10
  const CompactionOptionsSchema = Schema.Struct({
10
11
  enabled: Schema.optionalKey(Schema.Boolean),
11
12
  reserveTokens: Schema.optionalKey(tokens),
12
13
  keepRecentTokens: Schema.optionalKey(tokens),
14
+ backgroundTokens: Schema.optionalKey(tokens),
13
15
  modelOverrides: Schema.optionalKey(Schema.Record(Schema.String, BudgetSchema)),
14
16
  });
15
17
  const decodeOptions = Schema.decodeUnknownResult(CompactionOptionsSchema, {
@@ -35,6 +37,9 @@ export function openCompactionSettings(options) {
35
37
  keepRecentTokens: override?.keepRecentTokens ??
36
38
  settings.keepRecentTokens ??
37
39
  DEFAULT_COMPACTION_SETTINGS.keepRecentTokens,
40
+ backgroundTokens: override?.backgroundTokens ??
41
+ settings.backgroundTokens ??
42
+ DEFAULT_COMPACTION_SETTINGS.backgroundTokens,
38
43
  };
39
44
  };
40
45
  }