@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
@@ -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,11 @@ 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;
36
37
  };
@@ -81,24 +81,61 @@ 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
85
89
  ) STRICT`);
86
90
  return {
87
91
  read: (requestId) => storage.sql
88
- .exec("SELECT input, mail, receipt FROM shiitake_fleet_delegations WHERE request_id = ?", requestId)
92
+ .exec(`SELECT delegation.input, delegation.mail,
93
+ CASE
94
+ WHEN held.request_id IS NOT NULL THEN 'held'
95
+ WHEN delegation.receipt IS NOT NULL THEN 'accepted'
96
+ ELSE 'pending'
97
+ END AS state,
98
+ delegation.receipt
99
+ FROM shiitake_fleet_delegations AS delegation
100
+ LEFT JOIN shiitake_fleet_held_delegations AS held
101
+ ON held.request_id = delegation.request_id
102
+ WHERE delegation.request_id = ?`, requestId)
89
103
  .toArray()[0],
90
- /** Every delegation this owner has sent and had accepted, newest first. */
104
+ /** Every recipient-accepted delegation, newest first. */
91
105
  sent: () => storage.sql
92
- .exec("SELECT input, mail, receipt FROM shiitake_fleet_delegations WHERE receipt IS NOT NULL ORDER BY rowid DESC")
106
+ .exec(`SELECT input, mail, 'accepted' AS state, receipt
107
+ FROM shiitake_fleet_delegations
108
+ WHERE receipt IS NOT NULL
109
+ ORDER BY rowid DESC`)
93
110
  .toArray(),
94
111
  reserve(requestId, input, mail) {
95
112
  storage.transactionSync(() => {
96
113
  storage.sql.exec("INSERT OR IGNORE INTO shiitake_fleet_delegations(request_id, input, mail) VALUES (?, ?, ?)", requestId, input, mail);
97
114
  });
98
115
  },
99
- recordReceipt(requestId, receipt) {
116
+ recordReceipt(requestId, receipt, expectedMail, acceptedMail) {
117
+ storage.transactionSync(() => {
118
+ const row = storage.sql
119
+ .exec("SELECT mail, receipt FROM shiitake_fleet_delegations WHERE request_id = ?", requestId)
120
+ .toArray()[0];
121
+ if (row === undefined)
122
+ throw new Error("Cannot record acceptance without frozen delegation mail");
123
+ if (row.receipt !== null) {
124
+ if (row.receipt === receipt && row.mail === acceptedMail)
125
+ return;
126
+ throw new Error("Delegation acceptance conflicts with its recorded receipt");
127
+ }
128
+ if (row.mail !== expectedMail)
129
+ throw new Error("Delegation mail changed before acceptance was recorded");
130
+ storage.sql.exec("UPDATE shiitake_fleet_delegations SET mail = ?, receipt = ? WHERE request_id = ? AND receipt IS NULL", acceptedMail, receipt, requestId);
131
+ storage.sql.exec("DELETE FROM shiitake_fleet_held_delegations WHERE request_id = ?", requestId);
132
+ });
133
+ },
134
+ recordHeld(requestId) {
100
135
  storage.transactionSync(() => {
101
- storage.sql.exec("UPDATE shiitake_fleet_delegations SET receipt = ? WHERE request_id = ?", receipt, requestId);
136
+ storage.sql.exec(`INSERT OR IGNORE INTO shiitake_fleet_held_delegations (request_id)
137
+ SELECT request_id FROM shiitake_fleet_delegations
138
+ WHERE request_id = ? AND receipt IS NULL`, requestId);
102
139
  });
103
140
  },
104
141
  };
@@ -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) ||
@@ -70,13 +70,18 @@ declare const StoredSessionEntrySchema: Schema.Union<readonly [Schema.Struct<{
70
70
  readonly kind: Schema.Literal<"command">;
71
71
  readonly meta: Schema.optionalKey<Schema.Codec<import("../../extensions.js").Json, import("../../extensions.js").Json, never, never>>;
72
72
  /** Present only when the host admitted this line as a verified Agent reply. */
73
- readonly reply: Schema.optionalKey<Schema.Struct<{
73
+ readonly reply: Schema.optionalKey<Schema.Union<readonly [Schema.Struct<{
74
74
  readonly commandId: Schema.String;
75
75
  readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
76
76
  readonly diagnostic: Schema.optionalKey<Schema.String>;
77
77
  readonly agentId: Schema.String;
78
78
  readonly sessionId: Schema.String;
79
- }>>;
79
+ }>, Schema.Struct<{
80
+ readonly outcome: Schema.Literal<"undeliverable">;
81
+ readonly reason: Schema.Literals<readonly ["recipient_offline", "recipient_left"]>;
82
+ readonly agentId: Schema.String;
83
+ readonly sessionId: Schema.optionalKey<Schema.String>;
84
+ }>]>>;
80
85
  readonly delivery: Schema.optionalKey<Schema.Literals<readonly ["steer", "followUp"]>>;
81
86
  readonly requestId: Schema.String;
82
87
  readonly targetWorkId: Schema.optionalKey<Schema.String>;
@@ -357,6 +362,11 @@ export declare function decodeStoredSessionEntry(value: string): Result.Result<{
357
362
  readonly outcome: "completed" | "failed" | "cancelled" | "interrupted" | "outcome_unknown";
358
363
  readonly agentId: string;
359
364
  readonly diagnostic?: string;
365
+ } | {
366
+ readonly reason: "recipient_offline" | "recipient_left";
367
+ readonly outcome: "undeliverable";
368
+ readonly agentId: string;
369
+ readonly sessionId?: string;
360
370
  };
361
371
  readonly targetWorkId?: string;
362
372
  } | {
@@ -513,8 +523,8 @@ export declare function decodeStoredSessionEntry(value: string): Result.Result<{
513
523
  readonly workId: string;
514
524
  readonly result: "failed";
515
525
  readonly trigger: "manual" | "threshold" | "overflow";
516
- readonly usage?: Usage;
517
526
  readonly diagnostic?: string;
527
+ readonly usage?: Usage;
518
528
  } | {
519
529
  readonly kind: "compaction_notice";
520
530
  readonly workId: string;
@@ -147,6 +147,7 @@ const assistantMessage = Schema.Struct({
147
147
  data: Schema.optionalKey(jsonValue),
148
148
  })),
149
149
  errorMessage: Schema.optionalKey(Schema.String),
150
+ providerError: Schema.optionalKey(Schema.String),
150
151
  rawStopReason: Schema.optionalKey(Schema.String),
151
152
  providerThinkingLevel: Schema.optionalKey(Schema.String),
152
153
  endTurn: Schema.optionalKey(Schema.Boolean),
@@ -254,6 +254,10 @@ export declare function readActiveToolForWork(owner: SqliteOwner, sessionId: str
254
254
  export declare function createStoredSession(owner: SqliteOwner, requestId: string,
255
255
  /** Refuses a new Session under storage pressure; replays skip it. */
256
256
  admitNew: () => void): string;
257
+ /** Reserve a Session while the caller owns the enclosing SQLite transaction. */
258
+ export declare function createStoredSessionWithinTransaction(owner: SqliteOwner, requestId: string,
259
+ /** Refuses a new Session under storage pressure; replays skip it. */
260
+ admitNew: () => void): string;
257
261
  /**
258
262
  * Permanently remove one idle Session: its entries, command admissions,
259
263
  * creation receipt and inbox row, so a retried creation request reserves a
@@ -790,23 +790,27 @@ function ensureSession(owner, sessionId) {
790
790
  export function createStoredSession(owner, requestId,
791
791
  /** Refuses a new Session under storage pressure; replays skip it. */
792
792
  admitNew) {
793
- return owner.transactionSync(() => {
794
- const existing = owner.sql
795
- .exec("SELECT session_id FROM session_creation_receipts WHERE request_id = ?", requestId)
796
- .toArray();
797
- const first = existing[0];
798
- if (first !== undefined) {
799
- const decoded = decodeSessionCreationReceiptRow(first);
800
- if (decoded._tag === "Failure")
801
- throw unavailable("Session creation receipt is invalid");
802
- return decoded.success.session_id;
803
- }
804
- admitNew();
805
- const sessionId = sessionIds.session.generate();
806
- ensureSession(owner, sessionId);
807
- owner.sql.exec("INSERT INTO session_creation_receipts (request_id, session_id) VALUES (?, ?)", requestId, sessionId);
808
- return sessionId;
809
- });
793
+ return owner.transactionSync(() => createStoredSessionWithinTransaction(owner, requestId, admitNew));
794
+ }
795
+ /** Reserve a Session while the caller owns the enclosing SQLite transaction. */
796
+ export function createStoredSessionWithinTransaction(owner, requestId,
797
+ /** Refuses a new Session under storage pressure; replays skip it. */
798
+ admitNew) {
799
+ const existing = owner.sql
800
+ .exec("SELECT session_id FROM session_creation_receipts WHERE request_id = ?", requestId)
801
+ .toArray();
802
+ const first = existing[0];
803
+ if (first !== undefined) {
804
+ const decoded = decodeSessionCreationReceiptRow(first);
805
+ if (decoded._tag === "Failure")
806
+ throw unavailable("Session creation receipt is invalid");
807
+ return decoded.success.session_id;
808
+ }
809
+ admitNew();
810
+ const sessionId = sessionIds.session.generate();
811
+ ensureSession(owner, sessionId);
812
+ owner.sql.exec("INSERT INTO session_creation_receipts (request_id, session_id) VALUES (?, ?)", requestId, sessionId);
813
+ return sessionId;
810
814
  }
811
815
  /**
812
816
  * Permanently remove one idle Session: its entries, command admissions,
@@ -2280,9 +2284,12 @@ reply) {
2280
2284
  function sameReply(left, right) {
2281
2285
  return (left?.agentId === right?.agentId &&
2282
2286
  left?.sessionId === right?.sessionId &&
2283
- left?.commandId === right?.commandId &&
2284
2287
  left?.outcome === right?.outcome &&
2285
- left?.diagnostic === right?.diagnostic);
2288
+ (left?.outcome === "undeliverable"
2289
+ ? right?.outcome === "undeliverable" && left.reason === right.reason
2290
+ : right?.outcome !== "undeliverable" &&
2291
+ left?.commandId === right?.commandId &&
2292
+ left?.diagnostic === right?.diagnostic));
2286
2293
  }
2287
2294
  /**
2288
2295
  * Write one decoded admission (a prompt or a compaction request) and its one
@@ -10,7 +10,7 @@ import * as Schema from "effect/Schema";
10
10
  import * as Scope from "effect/Scope";
11
11
  import * as Sink from "effect/Sink";
12
12
  import * as Stream from "effect/Stream";
13
- import { parseAccepted, parseWithdrawResult, parseCancelResult, parseLatestPendingPrompt, parseForkedSession, parseCreatedSession, parseDeletedSession, parseExtensionOutput, parseExtensionStatus, parseExtensionStatuses, parseSessionFrame, parseSessionSnapshot, parseSessionSummaryPage, parseShiitakeError, } from "./client-wire.js";
13
+ import { parseAccepted, parseCommandSettlement, parseWithdrawResult, parseCancelResult, parseLatestPendingPrompt, parseForkedSession, parseCreatedSession, parseDeletedSession, parseExtensionOutput, parseExtensionStatus, parseExtensionStatuses, parseSessionFrame, parseSessionSnapshot, parseSessionSummaryPage, parseShiitakeError, } from "./client-wire.js";
14
14
  import { ShiitakeError } from "./errors.js";
15
15
  import { decodeClientReadOptions, decodeClientSessionCreationRequestId, decodeClientPageCursor, decodeClientSessionId, decodeClientSubscribeOptions, } from "./internal/client-boundary.js";
16
16
  import { toPromiseAgent } from "./internal/effect-client.js";
@@ -248,6 +248,10 @@ function makeRemoteEffectAgent(options) {
248
248
  params.set("includeSandboxFailures", "true");
249
249
  return request((signal) => fetch(`${addressedUrl}?${params.toString()}`, { signal }), parseSessionSnapshot, "Session read");
250
250
  })),
251
+ readSettlement: (commandId, timeoutMs = 0) => ensureOpen.pipe(Effect.andThen(request((signal) => fetch(`${addressedUrl}/settlement?${new URLSearchParams({
252
+ commandId,
253
+ timeoutMs: String(timeoutMs),
254
+ }).toString()}`, { signal }), parseCommandSettlement, "Command settlement"))),
251
255
  subscribe: (input) => Stream.unwrap(ensureOpen.pipe(Effect.andThen(Effect.try({
252
256
  try: () => decodeClientSubscribeOptions(input),
253
257
  catch: (error) => error instanceof ShiitakeError
package/dist/testing.d.ts CHANGED
@@ -40,8 +40,11 @@ export type MemoryHost = Readonly<{
40
40
  agent: ShiitakeOwnerAgent;
41
41
  /** Invoke one canonical reconciliation tick; no timer runs in the background. */
42
42
  reconcile: () => Promise<void>;
43
- /** Drain pending Agent work with a 1–1,000 tick budget (default 1,000). */
44
- settle: (sessionId: string, maxTicks?: number) => Promise<SessionSnapshot>;
43
+ /** Drain pending Agent work with a 1–1,000 tick budget (default 1,000).
44
+ * The optional test hook runs after pending work ends, before the snapshot
45
+ * context read, so load tests can end a timed turn at the terminal.
46
+ */
47
+ settle: (sessionId: string, maxTicks?: number, beforeRead?: () => Promise<void>) => Promise<SessionSnapshot>;
45
48
  /** Close the Agent and only the database owned by this helper. */
46
49
  close: () => Promise<void>;
47
50
  }>;
package/dist/testing.js CHANGED
@@ -176,12 +176,14 @@ export async function memoryHost(options = {}) {
176
176
  return {
177
177
  agent,
178
178
  reconcile: async () => requireBound().reconcile(),
179
- settle: async (sessionId, maxTicks) => {
179
+ settle: async (sessionId, maxTicks, beforeRead) => {
180
180
  const budget = settlementBudgetSchema.parse(maxTicks);
181
181
  for (let tick = 0; tick < budget; tick += 1) {
182
182
  const host = requireBound();
183
- if (!(await host.pendingWork()).pending)
183
+ if (!(await host.pendingWork()).pending) {
184
+ await beforeRead?.();
184
185
  return agent.session(sessionId).read();
186
+ }
185
187
  await host.reconcile();
186
188
  }
187
189
  throw new Error(`Memory host did not settle within ${String(budget)} reconciliation ticks`);
@@ -219,7 +219,12 @@ Host-only identity and admitted command metadata for one delegated send.
219
219
  ```ts
220
220
  type AgentControlDirectory = Readonly<{
221
221
  list: (signal, options?) => Promise<readonly AgentControlAgent[]>;
222
- send?: (input, sourceSessionId, signal, invocation?) => Promise<Accepted>;
222
+ send?: (
223
+ input,
224
+ sourceSessionId,
225
+ signal,
226
+ invocation?,
227
+ ) => Promise<Accepted | Held>;
223
228
  recall?: (input, sourceSessionId, signal) => Promise<CancelResult>;
224
229
  create?: (input, signal) => Promise<AgentControlAgent>;
225
230
  setModel?: (input, signal) => Promise<void>;
@@ -262,10 +267,10 @@ report back, its store predates the current schema, or the Agent is archived.
262
267
 
263
268
  ## Functions
264
269
 
265
- ### waitForAgentTerminal()
270
+ ### waitForAgentSettlement()
266
271
 
267
272
  ```ts
268
- function waitForAgentTerminal(
273
+ function waitForAgentSettlement(
269
274
  session,
270
275
  commandId,
271
276
  timeoutMs,
@@ -273,16 +278,16 @@ function waitForAgentTerminal(
273
278
  startedAt?,
274
279
  ): Promise<
275
280
  Readonly<{
276
- commandId: string;
277
- outcome: "completed" | "failed" | "cancelled" | "interrupted";
278
- sequence: number;
279
- diagnostic: string | null;
280
- meta?: Json;
281
+ acceptedSender: string | null;
282
+ terminal: SessionTerminal;
283
+ content: string;
284
+ usage: readonly Pick<AssistantMessage, "model" | "provider" | "usage">[];
285
+ toolCalls: number;
281
286
  }>
282
287
  >;
283
288
  ```
284
289
 
285
- Observe one durable terminal; timeout or abort closes observation, not the work.
290
+ Await one exact durable terminal without opening a transcript observation.
286
291
 
287
292
  #### Parameters
288
293
 
@@ -296,9 +301,10 @@ Observe one durable terminal; timeout or abort closes observation, not the work.
296
301
 
297
302
  #### Returns
298
303
 
299
- `Promise`\<`Readonly`\<\{ `commandId`: `string`; `outcome`: `"completed"` \|
300
- `"failed"` \| `"cancelled"` \| `"interrupted"`; `sequence`: `number`;
301
- `diagnostic`: `string` \| `null`; `meta?`: [`Json`](extensions.md#json); \}\>\>
304
+ `Promise`\<`Readonly`\<\{ `acceptedSender`: `string` \| `null`; `terminal`:
305
+ [`SessionTerminal`](client.md#sessionterminal); `content`: `string`; `usage`:
306
+ readonly `Pick`\<`AssistantMessage`, `"model"` \| `"provider"` \| `"usage"`\>[];
307
+ `toolCalls`: `number`; \}\>\>
302
308
 
303
309
  ---
304
310