@junejs/server 0.1.2-dev.4 → 0.1.2-dev.7

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.
@@ -36,6 +36,8 @@ declare class DoSessionStore implements SessionStore {
36
36
  delStep(id: string): void;
37
37
  getStatus(): string;
38
38
  setStatus(s: string): void;
39
+ getSessionKey(): string | undefined;
40
+ setSessionKey(key: string): void;
39
41
  tx<T>(fn: () => T): T;
40
42
  unwrap<H = unknown>(): H;
41
43
  }
@@ -49,23 +51,48 @@ type DoAgentDef = {
49
51
  env?: unknown;
50
52
  resources?: Resources;
51
53
  services?: unknown;
54
+ onTurnError?: (failure: {
55
+ turnId: string;
56
+ error: {
57
+ message: string;
58
+ };
59
+ }) => void | Promise<void>;
52
60
  };
53
61
  declare class AgentDurableObject {
54
62
  private session;
63
+ private sessionKey;
64
+ private readonly name;
65
+ private readonly store;
66
+ private readonly sink;
67
+ private readonly model;
68
+ private readonly tools;
69
+ private readonly channelInstructions?;
55
70
  private readonly resources;
56
71
  private readonly services;
57
72
  constructor(state: DurableObjectState, def: DoAgentDef);
73
+ private resolveSession;
58
74
  turn(input: {
59
75
  turnId?: string;
60
76
  userText: string;
61
77
  event?: InboundEvent;
62
78
  trigger?: ProactiveTrigger;
79
+ session?: string;
63
80
  }): Promise<string>;
81
+ start(input: {
82
+ turnId?: string;
83
+ userText: string;
84
+ event?: InboundEvent;
85
+ trigger?: ProactiveTrigger;
86
+ session?: string;
87
+ }): Promise<{
88
+ turnId: string;
89
+ }>;
64
90
  transcript(): import("@junejs/core/agent-runtime").Turn[];
65
91
  fetch(req: Request): Promise<Response>;
66
92
  }
67
93
  declare function sseTurnFinalText(res: Response): Promise<string>;
68
94
  declare function sseTurnEvents(res: Response): AsyncIterable<TurnEvent>;
95
+ declare const SESSION_HEADER = "x-june-session";
69
96
  declare function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response>;
70
97
  declare function durableAgentSurface(getNamespace: () => DurableObjectNamespace | undefined, opts: {
71
98
  agentName: string;
@@ -79,4 +106,4 @@ declare function durableChannelSurface(getNamespace: () => DurableObjectNamespac
79
106
  waitUntil?: (p: Promise<unknown>) => void;
80
107
  }): (req: Request) => Promise<Response | null>;
81
108
  //#endregion
82
- export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
109
+ export { AgentDurableObject, DoAgentDef, DoSessionStore, DurableObjectNamespace, DurableObjectState, DurableObjectStub, DurableStorage, SESSION_HEADER, SqlStorage, SqlStorageCursor, SqlStorageValue, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -40,6 +40,13 @@ var DoSessionStore = class {
40
40
  setStatus(s) {
41
41
  this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
42
42
  }
43
+ getSessionKey() {
44
+ const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
45
+ return rows.length ? rows[0].v : void 0;
46
+ }
47
+ setSessionKey(key) {
48
+ this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
49
+ }
43
50
  tx(fn) {
44
51
  return this.storage.transactionSync(fn);
45
52
  }
@@ -66,6 +73,13 @@ const crossDoUnsupported = { session() {
66
73
  } };
67
74
  var AgentDurableObject = class {
68
75
  session;
76
+ sessionKey;
77
+ name;
78
+ store;
79
+ sink;
80
+ model;
81
+ tools;
82
+ channelInstructions;
69
83
  resources;
70
84
  services;
71
85
  constructor(state, def) {
@@ -73,6 +87,26 @@ var AgentDurableObject = class {
73
87
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
74
88
  this.resources = def.resources ?? {};
75
89
  this.services = def.services;
90
+ const name = def.name ?? "agent";
91
+ const sink = new InProcEventSink();
92
+ sink.subscribe((e) => {
93
+ if (e.type !== "turn.failed") return;
94
+ const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed: ${e.error.message}`);
95
+ if (def.onTurnError) try {
96
+ const out = def.onTurnError({
97
+ turnId: e.turnId,
98
+ error: e.error
99
+ });
100
+ if (out && typeof out.then === "function") out.then(void 0, (hookErr) => {
101
+ console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
102
+ defaultLog();
103
+ });
104
+ return;
105
+ } catch (hookErr) {
106
+ console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
107
+ }
108
+ defaultLog();
109
+ });
76
110
  const channelTools = (def.channels ?? []).flatMap((c) => resolveChannel(c, def.env).tools?.() ?? []);
77
111
  const tools = [...def.tools];
78
112
  const seen = new Set(tools.map((t) => t.spec.name));
@@ -81,29 +115,62 @@ var AgentDurableObject = class {
81
115
  seen.add(t.spec.name);
82
116
  tools.push(t);
83
117
  }
84
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
118
+ this.name = name;
119
+ this.store = store;
120
+ this.sink = sink;
121
+ this.model = model;
122
+ this.tools = tools;
123
+ this.channelInstructions = def.channelInstructions;
124
+ }
125
+ resolveSession(key) {
126
+ if (key !== void 0) assertSessionKey(key);
127
+ if (this.session) {
128
+ if (key !== void 0 && this.sessionKey !== key) throw new Error(this.sessionKey === "self" ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)` : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
129
+ return this.session;
130
+ }
131
+ const stored = this.store.getSessionKey();
132
+ if (key !== void 0 && stored !== void 0 && key !== stored) throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
133
+ const resolved = key ?? stored ?? "self";
134
+ if (key !== void 0 && key !== "self" && stored === void 0) this.store.setSessionKey(key);
135
+ this.sessionKey = resolved;
136
+ this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
137
+ return this.session;
85
138
  }
86
139
  async turn(input) {
87
140
  await ensureScope();
141
+ const session = this.resolveSession(input.session);
88
142
  return runInScope({
89
143
  resources: this.resources,
90
144
  services: this.services
91
- }, () => this.session.turn(input));
145
+ }, () => session.turn(input));
146
+ }
147
+ async start(input) {
148
+ await ensureScope();
149
+ const session = this.resolveSession(input.session);
150
+ return runInScope({
151
+ resources: this.resources,
152
+ services: this.services
153
+ }, () => session.start(input));
92
154
  }
93
155
  transcript() {
94
- return this.session.transcript();
156
+ if (this.session) return this.session.transcript();
157
+ if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
158
+ return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
95
159
  }
96
160
  async fetch(req) {
97
161
  const url = new URL(req.url);
162
+ const key = req.headers.get("x-june-session") ?? void 0;
98
163
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
99
164
  const { userText, turnId, event, trigger } = await req.json();
100
165
  await ensureScope();
166
+ let session;
101
167
  let started;
102
168
  try {
169
+ session = this.resolveSession(key);
103
170
  started = runInScope({
104
171
  resources: this.resources,
105
172
  services: this.services
106
- }, () => this.session.start({
173
+ }, () => session.start({
107
174
  userText,
108
175
  turnId,
109
176
  event,
@@ -112,23 +179,33 @@ var AgentDurableObject = class {
112
179
  } catch (err) {
113
180
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
114
181
  }
115
- return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
182
+ if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
183
+ return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
116
184
  }
117
185
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
118
186
  const { turnId, inputId, input, by } = await req.json();
119
187
  await ensureScope();
188
+ let session;
120
189
  try {
190
+ session = this.resolveSession(key);
121
191
  runInScope({
122
192
  resources: this.resources,
123
193
  services: this.services
124
- }, () => this.session.resume(turnId, inputId, input, { by }));
194
+ }, () => session.resume(turnId, inputId, input, { by }));
125
195
  } catch (err) {
126
196
  const message = err instanceof Error ? err.message : String(err);
127
197
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
128
198
  }
129
- return new Response(sseTurnStream(this.session, turnId), { headers: SSE_HEADERS });
199
+ return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
200
+ }
201
+ if (url.pathname.endsWith("/transcript")) {
202
+ if (key !== void 0) try {
203
+ return Response.json({ transcript: this.resolveSession(key).transcript() });
204
+ } catch (err) {
205
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
206
+ }
207
+ return Response.json({ transcript: this.transcript() });
130
208
  }
131
- if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
132
209
  return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
133
210
  }
134
211
  };
@@ -212,8 +289,16 @@ async function* sseTurnEvents(res) {
212
289
  if (done) break;
213
290
  }
214
291
  }
292
+ const SESSION_HEADER = "x-june-session";
293
+ const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
294
+ function assertSessionKey(session) {
295
+ if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
296
+ }
215
297
  function durableFetch(namespace, agent, session, req) {
216
- return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
298
+ assertSessionKey(session);
299
+ const headers = new Headers(req.headers);
300
+ headers.set(SESSION_HEADER, session);
301
+ return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
217
302
  }
218
303
  function durableAgentSurface(getNamespace, opts) {
219
304
  const chatPath = opts.chatPath ?? "/message";
@@ -223,6 +308,11 @@ function durableAgentSurface(getNamespace, opts) {
223
308
  const namespace = getNamespace();
224
309
  if (!namespace) return null;
225
310
  const { message, session } = await req.json();
311
+ if (session !== void 0) try {
312
+ assertSessionKey(session);
313
+ } catch (err) {
314
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
315
+ }
226
316
  const res = await durableFetch(namespace, opts.agentName, session ?? "default", new Request("https://do/turn", {
227
317
  method: "POST",
228
318
  body: JSON.stringify({ userText: message })
@@ -251,6 +341,19 @@ function durableChannelSurface(getNamespace, opts) {
251
341
  body: serializeTurn(message, o)
252
342
  })));
253
343
  },
344
+ runDetached: async (message, o) => {
345
+ const namespace = getNamespace();
346
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
347
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
348
+ method: "POST",
349
+ body: serializeTurn(message, o)
350
+ }));
351
+ if (res.status !== 202) {
352
+ const detail = (await res.text()).slice(0, 200);
353
+ throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
354
+ }
355
+ return await res.json();
356
+ },
254
357
  runStream: async function* (message, o) {
255
358
  const namespace = getNamespace();
256
359
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
@@ -302,4 +405,4 @@ function serializeTurn(userText, o) {
302
405
  }
303
406
  }
304
407
  //#endregion
305
- export { AgentDurableObject, DoSessionStore, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
408
+ export { AgentDurableObject, DoSessionStore, SESSION_HEADER, durableAgentSurface, durableChannelSurface, durableFetch, sseTurnEvents, sseTurnFinalText };
@@ -164,6 +164,12 @@ function mountAgent(agent, runtime, opts = {}) {
164
164
  userText: message,
165
165
  event: o?.event,
166
166
  trigger: o?.trigger
167
+ }),
168
+ runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
169
+ turnId: o?.turnId,
170
+ userText: message,
171
+ event: o?.event,
172
+ trigger: o?.trigger
167
173
  })
168
174
  };
169
175
  const channels = channelFetch(agent, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.1.2-dev.4",
3
+ "version": "0.1.2-dev.7",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -125,7 +125,7 @@
125
125
  "build": "tsdown"
126
126
  },
127
127
  "dependencies": {
128
- "@junejs/core": ">=0.1.1-dev.8",
128
+ "@junejs/core": ">=0.1.1-dev.14",
129
129
  "@junejs/db": ">=0.0.34-dev.0",
130
130
  "@momiji-rs/sparkdown": "^0.0.4",
131
131
  "lightningcss": "^1.27.0",
@@ -117,6 +117,17 @@ export class DoSessionStore implements SessionStore {
117
117
  setStatus(s: string) {
118
118
  this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('status', ?) ON CONFLICT(k) DO UPDATE SET v = ?", s, s);
119
119
  }
120
+ // The externally-assigned session key (#75). A DO cannot read its own idFromName
121
+ // name, so the key arrives on the first routed request and is persisted here —
122
+ // surviving hibernation/eviction, where the constructor re-runs with no request
123
+ // in hand. Not part of core's SessionStore contract: only the DO seam needs it.
124
+ getSessionKey(): string | undefined {
125
+ const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'session_key'").toArray();
126
+ return rows.length ? rows[0]!.v : undefined;
127
+ }
128
+ setSessionKey(key: string) {
129
+ this.sql.exec("INSERT INTO agent_meta (k, v) VALUES ('session_key', ?) ON CONFLICT(k) DO UPDATE SET v = ?", key, key);
130
+ }
120
131
  tx<T>(fn: () => T): T {
121
132
  return this.storage.transactionSync(fn);
122
133
  }
@@ -171,6 +182,14 @@ export type DoAgentDef = {
171
182
  env?: unknown;
172
183
  resources?: Resources;
173
184
  services?: unknown;
185
+ // Called on every turn.failed for this session — the app's seam for routing turn
186
+ // failures to its own telemetry (Sentry, a ledger, …). Providing it REPLACES the
187
+ // default console.error; if the hook throws (or an async hook rejects), the default
188
+ // log fires anyway (both errors), so a failure is never silent. It happens to run
189
+ // inside the failed turn's request scope today (turn.failed is emitted mid-turn and
190
+ // ALS propagates), but that is NOT contract — don't rely on ambient db/services;
191
+ // close over what you need.
192
+ onTurnError?: (failure: { turnId: string; error: { message: string } }) => void | Promise<void>;
174
193
  };
175
194
 
176
195
  // The agent runtime INSIDE a Durable Object. A plain class (constructor takes the
@@ -188,7 +207,20 @@ export type DoAgentDef = {
188
207
  // fetch(req) { return this.agent.fetch(req); }
189
208
  // }
190
209
  export class AgentDurableObject {
191
- private session: AgentSession;
210
+ // The AgentSession is constructed LAZILY (#75): the DO is keyed externally by
211
+ // idFromName(`${agent}:${session}`) but cannot read its own name, so the true
212
+ // session key only exists on a routed request (the SESSION_HEADER durableFetch
213
+ // sets). First keyed request wins and persists the key (agent_meta) so it
214
+ // survives hibernation; key-less paths fall back to the persisted key, then
215
+ // "self" (the pre-#75 behavior, kept for backward compatibility).
216
+ private session: AgentSession | undefined;
217
+ private sessionKey: string | undefined;
218
+ private readonly name: string;
219
+ private readonly store: DoSessionStore;
220
+ private readonly sink: InProcEventSink;
221
+ private readonly model: Model;
222
+ private readonly tools: Tool[];
223
+ private readonly channelInstructions?: Record<string, string>;
192
224
  // Built from the DO's env in the constructor (this isolate), shared across turns —
193
225
  // env is stable per isolate, like bindWorkerResources memoizes per worker isolate.
194
226
  private readonly resources: Resources;
@@ -198,6 +230,40 @@ export class AgentDurableObject {
198
230
  const model = def.instructions ? withSystem(def.model, def.instructions) : def.model;
199
231
  this.resources = def.resources ?? {};
200
232
  this.services = def.services;
233
+ const name = def.name ?? "agent";
234
+ // Failure observability (#76): a turn that dies after the fast-ACK has no other
235
+ // observable surface on the edge — the webhook already 200'd and runBackground
236
+ // swallows the rejection unless the channel wired onError. Subscribing HERE (the
237
+ // session sink) covers every turn path — turn(), /turn, /resume — with one seam.
238
+ // Default: console.error, which wrangler tail surfaces. An app onTurnError takes
239
+ // over reporting; if IT throws, fall back to the default so nothing goes silent.
240
+ // (InProcEventSink already guards emit against a throwing subscriber.)
241
+ const sink = new InProcEventSink();
242
+ sink.subscribe((e) => {
243
+ if (e.type !== "turn.failed") return;
244
+ const defaultLog = () => console.error(`[june] agent "${name}" turn ${e.turnId} failed: ${e.error.message}`);
245
+ if (def.onTurnError) {
246
+ try {
247
+ const out = def.onTurnError({ turnId: e.turnId, error: e.error });
248
+ // An async hook (e.g. `await sendToSentry(...)`) types as void but returns a
249
+ // Promise — a rejection there must hit the same fallback as a sync throw, or
250
+ // the failure goes silent again (plus an unhandled rejection). No waitUntil
251
+ // keeper is needed here: unlike a Worker, a DO stays active while it has
252
+ // pending work/IO, and DurableObjectState.waitUntil is documented as a no-op —
253
+ // this .then subscription is what retains the promise.
254
+ if (out && typeof (out as Promise<void>).then === "function") {
255
+ (out as Promise<void>).then(undefined, (hookErr) => {
256
+ console.error(`[june] agent "${name}": onTurnError hook rejected:`, hookErr);
257
+ defaultLog();
258
+ });
259
+ }
260
+ return;
261
+ } catch (hookErr) {
262
+ console.error(`[june] agent "${name}": onTurnError hook threw:`, hookErr);
263
+ }
264
+ }
265
+ defaultLog();
266
+ });
201
267
  // Merge the mounted channels' capability tools (built here from this DO's env, since a
202
268
  // tool's `run` closure can't cross the RPC). The cross-channel source gate on each tool
203
269
  // keeps a Slack tool inert during a Crisp turn, so merging all of them is safe.
@@ -209,7 +275,49 @@ export class AgentDurableObject {
209
275
  seen.add(t.spec.name);
210
276
  tools.push(t);
211
277
  }
212
- this.session = new AgentSession(def.name ?? "agent", "self", store, new InProcEventSink(), model, tools, crossDoUnsupported, def.channelInstructions);
278
+ this.name = name;
279
+ this.store = store;
280
+ this.sink = sink;
281
+ this.model = model;
282
+ this.tools = tools;
283
+ this.channelInstructions = def.channelInstructions;
284
+ }
285
+ // Resolve THE session for this DO: explicit key (from a routed request) → persisted
286
+ // key (a prior life learned it) → "self". A key that contradicts the persisted or
287
+ // live identity is a mis-route (or a key-less path was used first) — fail loudly:
288
+ // silently proceeding is exactly the per-conversation data corruption #75 hit.
289
+ private resolveSession(key?: string): AgentSession {
290
+ // Every entry point shares the session-key contract (turn({ session }) and hand-rolled
291
+ // SESSION_HEADER values included, not just durableFetch): an invalid key persisted here
292
+ // would bind an identity no routed request could ever address — an orphaned session.
293
+ if (key !== undefined) assertSessionKey(key);
294
+ // Live fast path — no storage read once the session exists: this method is the only
295
+ // writer of the persisted key, so within one life it cannot diverge from the live one.
296
+ if (this.session) {
297
+ if (key !== undefined && this.sessionKey !== key) {
298
+ throw new Error(
299
+ this.sessionKey === "self"
300
+ ? `agent "${this.name}": request session "${key}" arrived, but this session is bound to the placeholder "self" (first used without a real key) — carry the key on every path (route through durableFetch, or pass turn({ session }) on the direct API)`
301
+ : `agent "${this.name}": request session "${key}" does not match this object's session "${this.sessionKey}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`,
302
+ );
303
+ }
304
+ return this.session;
305
+ }
306
+ const stored = this.store.getSessionKey();
307
+ if (key !== undefined && stored !== undefined && key !== stored) {
308
+ throw new Error(`agent "${this.name}": request session "${key}" does not match this object's session "${stored}" — one DO is one session; address each session by its own key (durableFetch, or turn({ session }) on the direct API)`);
309
+ }
310
+ const resolved = key ?? stored ?? "self";
311
+ // "self" is deliberately NOT persisted — even when a caller passes it EXPLICITLY: it
312
+ // is a placeholder ("no identity externally assigned yet"), not an identity. Within
313
+ // one life a keyed request after a "self" binding is refused above — two concurrently
314
+ // active paths disagreeing is a live bug. But across eviction a keyed request ADOPTS
315
+ // the legacy transcript: that asymmetry IS the migration path from pre-keyed
316
+ // deployments, where a persisted "self" would 409 every keyed request forever.
317
+ if (key !== undefined && key !== "self" && stored === undefined) this.store.setSessionKey(key);
318
+ this.sessionKey = resolved;
319
+ this.session = new AgentSession(this.name, resolved, this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions);
320
+ return this.session;
213
321
  }
214
322
  // Run the whole turn inside a request scope seeded from this DO's env, so ambient
215
323
  // `db`/`kv`/`blob` and `currentServices()` resolve inside a tool exactly as in a
@@ -218,32 +326,59 @@ export class AgentDurableObject {
218
326
  // Juno's batch-loader registry) can't leak across turns on a long-lived DO.
219
327
  // ensureScope() lazily wires node:async_hooks (workerd via nodejs_compat), as the
220
328
  // pipeline does; without it runInScope is a pass-through and ambient reads throw.
221
- async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger }): Promise<string> {
329
+ async turn(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<string> {
222
330
  await ensureScope();
223
- return runInScope({ resources: this.resources, services: this.services }, () => this.session.turn(input));
331
+ const session = this.resolveSession(input.session);
332
+ return runInScope({ resources: this.resources, services: this.services }, () => session.turn(input));
224
333
  }
334
+ // Fire-and-forget for custom shells (#77): resolves once the turn is durably ACCEPTED;
335
+ // it then runs under the DO's own lifetime (a DO stays alive while it has pending
336
+ // work), not the caller's. No live consumer sees the result — failures surface via
337
+ // the default turn-failure log / onTurnError (#76).
338
+ async start(input: { turnId?: string; userText: string; event?: InboundEvent; trigger?: ProactiveTrigger; session?: string }): Promise<{ turnId: string }> {
339
+ await ensureScope();
340
+ const session = this.resolveSession(input.session);
341
+ return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
342
+ }
343
+ // Read-only: folds the durable log. When an identity exists (live, or persisted from a
344
+ // prior life) the session resolves and caches like any other path. Only a read on a
345
+ // NEVER-keyed object stays non-committal — caching there would bake "self" in as the
346
+ // identity and 409 a later keyed turn — so it folds through an ephemeral AgentSession.
225
347
  transcript() {
226
- return this.session.transcript();
348
+ if (this.session) return this.session.transcript();
349
+ if (this.store.getSessionKey() !== undefined) return this.resolveSession().transcript();
350
+ return new AgentSession(this.name, "self", this.store, this.sink, this.model, this.tools, crossDoUnsupported, this.channelInstructions).transcript();
227
351
  }
228
352
  // Default HTTP surface: POST …/turn STREAMS the turn's TurnEvents as SSE (start the
229
353
  // turn in-scope, then stream from the session sink); GET …/transcript reads the log.
230
354
  // The app can call turn()/transcript() directly instead (turn() awaits the final text).
231
355
  async fetch(req: Request): Promise<Response> {
232
356
  const url = new URL(req.url);
357
+ // The session key durableFetch stamped on the routed request (#75); absent on a
358
+ // hand-rolled fetch → the pre-#75 "self" fallback keeps old callers working.
359
+ const key = req.headers.get(SESSION_HEADER) ?? undefined;
233
360
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
234
361
  const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
235
362
  await ensureScope();
236
363
  // start() schedules the turn on the chain WITHIN the scope, so it runs with ambient
237
364
  // db/services (ALS propagates to the .then continuation registered here); subscribing
238
365
  // happens synchronously right after, before any event can emit.
366
+ let session: AgentSession;
239
367
  let started: { turnId: string };
240
368
  try {
241
- started = runInScope({ resources: this.resources, services: this.services }, () => this.session.start({ userText, turnId, event, trigger }));
369
+ session = this.resolveSession(key);
370
+ started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
242
371
  } catch (err) {
243
- // e.g. the session is suspended awaiting input — a client-resolvable conflict, not a crash
372
+ // e.g. the session is suspended awaiting input, or the key mis-matches this
373
+ // object's identity — a client-resolvable conflict, not a crash
244
374
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
245
375
  }
246
- return new Response(sseTurnStream(this.session, started.turnId), { headers: SSE_HEADERS });
376
+ // DETACHED (#77): the turn is accepted — 202 now, no stream. It keeps running under
377
+ // this DO's lifetime (alive while work is pending), so its duration is no longer
378
+ // bounded by however long the CALLER can hold a connection (the edge waitUntil
379
+ // ceiling). Nobody consumes the result; failures surface via the #76 log/hook.
380
+ if (url.searchParams.get("detach") === "1") return Response.json({ turnId: started.turnId }, { status: 202 });
381
+ return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
247
382
  }
248
383
  // Provide the input a suspended turn is waiting on and stream its continuation as SSE.
249
384
  // NOTE: this default surface passes `by` straight from the body — the engine treats it as a
@@ -252,16 +387,29 @@ export class AgentDurableObject {
252
387
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
253
388
  const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
254
389
  await ensureScope();
390
+ let session: AgentSession;
255
391
  try {
256
- runInScope({ resources: this.resources, services: this.services }, () => this.session.resume(turnId, inputId, input, { by }));
392
+ session = this.resolveSession(key);
393
+ runInScope({ resources: this.resources, services: this.services }, () => session.resume(turnId, inputId, input, { by }));
257
394
  } catch (err) {
258
395
  const message = err instanceof Error ? err.message : String(err);
259
- // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id
396
+ // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
260
397
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
261
398
  }
262
- return new Response(sseTurnStream(this.session, turnId), { headers: SSE_HEADERS });
399
+ return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
400
+ }
401
+ if (url.pathname.endsWith("/transcript")) {
402
+ // A keyed read still conflict-checks (and may commit the key — it is authentic,
403
+ // idFromName routed it here); a key-less read stays non-committal via transcript().
404
+ if (key !== undefined) {
405
+ try {
406
+ return Response.json({ transcript: this.resolveSession(key).transcript() });
407
+ } catch (err) {
408
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
409
+ }
410
+ }
411
+ return Response.json({ transcript: this.transcript() });
263
412
  }
264
- if (url.pathname.endsWith("/transcript")) return Response.json({ transcript: this.transcript() });
265
413
  return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
266
414
  }
267
415
  }
@@ -352,10 +500,31 @@ export async function* sseTurnEvents(res: Response): AsyncIterable<TurnEvent> {
352
500
  }
353
501
  }
354
502
 
503
+ // The header durableFetch stamps the session key on (#75). A DO cannot read its own
504
+ // idFromName name, so this is how the externally-assigned session identity crosses
505
+ // into the object — exported so a custom shell that bypasses durableFetch can set it.
506
+ export const SESSION_HEADER = "x-june-session";
507
+
508
+ // A session key rides an HTTP header (SESSION_HEADER) and the DO name (idFromName) —
509
+ // reject values that can't: CR/LF would be header injection, and Headers.set throws a
510
+ // bare TypeError on control/non-ASCII bytes, turning a bad client value into an opaque
511
+ // 500 deep inside durableFetch. Non-empty printable ASCII is the contract.
512
+ const SESSION_KEY_RE = /^[\x20-\x7E]+$/;
513
+ function assertSessionKey(session: string) {
514
+ if (typeof session !== "string" || !SESSION_KEY_RE.test(session)) {
515
+ throw new Error(`invalid session key ${JSON.stringify(session)} — a session key must be a non-empty printable-ASCII string (it rides an HTTP header and the DO name)`);
516
+ }
517
+ }
518
+
355
519
  // Worker-side routing: address a session's DO by (agent, session) and forward the
356
- // request to it. `env.AGENT` is the DO namespace binding.
520
+ // request to it. `env.AGENT` is the DO namespace binding. The session key rides a
521
+ // header (not the body): one seam covers /turn, /resume, and /transcript without
522
+ // touching any body contract, and an older DO simply ignores it.
357
523
  export function durableFetch(namespace: DurableObjectNamespace, agent: string, session: string, req: Request): Promise<Response> {
358
- return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(req);
524
+ assertSessionKey(session);
525
+ const headers = new Headers(req.headers);
526
+ headers.set(SESSION_HEADER, session);
527
+ return namespace.get(namespace.idFromName(`${agent}:${session}`)).fetch(new Request(req, { headers }));
359
528
  }
360
529
 
361
530
  // The EDGE agent surface for June's router: route the chat endpoint to the
@@ -377,6 +546,15 @@ export function durableAgentSurface(
377
546
  const namespace = getNamespace();
378
547
  if (!namespace) return null; // no DO binding → not mounted here
379
548
  const { message, session } = (await req.json()) as { message: string; session?: string };
549
+ // `session` is CLIENT input here — an un-headerable value must be a clear 400, not
550
+ // the opaque 500 assertSessionKey would become this deep in the worker.
551
+ if (session !== undefined) {
552
+ try {
553
+ assertSessionKey(session);
554
+ } catch (err) {
555
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 });
556
+ }
557
+ }
380
558
  // Forward to the session's DO on its /turn contract — which now STREAMS SSE. A client
381
559
  // that asks for the stream (Accept: text/event-stream) gets live TurnEvents piped
382
560
  // through; otherwise collapse to the final { text } (the prior JSON contract).
@@ -450,6 +628,25 @@ export function durableChannelSurface(
450
628
  // /turn streams SSE; the simple path collapses it to the final text.
451
629
  return sseTurnFinalText(res);
452
630
  },
631
+ // FIRE-AND-FORGET (#77): POST /turn?detach=1 — the DO 202s once the turn is accepted
632
+ // and runs it under its OWN lifetime. This is how a shadow/observe hook (onEvent →
633
+ // assessment turn, reply dropped) escapes the edge waitUntil ceiling that killed
634
+ // 24–38s turns in production: nothing holds the edge open while the turn runs.
635
+ runDetached: async (message, o) => {
636
+ const namespace = getNamespace();
637
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
638
+ const res = await durableFetch(
639
+ namespace,
640
+ opts.agentName,
641
+ o?.session ?? "default",
642
+ new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
643
+ );
644
+ if (res.status !== 202) {
645
+ const detail = (await res.text()).slice(0, 200);
646
+ throw new Error(`runDetached: turn was not accepted (status ${res.status}): ${detail}`);
647
+ }
648
+ return (await res.json()) as { turnId: string };
649
+ },
453
650
  // The LIVE path: hand the channel the TurnEvent stream so it can render as the turn runs.
454
651
  runStream: async function* (message, o) {
455
652
  const namespace = getNamespace();
@@ -214,6 +214,11 @@ export function mountAgent(
214
214
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
215
215
  run: (message, o) =>
216
216
  runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
217
+ // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
218
+ // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
219
+ // written against ctx.runDetached behaves identically on both targets.
220
+ runDetached: async (message, o) =>
221
+ runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
217
222
  };
218
223
  const channels = channelFetch(agent, ctx);
219
224
  const surface = async (req: Request): Promise<Response | null> => {