@junejs/server 1.0.0-dev.12 → 1.0.0-dev.14

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.
@@ -39,6 +39,7 @@ declare class DoSessionStore implements SessionStore {
39
39
  getSessionKey(): string | undefined;
40
40
  setSessionKey(key: string): void;
41
41
  tx<T>(fn: () => T): T;
42
+ reset(): number;
42
43
  unwrap<H = unknown>(): H;
43
44
  }
44
45
  type DoAgentDef = {
@@ -88,6 +89,12 @@ declare class AgentDurableObject {
88
89
  }): Promise<{
89
90
  turnId: string;
90
91
  }>;
92
+ reset(opts?: {
93
+ session?: string;
94
+ }): Promise<{
95
+ previousSession: string;
96
+ generation: number;
97
+ }>;
91
98
  transcript(): import("@junejs/core/agent-runtime").Turn[];
92
99
  fetch(req: Request): Promise<Response>;
93
100
  }
@@ -52,6 +52,22 @@ var DoSessionStore = class {
52
52
  tx(fn) {
53
53
  return this.storage.transactionSync(fn);
54
54
  }
55
+ reset() {
56
+ return this.storage.transactionSync(() => {
57
+ const sql = this.sql;
58
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
59
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (generation INTEGER, id TEXT, output TEXT)`);
60
+ const rows = this.sql.exec("SELECT v FROM agent_meta WHERE k = 'generation'").toArray();
61
+ const generation = rows.length ? Number(rows[0].v) : 0;
62
+ sql.exec("INSERT INTO agent_messages_archive (generation, seq, body) SELECT ?, seq, body FROM agent_messages", generation);
63
+ sql.exec("DELETE FROM agent_messages");
64
+ sql.exec("INSERT INTO agent_steps_archive (generation, id, output) SELECT ?, id, output FROM agent_steps", generation);
65
+ sql.exec("DELETE FROM agent_steps");
66
+ this.setStatus("new");
67
+ sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
68
+ return generation;
69
+ });
70
+ }
55
71
  unwrap() {
56
72
  return this.sql;
57
73
  }
@@ -160,6 +176,9 @@ var AgentDurableObject = class {
160
176
  services: this.services
161
177
  }, () => session.start(input));
162
178
  }
179
+ reset(opts) {
180
+ return this.resolveSession(opts?.session).reset();
181
+ }
163
182
  transcript() {
164
183
  if (this.session) return this.session.transcript();
165
184
  if (this.store.getSessionKey() !== void 0) return this.resolveSession().transcript();
@@ -171,6 +190,7 @@ var AgentDurableObject = class {
171
190
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
172
191
  const { userText, turnId, event, trigger } = await req.json();
173
192
  await ensureScope();
193
+ const replace = url.searchParams.get("replace") === "1";
174
194
  const wantsDeliver = url.searchParams.get("deliver") === "1";
175
195
  let deliverChannel;
176
196
  if (wantsDeliver) {
@@ -189,7 +209,8 @@ var AgentDurableObject = class {
189
209
  userText,
190
210
  turnId,
191
211
  event,
192
- trigger
212
+ trigger,
213
+ replace
193
214
  }));
194
215
  } catch (err) {
195
216
  return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
@@ -197,7 +218,8 @@ var AgentDurableObject = class {
197
218
  if (wantsDeliver) {
198
219
  const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
199
220
  try {
200
- const events = observeTurnEvents(session, started.turnId);
221
+ const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
222
+ const events = { [Symbol.asyncIterator]: () => it };
201
223
  const target = {
202
224
  channelId: event.channelId,
203
225
  threadId: event.threadId,
@@ -207,7 +229,9 @@ var AgentDurableObject = class {
207
229
  Promise.resolve().then(() => runInScope({
208
230
  resources: this.resources,
209
231
  services: this.services
210
- }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure);
232
+ }, () => deliverChannel.deliver(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
233
+ it.return?.();
234
+ });
211
235
  } catch (err) {
212
236
  logRenderFailure(err);
213
237
  }
@@ -217,8 +241,15 @@ var AgentDurableObject = class {
217
241
  return new Response(sseTurnStream(session, started.turnId), { headers: SSE_HEADERS });
218
242
  }
219
243
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
220
- const { turnId, inputId, input, by } = await req.json();
244
+ const { turnId, inputId, input, by, source, target } = await req.json();
221
245
  await ensureScope();
246
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
247
+ let resumeChannel;
248
+ if (wantsDeliver) {
249
+ if (!source || !target?.channelId || !target.messageTs) return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
250
+ resumeChannel = this.channels.find((c) => c.name === source);
251
+ if (!resumeChannel?.deliverResume) return Response.json({ error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` }, { status: 501 });
252
+ }
222
253
  let session;
223
254
  try {
224
255
  session = this.resolveSession(key);
@@ -230,8 +261,37 @@ var AgentDurableObject = class {
230
261
  const message = err instanceof Error ? err.message : String(err);
231
262
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
232
263
  }
264
+ if (wantsDeliver) {
265
+ const logRenderFailure = (err) => console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
266
+ try {
267
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
268
+ const events = { [Symbol.asyncIterator]: () => it };
269
+ Promise.resolve().then(() => runInScope({
270
+ resources: this.resources,
271
+ services: this.services
272
+ }, () => resumeChannel.deliverResume(target, events, { session: this.sessionKey }))).catch(logRenderFailure).finally(() => {
273
+ it.return?.();
274
+ });
275
+ } catch (err) {
276
+ logRenderFailure(err);
277
+ }
278
+ return Response.json({ turnId }, { status: 202 });
279
+ }
233
280
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
234
281
  }
282
+ if (req.method === "POST" && url.pathname.endsWith("/reset")) {
283
+ let session;
284
+ try {
285
+ session = this.resolveSession(key);
286
+ } catch (err) {
287
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
288
+ }
289
+ try {
290
+ return Response.json(await session.reset());
291
+ } catch (err) {
292
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
293
+ }
294
+ }
235
295
  if (url.pathname.endsWith("/transcript")) {
236
296
  if (key !== void 0) try {
237
297
  return Response.json({ transcript: this.resolveSession(key).transcript() });
@@ -240,7 +300,7 @@ var AgentDurableObject = class {
240
300
  }
241
301
  return Response.json({ transcript: this.transcript() });
242
302
  }
243
- return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
303
+ return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
244
304
  }
245
305
  };
246
306
  const SSE_HEADERS = {
@@ -266,7 +326,7 @@ function sseTurnStream(session, turnId) {
266
326
  }, 2e4);
267
327
  unsub = session.observe((e) => {
268
328
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
269
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") {
329
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") {
270
330
  stop();
271
331
  controller.close();
272
332
  }
@@ -283,7 +343,7 @@ function observeTurnEvents(session, turnId) {
283
343
  let notify;
284
344
  const unsub = session.observe((e) => {
285
345
  queue.push(e);
286
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
346
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
287
347
  notify?.();
288
348
  }, { turnId });
289
349
  return { [Symbol.asyncIterator]() {
@@ -340,6 +400,7 @@ async function sseTurnFinalText(res) {
340
400
  const e = JSON.parse(line.slice(5).trim());
341
401
  if (e.type === "turn.completed") return e.text;
342
402
  if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error });
403
+ if (e.type === "turn.cancelled") throw new Error(`turn ${e.turnId} was cancelled (${e.reason})`);
343
404
  }
344
405
  if (done) break;
345
406
  }
@@ -425,7 +486,7 @@ function durableChannelSurface(getNamespace, opts) {
425
486
  run: async (message, o) => {
426
487
  const namespace = getNamespace();
427
488
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
428
- return sseTurnFinalText(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
489
+ return sseTurnFinalText(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, {
429
490
  method: "POST",
430
491
  body: serializeTurn(message, o)
431
492
  })));
@@ -433,7 +494,7 @@ function durableChannelSurface(getNamespace, opts) {
433
494
  runDetached: async (message, o) => {
434
495
  const namespace = getNamespace();
435
496
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
436
- const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?detach=1", {
497
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn?detach=1${o?.replace ? "&replace=1" : ""}`, {
437
498
  method: "POST",
438
499
  body: serializeTurn(message, o)
439
500
  }));
@@ -446,7 +507,7 @@ function durableChannelSurface(getNamespace, opts) {
446
507
  runDelivered: async (message, o) => {
447
508
  const namespace = getNamespace();
448
509
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
449
- const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn?deliver=1", {
510
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn?deliver=1${o?.replace ? "&replace=1" : ""}`, {
450
511
  method: "POST",
451
512
  body: serializeTurn(message, o)
452
513
  }));
@@ -460,7 +521,7 @@ function durableChannelSurface(getNamespace, opts) {
460
521
  runStream: async function* (message, o) {
461
522
  const namespace = getNamespace();
462
523
  if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
463
- yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/turn", {
524
+ yield* sseTurnEvents(await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, {
464
525
  method: "POST",
465
526
  body: serializeTurn(message, o)
466
527
  })));
@@ -472,6 +533,30 @@ function durableChannelSurface(getNamespace, opts) {
472
533
  method: "POST",
473
534
  body: serializeResume(o)
474
535
  })));
536
+ },
537
+ resumeDelivered: async (o) => {
538
+ const namespace = getNamespace();
539
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
540
+ const res = await durableFetch(namespace, opts.agentName, o.session ?? "default", new Request("https://do/resume?deliver=1", {
541
+ method: "POST",
542
+ body: serializeResume(o)
543
+ }));
544
+ if (res.status === 501) throw new DeliverUnsupportedError(`resumeDelivered: ${(await res.json().catch(() => void 0))?.error ?? "the turn host cannot deliver this continuation"}`);
545
+ if (res.status !== 202) {
546
+ const detail = (await res.text()).slice(0, 200);
547
+ throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
548
+ }
549
+ return await res.json();
550
+ },
551
+ resetSession: async (o) => {
552
+ const namespace = getNamespace();
553
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
554
+ const res = await durableFetch(namespace, opts.agentName, o?.session ?? "default", new Request("https://do/reset", { method: "POST" }));
555
+ if (!res.ok) {
556
+ const detail = (await res.text()).slice(0, 200);
557
+ throw new Error(`resetSession: reset was not accepted (status ${res.status}): ${detail}`);
558
+ }
559
+ return await res.json();
475
560
  }
476
561
  });
477
562
  }
@@ -481,10 +566,12 @@ function serializeResume(o) {
481
566
  turnId: o.turnId,
482
567
  inputId: o.inputId,
483
568
  input: o.input,
484
- by: o.by
569
+ by: o.by,
570
+ source: o.source,
571
+ target: o.target
485
572
  });
486
573
  } catch (err) {
487
- throw new Error(`resumeStream: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
574
+ throw new Error(`resume: input is not JSON-serializable (${err.message}) — a resume answer must round-trip to the DO`);
488
575
  }
489
576
  }
490
577
  function serializeTurn(userText, o) {
@@ -51,6 +51,21 @@ var SqliteSessionStore = class {
51
51
  throw e;
52
52
  }
53
53
  }
54
+ reset() {
55
+ return this.tx(() => {
56
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
57
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
58
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
59
+ const generation = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid)?.generation ?? 0;
60
+ this.db.query("INSERT INTO agent_messages_archive (session_id, generation, seq, body) SELECT session_id, ?, seq, body FROM agent_messages WHERE session_id = ?").run(generation, this.sid);
61
+ this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
62
+ this.db.query("INSERT INTO agent_steps_archive (session_id, generation, id, output) SELECT session_id, ?, id, output FROM agent_steps WHERE session_id = ?").run(generation, this.sid);
63
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
64
+ this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
65
+ this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
66
+ return generation;
67
+ });
68
+ }
54
69
  unwrap() {
55
70
  return this.db;
56
71
  }
@@ -99,6 +114,8 @@ var MemorySessionStore = class {
99
114
  msgs = [];
100
115
  steps = /* @__PURE__ */ new Map();
101
116
  status = "new";
117
+ generation = 0;
118
+ archives = [];
102
119
  appendMessage(m) {
103
120
  this.msgs.push(m);
104
121
  }
@@ -126,6 +143,18 @@ var MemorySessionStore = class {
126
143
  tx(fn) {
127
144
  return fn();
128
145
  }
146
+ reset() {
147
+ const generation = this.generation++;
148
+ this.archives.push({
149
+ generation,
150
+ msgs: this.msgs,
151
+ steps: this.steps
152
+ });
153
+ this.msgs = [];
154
+ this.steps = /* @__PURE__ */ new Map();
155
+ this.status = "new";
156
+ return generation;
157
+ }
129
158
  unwrap() {}
130
159
  };
131
160
  var MemoryRuntime = class {
@@ -166,14 +195,17 @@ function mountAgent(agent, runtime, opts = {}) {
166
195
  turnId: o?.turnId,
167
196
  userText: message,
168
197
  event: o?.event,
169
- trigger: o?.trigger
198
+ trigger: o?.trigger,
199
+ replace: o?.replace
170
200
  }),
171
201
  runDetached: async (message, o) => runtime.session(agent.name, o?.session ?? "default").start({
172
202
  turnId: o?.turnId,
173
203
  userText: message,
174
204
  event: o?.event,
175
- trigger: o?.trigger
176
- })
205
+ trigger: o?.trigger,
206
+ replace: o?.replace
207
+ }),
208
+ resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset()
177
209
  };
178
210
  const channels = channelFetch(agent, ctx);
179
211
  const surface = async (req) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "1.0.0-dev.12",
3
+ "version": "1.0.0-dev.14",
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",
@@ -132,7 +132,7 @@
132
132
  "rolldown": "^1.1.0"
133
133
  },
134
134
  "peerDependencies": {
135
- "@junejs/core": ">=0.2.0-dev.27",
135
+ "@junejs/core": ">=0.2.0-dev.32",
136
136
  "react": "^19.2.0",
137
137
  "react-dom": "^19.2.0",
138
138
  "react-server-dom-webpack": "19.2.7",
@@ -40,6 +40,7 @@ import {
40
40
  type Channel,
41
41
  type ChannelContext,
42
42
  type ChannelFactory,
43
+ type ResumeDeliveryTarget,
43
44
  } from "@junejs/core/agent-config";
44
45
  import { ensureScope, runInScope } from "@junejs/db";
45
46
  import { isolateLocal } from "./isolate-local";
@@ -136,6 +137,28 @@ export class DoSessionStore implements SessionStore {
136
137
  tx<T>(fn: () => T): T {
137
138
  return this.storage.transactionSync(fn);
138
139
  }
140
+ // Session reset (#129): ARCHIVE messages/steps under the current generation (the audit
141
+ // trail — never deleted), clear the live tables, status → "new". The session key in
142
+ // agent_meta is deliberately untouched: the DO's identity survives its history. Archive
143
+ // tables + the generation counter are created lazily, so existing objects stay
144
+ // untouched until their first reset. seq keeps counting across generations (DELETE
145
+ // doesn't reset AUTOINCREMENT), so an archived (generation, seq) stays unique.
146
+ reset(): number {
147
+ return this.storage.transactionSync(() => {
148
+ const sql = this.sql;
149
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (generation INTEGER, seq INTEGER, body TEXT)`);
150
+ sql.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (generation INTEGER, id TEXT, output TEXT)`);
151
+ const rows = this.sql.exec<{ v: string }>("SELECT v FROM agent_meta WHERE k = 'generation'").toArray();
152
+ const generation = rows.length ? Number(rows[0]!.v) : 0;
153
+ sql.exec("INSERT INTO agent_messages_archive (generation, seq, body) SELECT ?, seq, body FROM agent_messages", generation);
154
+ sql.exec("DELETE FROM agent_messages");
155
+ sql.exec("INSERT INTO agent_steps_archive (generation, id, output) SELECT ?, id, output FROM agent_steps", generation);
156
+ sql.exec("DELETE FROM agent_steps");
157
+ this.setStatus("new");
158
+ sql.exec("INSERT INTO agent_meta (k, v) VALUES ('generation', ?) ON CONFLICT(k) DO UPDATE SET v = ?", String(generation + 1), String(generation + 1));
159
+ return generation;
160
+ });
161
+ }
139
162
  unwrap<H = unknown>(): H {
140
163
  return this.sql as unknown as H;
141
164
  }
@@ -357,6 +380,11 @@ export class AgentDurableObject {
357
380
  const session = this.resolveSession(input.session);
358
381
  return runInScope({ resources: this.resources, services: this.services }, () => session.start(input));
359
382
  }
383
+ // Session reset (#129) for custom shells — the direct sibling of the /reset route. No
384
+ // scope needed: reset touches only the store (no tools run).
385
+ reset(opts?: { session?: string }): Promise<{ previousSession: string; generation: number }> {
386
+ return this.resolveSession(opts?.session).reset();
387
+ }
360
388
  // Read-only: folds the durable log. When an identity exists (live, or persisted from a
361
389
  // prior life) the session resolves and caches like any other path. Only a read on a
362
390
  // NEVER-keyed object stays non-committal — caching there would bake "self" in as the
@@ -377,6 +405,11 @@ export class AgentDurableObject {
377
405
  if (req.method === "POST" && url.pathname.endsWith("/turn")) {
378
406
  const { userText, turnId, event, trigger } = (await req.json()) as { userText: string; turnId?: string; event?: InboundEvent; trigger?: ProactiveTrigger };
379
407
  await ensureScope();
408
+ // CANCEL-AND-REPLACE (#129): supersede every unfinished turn on this session before
409
+ // queuing this one (debounce). A mode flag like deliver/detach, so it rides the query
410
+ // string and composes with both. A suspended session still 409s below — replace
411
+ // never cancels a parked approval.
412
+ const replace = url.searchParams.get("replace") === "1";
380
413
  // DELIVERED: like detach=1, the caller gets a 202 and goes away — but the reply is NOT
381
414
  // dropped: this DO renders the turn's event stream through the source channel's own
382
415
  // deliver() (the inbound renderer), under the DO's lifetime. Capability is checked
@@ -404,7 +437,7 @@ export class AgentDurableObject {
404
437
  let started: { turnId: string };
405
438
  try {
406
439
  session = this.resolveSession(key);
407
- started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger }));
440
+ started = runInScope({ resources: this.resources, services: this.services }, () => session.start({ userText, turnId, event, trigger, replace }));
408
441
  } catch (err) {
409
442
  // e.g. the session is suspended awaiting input, or the key mis-matches this
410
443
  // object's identity — a client-resolvable conflict, not a crash
@@ -429,11 +462,17 @@ export class AgentDurableObject {
429
462
  const logRenderFailure = (err: unknown) =>
430
463
  console.error(`[june] agent "${this.name}": delivered render for turn ${started.turnId} failed:`, err);
431
464
  try {
432
- const events = observeTurnEvents(session, started.turnId);
465
+ // Own the iterator here: observeTurnEvents subscribes EAGERLY, and a deliver()
466
+ // that throws before ever iterating would otherwise leave that subscription
467
+ // attached, buffering this turn's events forever. return() is idempotent, so
468
+ // closing in finally is safe whether deliver consumed the stream or died early.
469
+ const it = observeTurnEvents(session, started.turnId)[Symbol.asyncIterator]();
470
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
433
471
  const target = { channelId: event!.channelId, threadId: event!.threadId, recipientUserId: event!.user?.id, recipientTeamId: event!.teamId };
434
472
  Promise.resolve()
435
473
  .then(() => runInScope({ resources: this.resources, services: this.services }, () => deliverChannel!.deliver!(target, events, { session: this.sessionKey })))
436
- .catch(logRenderFailure);
474
+ .catch(logRenderFailure)
475
+ .finally(() => { void it.return?.(); });
437
476
  } catch (err) {
438
477
  logRenderFailure(err);
439
478
  }
@@ -451,8 +490,31 @@ export class AgentDurableObject {
451
490
  // VERIFIED identity, so the app must authenticate it upstream (e.g. take the user id from a
452
491
  // signature-checked Slack interaction payload), never expose this endpoint raw to clients.
453
492
  if (req.method === "POST" && url.pathname.endsWith("/resume")) {
454
- const { turnId, inputId, input, by } = (await req.json()) as { turnId: string; inputId: string; input: unknown; by?: string };
493
+ const { turnId, inputId, input, by, source, target } = (await req.json()) as {
494
+ turnId: string; inputId: string; input: unknown; by?: string;
495
+ source?: string; target?: ResumeDeliveryTarget;
496
+ };
455
497
  await ensureScope();
498
+ // DELIVERED resume: like /turn?deliver=1, the caller 202s away and THIS DO renders the
499
+ // continuation through the source channel's deliverResume() — a long continuation no
500
+ // longer dies with the webhook isolate. Capability is checked BEFORE resume() applies
501
+ // the answer — the DeliverUnsupportedError contract at the surface: on a pre-effect
502
+ // refusal the caller may fall back to consumer-side rendering without double-answering
503
+ // (a second resume of an already-applied input would 409).
504
+ const wantsDeliver = url.searchParams.get("deliver") === "1";
505
+ let resumeChannel: Channel | undefined;
506
+ if (wantsDeliver) {
507
+ if (!source || !target?.channelId || !target.messageTs) {
508
+ return Response.json({ error: "resume deliver=1 needs `source` (the channel name) and `target` (channelId + messageTs of the prompt message)" }, { status: 400 });
509
+ }
510
+ resumeChannel = this.channels.find((c) => c.name === source);
511
+ if (!resumeChannel?.deliverResume) {
512
+ return Response.json(
513
+ { error: `agent "${this.name}": no deliverResume()-capable channel named "${source}" is wired into this DO (DoAgentDef.channels) — the answer was NOT applied` },
514
+ { status: 501 },
515
+ );
516
+ }
517
+ }
456
518
  let session: AgentSession;
457
519
  try {
458
520
  session = this.resolveSession(key);
@@ -462,8 +524,49 @@ export class AgentDurableObject {
462
524
  // 403: the resumer may not answer; 409: not suspended / wrong turn / wrong input id / key mismatch
463
525
  return Response.json({ error: message }, { status: err instanceof ResumeAuthorizationError ? 403 : 409 });
464
526
  }
527
+ if (wantsDeliver) {
528
+ // Same post-acceptance discipline as delivered turns: subscribe synchronously, render
529
+ // under this DO's lifetime, and let NOTHING escape past the accepted resume — the log
530
+ // is the render failure's only surfacing path (the TURN's own failures surface via
531
+ // the #76 sink subscription).
532
+ const logRenderFailure = (err: unknown) =>
533
+ console.error(`[june] agent "${this.name}": delivered resume render for turn ${turnId} failed:`, err);
534
+ try {
535
+ // Own the iterator (same rationale as the delivered-turn path): the eager
536
+ // subscription must be released even when deliverResume throws before
537
+ // iterating — e.g. its very first message update fails.
538
+ const it = observeTurnEvents(session, turnId)[Symbol.asyncIterator]();
539
+ const events: AsyncIterable<TurnEvent> = { [Symbol.asyncIterator]: () => it };
540
+ Promise.resolve()
541
+ .then(() => runInScope({ resources: this.resources, services: this.services }, () => resumeChannel!.deliverResume!(target!, events, { session: this.sessionKey })))
542
+ .catch(logRenderFailure)
543
+ .finally(() => { void it.return?.(); });
544
+ } catch (err) {
545
+ logRenderFailure(err);
546
+ }
547
+ return Response.json({ turnId }, { status: 202 });
548
+ }
465
549
  return new Response(sseTurnStream(session, turnId), { headers: SSE_HEADERS });
466
550
  }
551
+ // SESSION RESET (#129): terminally retire this session's history — supersede unfinished
552
+ // turns, archive messages/steps under the current generation (the audit trail), and
553
+ // answer with the audit handle. The response waits for in-flight turns to unwind at
554
+ // their next checkpoint boundary (the reset op runs on the turn chain), which is fine:
555
+ // a DO stays alive while it has pending work.
556
+ if (req.method === "POST" && url.pathname.endsWith("/reset")) {
557
+ let session: AgentSession;
558
+ try {
559
+ session = this.resolveSession(key);
560
+ } catch (err) {
561
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
562
+ }
563
+ try {
564
+ return Response.json(await session.reset());
565
+ } catch (err) {
566
+ // e.g. a store without reset() — a caller-resolvable refusal, not a crash
567
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 409 });
568
+ }
569
+ }
467
570
  if (url.pathname.endsWith("/transcript")) {
468
571
  // A keyed read still conflict-checks (and may commit the key — it is authentic,
469
572
  // idFromName routed it here); a key-less read stays non-committal via transcript().
@@ -476,7 +579,7 @@ export class AgentDurableObject {
476
579
  }
477
580
  return Response.json({ transcript: this.transcript() });
478
581
  }
479
- return new Response("agent DO — POST /turn, POST /resume, or GET /transcript", { status: 404 });
582
+ return new Response("agent DO — POST /turn, POST /resume, POST /reset, or GET /transcript", { status: 404 });
480
583
  }
481
584
  }
482
585
 
@@ -504,9 +607,9 @@ function sseTurnStream(session: AgentSession, turnId: string): ReadableStream<Ui
504
607
  }, 20_000);
505
608
  unsub = session.observe((e) => {
506
609
  controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
507
- // terminal for the STREAM: completed, failed, OR suspended (input.requested → the turn
508
- // parked; the stream ends and a later /resume opens a fresh continuation stream).
509
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") { stop(); controller.close(); }
610
+ // terminal for the STREAM: completed, failed, cancelled, OR suspended (input.requested
611
+ // → the turn parked; the stream ends and a later /resume opens a fresh continuation stream).
612
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") { stop(); controller.close(); }
510
613
  }, { turnId });
511
614
  },
512
615
  cancel() { stop(); },
@@ -526,7 +629,7 @@ function observeTurnEvents(session: AgentSession, turnId: string): AsyncIterable
526
629
  let notify: (() => void) | undefined;
527
630
  const unsub = session.observe((e) => {
528
631
  queue.push(e);
529
- if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "input.requested") terminal = true;
632
+ if (e.type === "turn.completed" || e.type === "turn.failed" || e.type === "turn.cancelled" || e.type === "input.requested") terminal = true;
530
633
  notify?.();
531
634
  }, { turnId });
532
635
  return {
@@ -575,6 +678,7 @@ export async function sseTurnFinalText(res: Response): Promise<string> {
575
678
  const e = JSON.parse(line.slice(5).trim()) as TurnEvent;
576
679
  if (e.type === "turn.completed") return e.text;
577
680
  if (e.type === "turn.failed") throw new Error(e.error.message, { cause: e.error }); // full TurnError rides along
681
+ if (e.type === "turn.cancelled") throw new Error(`turn ${e.turnId} was cancelled (${e.reason})`);
578
682
  }
579
683
  if (done) break;
580
684
  }
@@ -775,7 +879,7 @@ export function durableChannelSurface(
775
879
  namespace,
776
880
  opts.agentName,
777
881
  o?.session ?? "default",
778
- new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
882
+ new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
779
883
  );
780
884
  // /turn streams SSE; the simple path collapses it to the final text.
781
885
  return sseTurnFinalText(res);
@@ -791,7 +895,7 @@ export function durableChannelSurface(
791
895
  namespace,
792
896
  opts.agentName,
793
897
  o?.session ?? "default",
794
- new Request("https://do/turn?detach=1", { method: "POST", body: serializeTurn(message, o) }),
898
+ new Request(`https://do/turn?detach=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
795
899
  );
796
900
  if (res.status !== 202) {
797
901
  const detail = (await res.text()).slice(0, 200);
@@ -812,7 +916,7 @@ export function durableChannelSurface(
812
916
  namespace,
813
917
  opts.agentName,
814
918
  o?.session ?? "default",
815
- new Request("https://do/turn?deliver=1", { method: "POST", body: serializeTurn(message, o) }),
919
+ new Request(`https://do/turn?deliver=1${o?.replace ? "&replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
816
920
  );
817
921
  if (res.status === 501) {
818
922
  throw new DeliverUnsupportedError(`runDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this turn"}`);
@@ -831,7 +935,7 @@ export function durableChannelSurface(
831
935
  namespace,
832
936
  opts.agentName,
833
937
  o?.session ?? "default",
834
- new Request("https://do/turn", { method: "POST", body: serializeTurn(message, o) }),
938
+ new Request(`https://do/turn${o?.replace ? "?replace=1" : ""}`, { method: "POST", body: serializeTurn(message, o) }),
835
939
  );
836
940
  yield* sseTurnEvents(res);
837
941
  },
@@ -847,6 +951,49 @@ export function durableChannelSurface(
847
951
  );
848
952
  yield* sseTurnEvents(res);
849
953
  },
954
+ // DELIVERED resume (runDelivered's sibling for the HITL leg): the DO applies the answer
955
+ // AND renders the continuation through the source channel's deliverResume() under its
956
+ // OWN lifetime. A 501 means the DO refused BEFORE applying the answer (channel not
957
+ // wired there / no deliverResume) — surfaced as DeliverUnsupportedError, the one
958
+ // rejection a channel may answer with a consumer-side fallback without double-answering.
959
+ // Engine rejections (403 unauthorized clicker / 409 stale-or-double click) throw as
960
+ // ordinary errors, mirroring what resumeStream's first pull would do.
961
+ resumeDelivered: async (o) => {
962
+ const namespace = getNamespace();
963
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
964
+ const res = await durableFetch(
965
+ namespace,
966
+ opts.agentName,
967
+ o.session ?? "default",
968
+ new Request("https://do/resume?deliver=1", { method: "POST", body: serializeResume(o) }),
969
+ );
970
+ if (res.status === 501) {
971
+ throw new DeliverUnsupportedError(`resumeDelivered: ${((await res.json().catch(() => undefined)) as { error?: string } | undefined)?.error ?? "the turn host cannot deliver this continuation"}`);
972
+ }
973
+ if (res.status !== 202) {
974
+ const detail = (await res.text()).slice(0, 200);
975
+ throw new Error(`resumeDelivered: resume was not accepted (status ${res.status}): ${detail}`);
976
+ }
977
+ return (await res.json()) as { turnId: string };
978
+ },
979
+ // SESSION RESET (#129): retire the session's history on its DO. The response carries
980
+ // the audit handle ({ previousSession, generation }); a 409 (store without reset(),
981
+ // key mismatch) surfaces as an ordinary error — there is no fallback to invent.
982
+ resetSession: async (o) => {
983
+ const namespace = getNamespace();
984
+ if (!namespace) throw new Error("durableChannelSurface: no Durable Object namespace bound (env.AGENT)");
985
+ const res = await durableFetch(
986
+ namespace,
987
+ opts.agentName,
988
+ o?.session ?? "default",
989
+ new Request("https://do/reset", { method: "POST" }),
990
+ );
991
+ if (!res.ok) {
992
+ const detail = (await res.text()).slice(0, 200);
993
+ throw new Error(`resetSession: reset was not accepted (status ${res.status}): ${detail}`);
994
+ }
995
+ return (await res.json()) as { previousSession: string; generation: number };
996
+ },
850
997
  };
851
998
  return channelDispatch(resolved, ctx);
852
999
  }
@@ -861,11 +1008,12 @@ export function durableChannelSurface(
861
1008
  // (third-party) host could hand us something JSON.stringify chokes on (BigInt, circular).
862
1009
  // Unlike serializeTurn's `raw`, `input` is essential: silently dropping it would resume the
863
1010
  // turn with the wrong answer. So fail loudly with a clear message rather than corrupt the resume.
864
- function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string }): string {
1011
+ function serializeResume(o: { turnId: string; inputId: string; input: unknown; by?: string; source?: string; target?: ResumeDeliveryTarget }): string {
865
1012
  try {
866
- return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by });
1013
+ return JSON.stringify({ turnId: o.turnId, inputId: o.inputId, input: o.input, by: o.by, source: o.source, target: o.target });
867
1014
  } catch (err) {
868
- throw new Error(`resumeStream: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
1015
+ // API-neutral prefix: this serializer backs both resumeStream and resumeDelivered.
1016
+ throw new Error(`resume: input is not JSON-serializable (${(err as Error).message}) — a resume answer must round-trip to the DO`);
869
1017
  }
870
1018
  }
871
1019
 
@@ -79,6 +79,26 @@ class SqliteSessionStore implements SessionStore {
79
79
  throw e;
80
80
  }
81
81
  }
82
+ // Session reset (#129): ARCHIVE this session's messages/steps under the current
83
+ // generation (audit trail — never deleted), clear the live rows, status → "new".
84
+ // Archive tables + the generation counter are created lazily here, so existing
85
+ // databases stay untouched until the first reset.
86
+ reset(): number {
87
+ return this.tx(() => {
88
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_messages_archive (session_id TEXT, generation INTEGER, seq INTEGER, body TEXT)`);
89
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_steps_archive (session_id TEXT, generation INTEGER, id TEXT, output TEXT)`);
90
+ this.db.exec(`CREATE TABLE IF NOT EXISTS agent_session_generations (session_id TEXT PRIMARY KEY, generation INTEGER)`);
91
+ const r = this.db.query("SELECT generation FROM agent_session_generations WHERE session_id = ?").get(this.sid) as { generation: number } | undefined;
92
+ const generation = r?.generation ?? 0;
93
+ this.db.query("INSERT INTO agent_messages_archive (session_id, generation, seq, body) SELECT session_id, ?, seq, body FROM agent_messages WHERE session_id = ?").run(generation, this.sid);
94
+ this.db.query("DELETE FROM agent_messages WHERE session_id = ?").run(this.sid);
95
+ this.db.query("INSERT INTO agent_steps_archive (session_id, generation, id, output) SELECT session_id, ?, id, output FROM agent_steps WHERE session_id = ?").run(generation, this.sid);
96
+ this.db.query("DELETE FROM agent_steps WHERE session_id = ?").run(this.sid);
97
+ this.db.query("INSERT INTO agent_sessions (session_id, status) VALUES (?, 'new') ON CONFLICT(session_id) DO UPDATE SET status = 'new'").run(this.sid);
98
+ this.db.query("INSERT INTO agent_session_generations (session_id, generation) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET generation = ?").run(this.sid, generation + 1, generation + 1);
99
+ return generation;
100
+ });
101
+ }
82
102
  unwrap<H = unknown>(): H { return this.db as unknown as H; }
83
103
  }
84
104
 
@@ -139,6 +159,10 @@ class MemorySessionStore implements SessionStore {
139
159
  private msgs: Msg[] = [];
140
160
  private steps = new Map<string, unknown>();
141
161
  private status = "new";
162
+ private generation = 0;
163
+ // Retired generations (#129) — kept for the audit contract even in memory, so the
164
+ // backend behaves like the durable tiers within one process lifetime.
165
+ private readonly archives: { generation: number; msgs: Msg[]; steps: Map<string, unknown> }[] = [];
142
166
  appendMessage(m: Msg) { this.msgs.push(m); }
143
167
  messages(): Msg[] { return this.msgs.slice(); }
144
168
  hasOpeningMessage(turnId: string): boolean { return this.msgs.some((m) => (m.role === "user" || m.role === "trigger") && m.turnId === turnId); }
@@ -148,6 +172,14 @@ class MemorySessionStore implements SessionStore {
148
172
  getStatus(): string { return this.status; }
149
173
  setStatus(s: string) { this.status = s; }
150
174
  tx<T>(fn: () => T): T { return fn(); } // no rollback: an in-memory store is not a durability tier
175
+ reset(): number {
176
+ const generation = this.generation++;
177
+ this.archives.push({ generation, msgs: this.msgs, steps: this.steps });
178
+ this.msgs = [];
179
+ this.steps = new Map();
180
+ this.status = "new";
181
+ return generation;
182
+ }
151
183
  unwrap<H = unknown>(): H { return undefined as unknown as H; }
152
184
  }
153
185
 
@@ -216,12 +248,16 @@ export function mountAgent(
216
248
  agent,
217
249
  services: opts.services, // same DI bag reachable from channel hooks (parity with durableChannelSurface)
218
250
  run: (message, o) =>
219
- runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
251
+ runtime.session(agent.name, o?.session ?? "default").turn({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
220
252
  // FIRE-AND-FORGET (#77): start() without awaiting the result. Native has no waitUntil
221
253
  // ceiling (Node keeps floating promises alive), but the seam is the same so a channel
222
254
  // written against ctx.runDetached behaves identically on both targets.
223
255
  runDetached: async (message, o) =>
224
- runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger }),
256
+ runtime.session(agent.name, o?.session ?? "default").start({ turnId: o?.turnId, userText: message, event: o?.event, trigger: o?.trigger, replace: o?.replace }),
257
+ // SESSION RESET (#129): same seam as durableChannelSurface — the in-process runtimes'
258
+ // stores implement archival, so a channel written against ctx.resetSession behaves
259
+ // identically on both targets.
260
+ resetSession: (o) => runtime.session(agent.name, o?.session ?? "default").reset(),
225
261
  };
226
262
  const channels = channelFetch(agent, ctx);
227
263
  const surface = async (req: Request): Promise<Response | null> => {