laohuang 0.3.2 → 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 (86) hide show
  1. package/README.md +142 -16
  2. package/dist/agent.d.ts +166 -0
  3. package/dist/agent.js +858 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/bash-runner.d.ts +79 -0
  6. package/dist/bash-runner.js +464 -0
  7. package/dist/bash-runner.js.map +1 -0
  8. package/dist/cancellation.d.ts +36 -0
  9. package/dist/cancellation.js +123 -0
  10. package/dist/cancellation.js.map +1 -0
  11. package/dist/cli.d.ts +117 -0
  12. package/dist/cli.js +1307 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/client.d.ts +21 -0
  15. package/dist/client.js +17 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/commands.d.ts +123 -0
  18. package/dist/commands.js +660 -0
  19. package/dist/commands.js.map +1 -0
  20. package/dist/config.d.ts +51 -0
  21. package/dist/config.js +183 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/credentials.d.ts +12 -0
  24. package/dist/credentials.js +102 -0
  25. package/dist/credentials.js.map +1 -0
  26. package/dist/events.d.ts +288 -0
  27. package/dist/events.js +838 -0
  28. package/dist/events.js.map +1 -0
  29. package/dist/model-adapter.d.ts +138 -0
  30. package/dist/model-adapter.js +244 -0
  31. package/dist/model-adapter.js.map +1 -0
  32. package/dist/model-selection.d.ts +67 -0
  33. package/dist/model-selection.js +148 -0
  34. package/dist/model-selection.js.map +1 -0
  35. package/dist/model-stream.d.ts +128 -0
  36. package/dist/model-stream.js +582 -0
  37. package/dist/model-stream.js.map +1 -0
  38. package/dist/project-instructions.d.ts +99 -0
  39. package/dist/project-instructions.js +348 -0
  40. package/dist/project-instructions.js.map +1 -0
  41. package/dist/providers.d.ts +9 -0
  42. package/dist/providers.js +26 -0
  43. package/dist/providers.js.map +1 -0
  44. package/dist/routing.d.ts +170 -0
  45. package/dist/routing.js +669 -0
  46. package/dist/routing.js.map +1 -0
  47. package/dist/semantic-classifier.d.ts +66 -0
  48. package/dist/semantic-classifier.js +86 -0
  49. package/dist/semantic-classifier.js.map +1 -0
  50. package/dist/session.d.ts +162 -0
  51. package/dist/session.js +871 -0
  52. package/dist/session.js.map +1 -0
  53. package/dist/system-prompt.d.ts +16 -0
  54. package/dist/system-prompt.js +42 -0
  55. package/dist/system-prompt.js.map +1 -0
  56. package/dist/terminal/editor.d.ts +161 -0
  57. package/dist/terminal/editor.js +1060 -0
  58. package/dist/terminal/editor.js.map +1 -0
  59. package/dist/terminal/input.d.ts +61 -0
  60. package/dist/terminal/input.js +276 -0
  61. package/dist/terminal/input.js.map +1 -0
  62. package/dist/terminal/markdown.d.ts +20 -0
  63. package/dist/terminal/markdown.js +621 -0
  64. package/dist/terminal/markdown.js.map +1 -0
  65. package/dist/terminal/screen.d.ts +66 -0
  66. package/dist/terminal/screen.js +624 -0
  67. package/dist/terminal/screen.js.map +1 -0
  68. package/dist/terminal/theme.d.ts +23 -0
  69. package/dist/terminal/theme.js +101 -0
  70. package/dist/terminal/theme.js.map +1 -0
  71. package/dist/terminal/ui.d.ts +285 -0
  72. package/dist/terminal/ui.js +1815 -0
  73. package/dist/terminal/ui.js.map +1 -0
  74. package/dist/tools.d.ts +95 -0
  75. package/dist/tools.js +444 -0
  76. package/dist/tools.js.map +1 -0
  77. package/dist/ui-state.d.ts +51 -0
  78. package/dist/ui-state.js +194 -0
  79. package/dist/ui-state.js.map +1 -0
  80. package/dist/web.d.ts +56 -0
  81. package/dist/web.js +247 -0
  82. package/dist/web.js.map +1 -0
  83. package/package.json +24 -21
  84. package/bin/laohuang.js +0 -10
  85. package/lib/launcher.js +0 -133
  86. package/vendor/laohuangcode-0.3.2-py3-none-any.whl +0 -0
@@ -0,0 +1,871 @@
1
+ /** Single-active-task runtime coordination for laoHuangCode. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { CancellationError } from "./cancellation.js";
4
+ import { EventBus, EventKind, EventSource, } from "./events.js";
5
+ import { DeadLetterQueue, EventRouter, HeldQueue, PendingQueue, QueueOverflowError, Scheduler, TaskRegistry, TaskState, combineInput, } from "./routing.js";
6
+ export const SessionState = {
7
+ Idle: "idle",
8
+ Running: "running",
9
+ Cancelling: "cancelling",
10
+ Stopped: "stopped",
11
+ };
12
+ const TERMINAL_TASK_STATES = new Set([
13
+ TaskState.Cancelled,
14
+ TaskState.Completed,
15
+ TaskState.Failed,
16
+ ]);
17
+ const ROUTE_STRATEGIES = new Set([
18
+ "execute",
19
+ "steer",
20
+ "follow_up",
21
+ "cancel",
22
+ "reject",
23
+ ]);
24
+ function makeBatch(events) {
25
+ return {
26
+ events,
27
+ content: combineInput(events),
28
+ eventIds: events.map((item) => item.event.event_id),
29
+ };
30
+ }
31
+ const EMPTY_BATCH = {
32
+ events: [],
33
+ content: "",
34
+ eventIds: [],
35
+ };
36
+ const assertCodingAgentIsRunner = true;
37
+ void assertCodingAgentIsRunner;
38
+ function sameEventIds(inflight, eventIds) {
39
+ if (inflight.length !== eventIds.length) {
40
+ return false;
41
+ }
42
+ return inflight.every((item, index) => item.event.event_id === eventIds[index]);
43
+ }
44
+ /** Capabilities exposed to one model/tool worker invocation. */
45
+ export class TaskContext {
46
+ sessionId;
47
+ taskId;
48
+ #session;
49
+ #claimedInputEventIds = [];
50
+ constructor(session, taskId) {
51
+ this.#session = session;
52
+ this.sessionId = session.sessionId;
53
+ this.taskId = taskId;
54
+ }
55
+ get cancelToken() {
56
+ const record = this.#session.taskRegistry.get(this.taskId);
57
+ if (record === null) {
58
+ throw new Error(`task no longer exists: ${this.taskId}`);
59
+ }
60
+ return record.cancelToken;
61
+ }
62
+ get eventBus() {
63
+ return this.#session.eventBus;
64
+ }
65
+ isActive() {
66
+ return this.#session.isTaskActive(this.taskId);
67
+ }
68
+ /**
69
+ * Normalize and route a model/tool callback before fan-out. Await it to
70
+ * preserve canonical ordering: the routing decision is published before the
71
+ * event itself. Option names follow the events.ts EventBus.publish
72
+ * convention (snake_case), matching agent.ts's AgentContext.publish.
73
+ */
74
+ publish(kind, options) {
75
+ return this.#session.publishInternalEvent(kind, {
76
+ source: options.source,
77
+ taskId: this.taskId,
78
+ correlationId: options.correlation_id ?? null,
79
+ payload: options.payload,
80
+ });
81
+ }
82
+ setState(state) {
83
+ return this.#session.setRunningState(this.taskId, state);
84
+ }
85
+ modelStarted() {
86
+ return this.setState(TaskState.RunningModel);
87
+ }
88
+ /** Acknowledge claimed input only once the SDK opened its request. */
89
+ modelRequestOpened() {
90
+ const acknowledged = this.#session.ackClaimedInput(this.taskId, this.#claimedInputEventIds);
91
+ if (acknowledged) {
92
+ this.#claimedInputEventIds = [];
93
+ }
94
+ return acknowledged;
95
+ }
96
+ toolsStarted() {
97
+ return this.setState(TaskState.RunningTools);
98
+ }
99
+ /** Atomically take one compatible pending batch for this task. */
100
+ safePoint() {
101
+ return this.#session.drainPending(this.taskId);
102
+ }
103
+ /** @internal Used by the session worker around each runner invocation. */
104
+ setClaimedInput(batch) {
105
+ this.#claimedInputEventIds = batch === null ? [] : batch.eventIds;
106
+ }
107
+ commitInput(callback, rollback = () => { }) {
108
+ return this.#session.commitClaimedInput(this.taskId, this.#claimedInputEventIds, callback, rollback);
109
+ }
110
+ commitPending(batch, callback, rollback = () => { }) {
111
+ const committed = this.#session.commitClaimedInput(this.taskId, batch.eventIds, callback, rollback);
112
+ if (committed) {
113
+ this.#claimedInputEventIds = batch.eventIds;
114
+ }
115
+ return committed;
116
+ }
117
+ /** Atomically reject history commits once cancellation has won. */
118
+ commitIfActive(callback) {
119
+ return this.#session.commitIfActive(this.taskId, callback);
120
+ }
121
+ }
122
+ /**
123
+ * Owns runtime coordination while the CodingAgent owns model/tool logic.
124
+ *
125
+ * The Python original coordinates worker threads with locks; here every
126
+ * critical section is synchronous, so atomicity comes from the event loop.
127
+ * The only yield points are routing (semantic classification may be a network
128
+ * call) and awaiting the runner, mirroring the spots where Python released
129
+ * its coordination lock.
130
+ */
131
+ export class AgentSession {
132
+ sessionId;
133
+ eventBus;
134
+ taskRegistry;
135
+ pending;
136
+ held;
137
+ deadLetters;
138
+ scheduler;
139
+ router;
140
+ runner;
141
+ #state = SessionState.Idle;
142
+ #worker = null;
143
+ #workerActive = false;
144
+ #closeRequested = false;
145
+ #inflightPending = new Map();
146
+ #inflightRollbacks = new Map();
147
+ #idle = true;
148
+ #idleWaiters = new Set();
149
+ #finalizePromise = null;
150
+ constructor(runner, options = {}) {
151
+ this.sessionId = options.sessionId ?? randomUUID().replaceAll("-", "");
152
+ this.eventBus = options.eventBus ?? new EventBus();
153
+ this.taskRegistry = options.taskRegistry ?? new TaskRegistry();
154
+ this.pending = new PendingQueue();
155
+ this.held = new HeldQueue();
156
+ this.deadLetters = new DeadLetterQueue();
157
+ this.scheduler = new Scheduler({
158
+ pending: this.pending,
159
+ held: this.held,
160
+ deadLetters: this.deadLetters,
161
+ });
162
+ this.router = new EventRouter(this.taskRegistry, {
163
+ semanticClassifier: options.semanticClassifier ?? null,
164
+ safetyPolicy: options.safetyPolicy ?? null,
165
+ });
166
+ this.runner = runner;
167
+ this.eventBus.publish(EventKind.SessionReady, {
168
+ source: EventSource.Session,
169
+ session_id: this.sessionId,
170
+ payload: {},
171
+ });
172
+ }
173
+ get state() {
174
+ return this.#state;
175
+ }
176
+ get activeTask() {
177
+ return this.taskRegistry.active();
178
+ }
179
+ get pendingCount() {
180
+ return this.pending.size;
181
+ }
182
+ get heldCount() {
183
+ return this.held.size;
184
+ }
185
+ async submitInput(content, options = {}) {
186
+ if (this.#state === SessionState.Stopped) {
187
+ throw new Error("session is stopped");
188
+ }
189
+ const text = content.trim();
190
+ if (!text) {
191
+ throw new Error("input must not be empty");
192
+ }
193
+ const strategy = options.strategy;
194
+ if (strategy !== undefined && !ROUTE_STRATEGIES.has(strategy)) {
195
+ throw new Error(`unknown route strategy: ${strategy}`);
196
+ }
197
+ if (text === "/cancel") {
198
+ const event = this.publishInput(EventKind.InputSlashCommand, text);
199
+ const routed = await this.router.route(event);
200
+ this.cancelRouted(routed);
201
+ return {
202
+ event,
203
+ routed,
204
+ taskId: routed.decision.taskId,
205
+ queued: false,
206
+ control: true,
207
+ rejected: false,
208
+ reason: "",
209
+ };
210
+ }
211
+ const kind = text.startsWith("/")
212
+ ? EventKind.InputSlashCommand
213
+ : EventKind.InputUserMessage;
214
+ const event = this.publishInput(kind, text, strategy);
215
+ const routingTaskId = this.taskRegistry.activeTaskId;
216
+ // Layer-three classification may make a network request. The event loop
217
+ // stays free while awaiting it, so the worker can finish, cancel, or
218
+ // reach a safe point; the decision is repaired afterwards if it did.
219
+ const routed = this.refreshRouteForCurrentTask(await this.router.route(event), routingTaskId);
220
+ // From here on the section is synchronous and therefore atomic.
221
+ this.publishRoute(routed);
222
+ const scheduled = this.scheduler.schedule(routed);
223
+ let taskId = routed.decision.taskId;
224
+ if (routed.decision.destination === "new_task") {
225
+ taskId = this.startTask(text, event.event_id);
226
+ }
227
+ else if (routed.decision.destination === "pending") {
228
+ this.eventBus.publish(EventKind.InputPending, {
229
+ source: EventSource.Session,
230
+ session_id: this.sessionId,
231
+ task_id: taskId,
232
+ correlation_id: event.event_id,
233
+ payload: { pending_count: this.pending.size },
234
+ });
235
+ }
236
+ else if (routed.decision.destination === "held") {
237
+ this.eventBus.publish(EventKind.InputHeld, {
238
+ source: EventSource.Session,
239
+ session_id: this.sessionId,
240
+ task_id: taskId,
241
+ correlation_id: event.event_id,
242
+ payload: { held_count: this.held.size },
243
+ });
244
+ }
245
+ if (scheduled.rejected) {
246
+ this.eventBus.publish(EventKind.RoutingRejected, {
247
+ source: EventSource.Router,
248
+ session_id: this.sessionId,
249
+ task_id: taskId,
250
+ correlation_id: event.event_id,
251
+ payload: {
252
+ reason: scheduled.reason,
253
+ destination: "drop",
254
+ layer: 4,
255
+ },
256
+ });
257
+ }
258
+ return {
259
+ event,
260
+ routed,
261
+ taskId,
262
+ queued: scheduled.queued,
263
+ control: routed.decision.destination === "control",
264
+ rejected: scheduled.rejected,
265
+ reason: scheduled.reason,
266
+ };
267
+ }
268
+ /** Repair a decision if task state changed during semantic routing. */
269
+ refreshRouteForCurrentTask(routed, routingTaskId) {
270
+ const decision = routed.decision;
271
+ const active = this.taskRegistry.active();
272
+ const destination = decision.destination;
273
+ if (routingTaskId !== null) {
274
+ const original = this.taskRegistry.get(routingTaskId);
275
+ if (active !== null && active.taskId === routingTaskId) {
276
+ if (active.state !== TaskState.Cancelling) {
277
+ return routed;
278
+ }
279
+ }
280
+ else if (active === null &&
281
+ original !== null &&
282
+ original.state === TaskState.Completed) {
283
+ return {
284
+ event: routed.event,
285
+ decision: {
286
+ taskId: null,
287
+ destination: "new_task",
288
+ timing: "immediate",
289
+ strategy: "execute",
290
+ confidence: 1.0,
291
+ reason: "original task completed while routing; start follow-up task",
292
+ layer: 4,
293
+ },
294
+ };
295
+ }
296
+ return {
297
+ event: routed.event,
298
+ decision: {
299
+ taskId: routingTaskId,
300
+ destination: "held",
301
+ timing: "after_cancel",
302
+ strategy: "follow_up",
303
+ confidence: 1.0,
304
+ reason: "original task stopped or changed while routing; hold input",
305
+ layer: 4,
306
+ },
307
+ };
308
+ }
309
+ const stale = (destination === "new_task" && active !== null) ||
310
+ ((destination === "pending" || destination === "held") &&
311
+ (active === null || decision.taskId !== active.taskId));
312
+ if (!stale) {
313
+ return routed;
314
+ }
315
+ let replacement;
316
+ if (active === null) {
317
+ replacement = {
318
+ taskId: null,
319
+ destination: "new_task",
320
+ timing: "immediate",
321
+ strategy: "execute",
322
+ confidence: 1.0,
323
+ reason: "task state changed while routing; start a new task",
324
+ layer: 4,
325
+ };
326
+ }
327
+ else if (active.state === TaskState.Cancelling) {
328
+ replacement = {
329
+ taskId: active.taskId,
330
+ destination: "held",
331
+ timing: "after_cancel",
332
+ strategy: "follow_up",
333
+ confidence: 1.0,
334
+ reason: "task began cancelling while routing; hold input",
335
+ layer: 4,
336
+ };
337
+ }
338
+ else {
339
+ replacement = {
340
+ taskId: active.taskId,
341
+ destination: "pending",
342
+ timing: "safe_point",
343
+ strategy: "follow_up",
344
+ confidence: 1.0,
345
+ reason: "active task changed while routing; queue safe follow-up",
346
+ layer: 4,
347
+ };
348
+ }
349
+ return { event: routed.event, decision: replacement };
350
+ }
351
+ requestCancel(reason = "cancelled by user") {
352
+ const event = this.eventBus.publish(EventKind.InputCancelRequested, {
353
+ source: EventSource.User,
354
+ session_id: this.sessionId,
355
+ payload: { reason },
356
+ });
357
+ // EventRouter.route is async, but for InputCancelRequested the outcome is
358
+ // pinned by the layer-four safety arbiter regardless of classifier or
359
+ // policy verdicts, so the decision can be produced synchronously. (The
360
+ // policy callback itself is not invoked on this fast path.)
361
+ const active = this.taskRegistry.active();
362
+ const routed = {
363
+ event,
364
+ decision: {
365
+ taskId: active?.taskId ?? null,
366
+ destination: "control",
367
+ timing: "immediate",
368
+ strategy: "cancel",
369
+ confidence: 1.0,
370
+ reason: "safety arbiter preserves immediate cancellation",
371
+ layer: 4,
372
+ },
373
+ };
374
+ return this.cancelRouted(routed, reason);
375
+ }
376
+ /** Command-facing alias for task-scoped cancellation. */
377
+ cancelActiveTask() {
378
+ return this.requestCancel("cancelled by user");
379
+ }
380
+ queueStatus() {
381
+ return {
382
+ pending: this.pending.size,
383
+ held: this.held.size,
384
+ deadLetters: this.deadLetters.size,
385
+ pendingTokens: this.pending.estimatedTokens,
386
+ heldTokens: this.held.estimatedTokens,
387
+ };
388
+ }
389
+ /** Publish user-facing local feedback in canonical event order. */
390
+ publishNotice(text, options = {}) {
391
+ return this.eventBus.publish(EventKind.UiMessage, {
392
+ source: EventSource.Session,
393
+ session_id: this.sessionId,
394
+ payload: { text: text, style: options.style ?? "" },
395
+ });
396
+ }
397
+ clearQueues() {
398
+ return (this.pending.clear() + this.held.clear() + this.deadLetters.clear());
399
+ }
400
+ /** Normalize and route a model/tool callback before fan-out. */
401
+ async publishInternalEvent(kind, options) {
402
+ const event = this.eventBus.factory.create(kind, {
403
+ source: options.source,
404
+ session_id: this.sessionId,
405
+ task_id: options.taskId,
406
+ correlation_id: options.correlationId,
407
+ payload: options.payload,
408
+ });
409
+ const routed = await this.router.route(event);
410
+ this.publishRoute(routed);
411
+ if (routed.decision.destination === "drop") {
412
+ this.deadLetters.put(event, routed.decision.reason);
413
+ return event;
414
+ }
415
+ return this.eventBus.publishEvent(event);
416
+ }
417
+ cancelRouted(routed, reason = "cancelled by user") {
418
+ this.publishRoute(routed);
419
+ const taskId = routed.decision.taskId;
420
+ if (taskId === null) {
421
+ return false;
422
+ }
423
+ const record = this.taskRegistry.get(taskId);
424
+ if (record === null || TERMINAL_TASK_STATES.has(record.state)) {
425
+ return false;
426
+ }
427
+ this.taskRegistry.transition(taskId, TaskState.Cancelling);
428
+ this.setSessionState(SessionState.Cancelling);
429
+ this.holdTaskInputs(taskId, "held because its task is cancelling");
430
+ this.publishTaskState(taskId, TaskState.Cancelling);
431
+ return record.cancelToken.cancel(reason);
432
+ }
433
+ waitForIdle(timeoutMs) {
434
+ if (this.#idle) {
435
+ return Promise.resolve(true);
436
+ }
437
+ if (timeoutMs !== undefined && timeoutMs <= 0) {
438
+ return Promise.resolve(false);
439
+ }
440
+ return new Promise((resolve) => {
441
+ let timer;
442
+ const onIdle = () => {
443
+ if (timer !== undefined) {
444
+ clearTimeout(timer);
445
+ }
446
+ this.#idleWaiters.delete(onIdle);
447
+ resolve(true);
448
+ };
449
+ this.#idleWaiters.add(onIdle);
450
+ if (timeoutMs !== undefined) {
451
+ timer = setTimeout(() => {
452
+ this.#idleWaiters.delete(onIdle);
453
+ resolve(false);
454
+ }, timeoutMs);
455
+ // A pending wait must never hold the process open on its own.
456
+ timer.unref();
457
+ }
458
+ });
459
+ }
460
+ isTaskActive(taskId) {
461
+ const active = this.taskRegistry.active();
462
+ return active !== null && active.taskId === taskId;
463
+ }
464
+ /** Start all held user inputs as one new task when currently idle. */
465
+ resumeHeld() {
466
+ if (this.taskRegistry.active() !== null) {
467
+ return 0;
468
+ }
469
+ const held = this.held.drain();
470
+ const content = combineInput(held);
471
+ if (!content) {
472
+ return 0;
473
+ }
474
+ this.startTask(content, null);
475
+ return held.length;
476
+ }
477
+ clearHeld() {
478
+ return this.held.drain().length;
479
+ }
480
+ async close(options = {}) {
481
+ const wait = options.wait ?? true;
482
+ if (this.#state === SessionState.Stopped) {
483
+ // A previous close may still be draining subscriber mailboxes; a
484
+ // returned close() must imply subscribers saw the terminal event.
485
+ await this.#finalizePromise;
486
+ return true;
487
+ }
488
+ this.#closeRequested = true;
489
+ if (this.activeTask !== null) {
490
+ this.requestCancel("session closed");
491
+ }
492
+ let clean = true;
493
+ if (wait) {
494
+ // Idle is only published by the owning worker after it settles, so a
495
+ // true result implies the worker has fully returned.
496
+ clean = await this.waitForIdle(options.timeoutMs);
497
+ }
498
+ else if (this.#workerActive) {
499
+ clean = false;
500
+ }
501
+ if (!clean) {
502
+ return false;
503
+ }
504
+ await this.finalizeStop();
505
+ return true;
506
+ }
507
+ finalizeStop() {
508
+ if (this.#state === SessionState.Stopped) {
509
+ // settleWorker may already have started finalization; join it so
510
+ // callers still observe the bus fully closed.
511
+ return this.#finalizePromise ?? Promise.resolve();
512
+ }
513
+ this.#state = SessionState.Stopped;
514
+ this.eventBus.publish(EventKind.SessionStopped, {
515
+ source: EventSource.Session,
516
+ session_id: this.sessionId,
517
+ payload: {},
518
+ });
519
+ // Python's close() runs bus.close() synchronously before returning, so
520
+ // subscribers are guaranteed to have seen the terminal event once the
521
+ // returned promise resolves.
522
+ this.#finalizePromise = this.eventBus.close();
523
+ return this.#finalizePromise;
524
+ }
525
+ publishInput(kind, content, strategy) {
526
+ const payload = { content };
527
+ if (strategy !== undefined) {
528
+ payload.strategy = strategy;
529
+ }
530
+ const options = {
531
+ source: EventSource.User,
532
+ session_id: this.sessionId,
533
+ payload,
534
+ };
535
+ return kind === EventKind.InputSlashCommand
536
+ ? this.eventBus.publish(EventKind.InputSlashCommand, options)
537
+ : this.eventBus.publish(EventKind.InputUserMessage, options);
538
+ }
539
+ publishRoute(routed) {
540
+ const decision = routed.decision;
541
+ const kind = decision.destination === "drop"
542
+ ? EventKind.RoutingRejected
543
+ : EventKind.RoutingDecided;
544
+ this.eventBus.publish(kind, {
545
+ source: EventSource.Router,
546
+ session_id: this.sessionId,
547
+ task_id: decision.taskId,
548
+ correlation_id: routed.event.event_id,
549
+ payload: {
550
+ destination: decision.destination,
551
+ timing: decision.timing,
552
+ strategy: decision.strategy,
553
+ confidence: decision.confidence,
554
+ reason: decision.reason,
555
+ layer: decision.layer,
556
+ },
557
+ });
558
+ }
559
+ startTask(content, inputEventId) {
560
+ const taskId = randomUUID().replaceAll("-", "");
561
+ this.taskRegistry.register(taskId);
562
+ this.setIdle(false);
563
+ this.setSessionState(SessionState.Running);
564
+ this.eventBus.publish(EventKind.TaskStarted, {
565
+ source: EventSource.Session,
566
+ session_id: this.sessionId,
567
+ task_id: taskId,
568
+ correlation_id: inputEventId,
569
+ payload: {
570
+ pending_count: this.pending.size,
571
+ held_count: this.held.size,
572
+ },
573
+ });
574
+ const workerKey = {};
575
+ this.#worker = workerKey;
576
+ this.#workerActive = true;
577
+ const worker = this.runTask(workerKey, taskId, content);
578
+ // The task body handles runner failures itself; this guard only prevents
579
+ // an unhandled rejection if event publication itself fails during
580
+ // teardown (the Python original lost those to the daemon thread as well).
581
+ void worker.catch(() => { });
582
+ return taskId;
583
+ }
584
+ async runTask(workerKey, taskId, content) {
585
+ const context = new TaskContext(this, taskId);
586
+ let currentInput = content;
587
+ let currentBatch = null;
588
+ let result = null;
589
+ try {
590
+ for (;;) {
591
+ context.cancelToken.throwIfCancelled();
592
+ this.setRunningState(taskId, TaskState.RunningModel);
593
+ context.setClaimedInput(currentBatch);
594
+ result = await this.invokeRunner(currentInput, context);
595
+ if (currentBatch !== null) {
596
+ this.ackClaimedInput(taskId, currentBatch.eventIds);
597
+ }
598
+ context.cancelToken.throwIfCancelled();
599
+ let cancelledAtBoundary = false;
600
+ // Synchronous critical section: drain or complete atomically.
601
+ const batch = this.drainPending(taskId);
602
+ if (batch.events.length > 0) {
603
+ currentInput = batch.content;
604
+ currentBatch = batch;
605
+ continue;
606
+ }
607
+ const record = this.taskRegistry.get(taskId);
608
+ cancelledAtBoundary =
609
+ record === null ||
610
+ record.state === TaskState.Cancelling ||
611
+ record.cancelToken.isCancelled();
612
+ if (!cancelledAtBoundary) {
613
+ this.taskRegistry.transition(taskId, TaskState.Completed, result === null ? {} : { result });
614
+ this.eventBus.publish(EventKind.TaskCompleted, {
615
+ source: EventSource.Session,
616
+ session_id: this.sessionId,
617
+ task_id: taskId,
618
+ payload: {
619
+ result: result ?? "",
620
+ pending_count: this.pending.size,
621
+ held_count: this.held.size,
622
+ },
623
+ });
624
+ }
625
+ if (cancelledAtBoundary) {
626
+ this.finishCancelled(taskId);
627
+ }
628
+ break;
629
+ }
630
+ }
631
+ catch (error) {
632
+ if (error instanceof CancellationError) {
633
+ this.finishCancelled(taskId);
634
+ }
635
+ else {
636
+ const record = this.taskRegistry.get(taskId);
637
+ if (record !== null && record.cancelToken.isCancelled()) {
638
+ this.finishCancelled(taskId);
639
+ }
640
+ else {
641
+ const message = error instanceof Error ? error.message : String(error);
642
+ this.holdTaskInputs(taskId, "held because its task failed");
643
+ this.taskRegistry.transition(taskId, TaskState.Failed, {
644
+ error: message,
645
+ });
646
+ this.eventBus.publish(EventKind.TaskFailed, {
647
+ source: EventSource.Session,
648
+ session_id: this.sessionId,
649
+ task_id: taskId,
650
+ payload: {
651
+ error: message,
652
+ pending_count: this.pending.size,
653
+ held_count: this.held.size,
654
+ },
655
+ });
656
+ }
657
+ }
658
+ }
659
+ finally {
660
+ this.settleWorker(workerKey);
661
+ }
662
+ }
663
+ settleWorker(workerKey) {
664
+ let shouldFinalize = false;
665
+ // A follow-up task may start after this task transitions to a terminal
666
+ // TaskState but before its worker returns. Only the worker which still
667
+ // owns the Session may publish the shared idle state; an older worker
668
+ // must not clobber its successor.
669
+ const ownsSession = this.#worker === workerKey;
670
+ if (ownsSession) {
671
+ this.#workerActive = false;
672
+ }
673
+ const noActiveTask = this.taskRegistry.active() === null;
674
+ if (ownsSession && noActiveTask) {
675
+ if (this.#state !== SessionState.Stopped) {
676
+ this.setSessionState(SessionState.Idle);
677
+ }
678
+ this.setIdle(true);
679
+ shouldFinalize = this.#closeRequested;
680
+ }
681
+ if (shouldFinalize) {
682
+ // No one awaits this path (close() already returned false or was never
683
+ // called); mirror the worker guard so a bus teardown failure cannot
684
+ // surface as an unhandled rejection.
685
+ void this.finalizeStop().catch(() => { });
686
+ }
687
+ }
688
+ /** @internal Test hook mirroring Python's ``_settle_worker``. */
689
+ _settleWorker(workerKey) {
690
+ this.settleWorker(workerKey);
691
+ }
692
+ finishCancelled(taskId) {
693
+ const record = this.taskRegistry.get(taskId);
694
+ if (record === null || TERMINAL_TASK_STATES.has(record.state)) {
695
+ return;
696
+ }
697
+ this.holdTaskInputs(taskId, "held because its task was cancelled");
698
+ this.taskRegistry.transition(taskId, TaskState.Cancelled);
699
+ this.eventBus.publish(EventKind.TaskCancelled, {
700
+ source: EventSource.Session,
701
+ session_id: this.sessionId,
702
+ task_id: taskId,
703
+ payload: {
704
+ reason: record.cancelToken.reason ?? "cancelled",
705
+ pending_count: this.pending.size,
706
+ held_count: this.held.size,
707
+ },
708
+ });
709
+ }
710
+ /** Rollback and preserve every unacknowledged input for a task. */
711
+ holdTaskInputs(taskId, reason) {
712
+ const rollback = this.#inflightRollbacks.get(taskId);
713
+ this.#inflightRollbacks.delete(taskId);
714
+ if (rollback !== undefined) {
715
+ try {
716
+ rollback();
717
+ }
718
+ catch {
719
+ // A faulty rollback must not lose the inputs being preserved.
720
+ }
721
+ }
722
+ const inflight = this.#inflightPending.get(taskId) ?? [];
723
+ this.#inflightPending.delete(taskId);
724
+ const items = [...this.pending.drainTask(taskId), ...inflight];
725
+ let heldCount = 0;
726
+ for (const pending of items) {
727
+ const held = {
728
+ event: pending.event,
729
+ decision: {
730
+ ...pending.decision,
731
+ destination: "held",
732
+ timing: "after_cancel",
733
+ strategy: "follow_up",
734
+ reason,
735
+ layer: 4,
736
+ },
737
+ };
738
+ try {
739
+ this.held.put(held);
740
+ heldCount += 1;
741
+ }
742
+ catch (error) {
743
+ if (!(error instanceof QueueOverflowError)) {
744
+ throw error;
745
+ }
746
+ this.deadLetters.put(pending.event, "held queue is full");
747
+ this.eventBus.publish(EventKind.RoutingRejected, {
748
+ source: EventSource.Router,
749
+ session_id: this.sessionId,
750
+ task_id: taskId,
751
+ correlation_id: pending.event.event_id,
752
+ payload: { reason: "held queue is full" },
753
+ });
754
+ }
755
+ }
756
+ return heldCount;
757
+ }
758
+ async invokeRunner(content, context) {
759
+ const runner = this.runner;
760
+ const fn = typeof runner === "function" ? runner : runner.run.bind(runner);
761
+ return (await fn(content, context)) ?? null;
762
+ }
763
+ /** @internal Used by {@link TaskContext}. */
764
+ setRunningState(taskId, state) {
765
+ if (state !== TaskState.RunningModel && state !== TaskState.RunningTools) {
766
+ throw new Error("TaskContext can only enter model or tool running state");
767
+ }
768
+ const record = this.taskRegistry.get(taskId);
769
+ if (record === null ||
770
+ record.state === TaskState.Cancelling ||
771
+ TERMINAL_TASK_STATES.has(record.state)) {
772
+ return false;
773
+ }
774
+ if (record.state !== state) {
775
+ this.taskRegistry.transition(taskId, state);
776
+ this.publishTaskState(taskId, state);
777
+ }
778
+ return true;
779
+ }
780
+ /** @internal Used by {@link TaskContext}. */
781
+ commitIfActive(taskId, callback) {
782
+ const record = this.taskRegistry.get(taskId);
783
+ if (record === null ||
784
+ record.state === TaskState.Cancelling ||
785
+ record.cancelToken.isCancelled()) {
786
+ return false;
787
+ }
788
+ callback();
789
+ return true;
790
+ }
791
+ /** @internal Used by {@link TaskContext}. */
792
+ commitClaimedInput(taskId, eventIds, callback, rollback) {
793
+ const record = this.taskRegistry.get(taskId);
794
+ if (record === null ||
795
+ record.state === TaskState.Cancelling ||
796
+ record.cancelToken.isCancelled()) {
797
+ return false;
798
+ }
799
+ if (eventIds.length > 0) {
800
+ const inflight = this.#inflightPending.get(taskId) ?? [];
801
+ if (!sameEventIds(inflight, eventIds)) {
802
+ return false;
803
+ }
804
+ }
805
+ callback();
806
+ if (eventIds.length > 0) {
807
+ this.#inflightRollbacks.set(taskId, rollback);
808
+ }
809
+ return true;
810
+ }
811
+ /** @internal Used by {@link TaskContext} and the worker loop. */
812
+ ackClaimedInput(taskId, eventIds) {
813
+ if (eventIds.length === 0) {
814
+ return true;
815
+ }
816
+ const record = this.taskRegistry.get(taskId);
817
+ const inflight = this.#inflightPending.get(taskId) ?? [];
818
+ if (record === null ||
819
+ record.state === TaskState.Cancelling ||
820
+ record.cancelToken.isCancelled() ||
821
+ !sameEventIds(inflight, eventIds)) {
822
+ return false;
823
+ }
824
+ this.#inflightPending.delete(taskId);
825
+ this.#inflightRollbacks.delete(taskId);
826
+ return true;
827
+ }
828
+ publishTaskState(taskId, state) {
829
+ this.eventBus.publish(EventKind.TaskStateChanged, {
830
+ source: EventSource.Session,
831
+ session_id: this.sessionId,
832
+ task_id: taskId,
833
+ payload: {
834
+ state,
835
+ state_name: state.toUpperCase(),
836
+ pending_count: this.pending.size,
837
+ held_count: this.held.size,
838
+ },
839
+ });
840
+ }
841
+ /** @internal Used by {@link TaskContext.safePoint} and the worker loop. */
842
+ drainPending(taskId) {
843
+ const record = this.taskRegistry.get(taskId);
844
+ if (record === null || record.state === TaskState.Cancelling) {
845
+ return EMPTY_BATCH;
846
+ }
847
+ if (this.#inflightPending.has(taskId)) {
848
+ throw new Error("pending batch is already in flight");
849
+ }
850
+ const events = this.scheduler.safePoint(taskId);
851
+ if (events.length > 0) {
852
+ this.#inflightPending.set(taskId, events);
853
+ this.publishTaskState(taskId, record.state);
854
+ }
855
+ return makeBatch(events);
856
+ }
857
+ setSessionState(state) {
858
+ this.#state = state;
859
+ }
860
+ setIdle(value) {
861
+ this.#idle = value;
862
+ if (value) {
863
+ const waiters = [...this.#idleWaiters];
864
+ this.#idleWaiters.clear();
865
+ for (const resolve of waiters) {
866
+ resolve();
867
+ }
868
+ }
869
+ }
870
+ }
871
+ //# sourceMappingURL=session.js.map